A complete PHP integration in four files
Let creators on your site connect their BentBox account, upload videos to it, and follow each one until it's live. Plain PHP with cURL and OpenSSL, no framework, no packages. Copy it into your site, set four environment variables, and link to it.
How it works
Everything that touches BentBox runs on your server. The creator's browser never sees a token, your client secret or a video ID.
- The creator clicks “Connect BentBox” bentbox_connect.php
It stores a random
statein an encrypted cookie and redirects to BentBox's consent screen. - They sign in and approve on BentBox
BentBox sends them back to your callback with a short-lived
codeand the samestate. - Your server exchanges the code bentbox_callback.php
It checks
state, swaps the code for tokens atPOST /oauth/tokenusing your client secret, and stores them in an encrypted, HttpOnly cookie. - The creator uploads a video bentbox_upload.php
They choose a file and say who's in it. Your server calls
POST /v1/content/videoandPOST /v1/content/upload-url; the browser sends the file straight to the presigned URL. - They follow it until it's live bentbox_upload.php
“Your uploads” checks
GET /v1/content/video/{video_id}through your server and links to BentBox when the creator needs to publish or finish a setting.
Setup
You need PHP 7.4 or later with the curl and openssl extensions, served over HTTPS, and BentBox OAuth credentials. Email api@bentbox.co for credentials, and include the URL where bentbox_callback.php will live.
- Copy the four PHP files into a folder on your siteFor example
/bentbox/. - Register the callback URL with BentBoxThe full URL of
bentbox_callback.php, exactly: same scheme, host and path, no trailing slash. - Set the environment variablesIn your web server or PHP-FPM pool config, not in the files.
- Link to
bentbox_connect.phpFrom wherever creators manage their account on your site.
| Variable | Value |
|---|---|
BENTBOX_CLIENT_ID | Your client ID, mp_live_… |
BENTBOX_CLIENT_SECRET | Your client secret, mp_secret_… |
BENTBOX_REDIRECT_URI | The callback URL you registered, exactly |
BENTBOX_COOKIE_KEY | A random 32-byte key, base64 (see below). The same on every server. |
BENTBOX_COOKIE_DOMAIN | Optional. .example.com to share the connection across www and the bare domain. |
Setup
# Generate the cookie key
php -r "echo base64_encode(random_bytes(32)).PHP_EOL;"
# PHP-FPM pool file (e.g. /etc/php/8.3/fpm/pool.d/www.conf), then reload PHP-FPM
env[BENTBOX_CLIENT_ID] = mp_live_…
env[BENTBOX_CLIENT_SECRET] = mp_secret_…
env[BENTBOX_REDIRECT_URI] = https://example.com/bentbox/bentbox_callback.php
env[BENTBOX_COOKIE_KEY] = …The files
The code below is exactly what the downloads contain.
<?php
/**
* bentbox_config.php
* Settings, token store and HTTP helpers for the BentBox integration.
* Include this at the top of every BentBox page.
*
* SECURITY MODEL
* - client_secret and all tokens live ONLY on the server.
* - The browser never receives access_token, refresh_token or connection_id.
* - Token exchange and every BentBox API call happen server-side (cURL).
* - No PHP sessions: state lives in encrypted cookies, so this works on
* several servers behind a load balancer as long as they share BB_COOKIE_KEY.
*
* SETTINGS come from environment variables (set them in your web server or
* PHP-FPM pool config). Never hard-code secrets in a web-served file.
*
* BENTBOX_CLIENT_ID your OAuth client ID (mp_live_…)
* BENTBOX_CLIENT_SECRET your OAuth client secret (mp_secret_…)
* BENTBOX_REDIRECT_URI full URL of bentbox_callback.php, exactly as registered with BentBox
* BENTBOX_COOKIE_KEY base64 32-byte key: php -r "echo base64_encode(random_bytes(32)).PHP_EOL;"
* BENTBOX_COOKIE_DOMAIN optional, e.g. ".example.com" to share the cookie across www + apex
*/
function bb_env(string $name, ?string $default = null): string {
$v = getenv($name);
if ($v === false || $v === '') {
if ($default !== null) return $default;
http_response_code(500);
exit("BentBox integration is not configured: set the $name environment variable.");
}
return $v;
}
// --- Your settings -----------------------------------------------------------
define('BB_CLIENT_ID', bb_env('BENTBOX_CLIENT_ID'));
define('BB_CLIENT_SECRET', bb_env('BENTBOX_CLIENT_SECRET'));
define('BB_REDIRECT_URI', bb_env('BENTBOX_REDIRECT_URI'));
define('BB_COOKIE_KEY', bb_env('BENTBOX_COOKIE_KEY'));
define('BB_COOKIE_DOMAIN', bb_env('BENTBOX_COOKIE_DOMAIN', '')); // '' = current host only
const BB_SCOPES = 'upload_video';
// false = the browser uploads the file straight to the presigned storage URL
// (best for large files; the short-lived URL is used by JS, never shown).
// true = the file goes browser -> your server -> storage, so nothing storage-
// related reaches the browser (heavier; limited by PHP upload limits).
const BB_PROXY_UPLOAD = false;
// Encrypted cookies
const BB_CONN_COOKIE = 'bb_conn'; // the connection (tokens)
const BB_CONN_TTL = 30 * 24 * 3600; // 30 days, long enough to keep the refresh_token
const BB_STATE_COOKIE = 'bb_oauth_state'; // CSRF state during the OAuth round trip
const BB_STATE_TTL = 15 * 60; // 15 minutes to sign in and approve
// --- BentBox endpoints -------------------------------------------------------
const BB_API_BASE = 'https://api.bentbox.co';
const BB_AUTHORIZE_URL = BB_API_BASE . '/oauth/authorize';
const BB_TOKEN_URL = BB_API_BASE . '/oauth/token';
const BB_CREATE_VIDEO = BB_API_BASE . '/v1/content/video';
const BB_UPLOAD_URL = BB_API_BASE . '/v1/content/upload-url';
const BB_VIDEO_STATUS = BB_API_BASE . '/v1/content/video/'; // + {video_id}
/** Cookie options shared by every cookie this integration sets. */
function bb_cookie_opts(int $expires): array {
$o = ['expires' => $expires, 'path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax'];
if (BB_COOKIE_DOMAIN !== '') $o['domain'] = BB_COOKIE_DOMAIN;
return $o;
}
/**
* ---- Token store (encrypted cookie, no server-side state) -------------------
* The connection (access_token, refresh_token, connection_id, user_id, scopes)
* is stored in ONE cookie, AES-256-GCM encrypted and authenticated with
* BB_COOKIE_KEY. The browser can't read or forge it (HttpOnly + GCM auth tag),
* and any of your servers can decrypt it — no sessions, no DB, no shared storage.
*
* An in-request cache mirrors writes so bb_get_connection() reflects a
* bb_store_connection() call made earlier in the SAME request (e.g. after a
* token refresh), since $_COOKIE isn't updated mid-request.
*/
function &bb_conn_cache(): array {
static $c = ['loaded' => false, 'data' => null];
return $c;
}
function bb_store_connection(array $tokens): void {
$existing = bb_get_connection() ?? [];
$data = [
'access_token' => $tokens['access_token'] ?? $existing['access_token'] ?? null,
'refresh_token' => $tokens['refresh_token'] ?? $existing['refresh_token'] ?? null,
'connection_id' => $tokens['connection_id'] ?? $existing['connection_id'] ?? null,
'user_id' => $tokens['user_id'] ?? $existing['user_id'] ?? null,
'scopes' => $tokens['scopes'] ?? $existing['scopes'] ?? [],
'expires_at' => isset($tokens['expires_in'])
? time() + (int)$tokens['expires_in']
: ($existing['expires_at'] ?? 0),
];
setcookie(BB_CONN_COOKIE, bb_cookie_encode($data), bb_cookie_opts(time() + BB_CONN_TTL));
$cache = &bb_conn_cache();
$cache['loaded'] = true;
$cache['data'] = $data;
}
function bb_get_connection(): ?array {
$cache = &bb_conn_cache();
if (!$cache['loaded']) {
$cache['data'] = bb_cookie_decode($_COOKIE[BB_CONN_COOKIE] ?? '');
$cache['loaded'] = true;
}
return $cache['data'];
}
function bb_is_connected(): bool {
$c = bb_get_connection();
return $c && !empty($c['access_token']) && !empty($c['connection_id']);
}
function bb_clear_connection(): void {
setcookie(BB_CONN_COOKIE, '', bb_cookie_opts(time() - 3600));
$cache = &bb_conn_cache();
$cache['loaded'] = true;
$cache['data'] = null;
}
/**
* ---- Cookie encryption (AES-256-GCM, url-safe base64) -----------------------
*/
function bb_cookie_key(): string {
$key = base64_decode(BB_COOKIE_KEY, true);
if ($key === false || strlen($key) !== 32) {
throw new RuntimeException(
'BENTBOX_COOKIE_KEY must be a base64-encoded 32-byte key. Generate one with: ' .
'php -r "echo base64_encode(random_bytes(32)).PHP_EOL;"'
);
}
return $key;
}
function bb_b64url(string $bin): string {
return rtrim(strtr(base64_encode($bin), '+/', '-_'), '=');
}
function bb_b64url_decode(string $s) {
return base64_decode(strtr($s, '-_', '+/'));
}
function bb_cookie_encode(array $data): string {
$iv = random_bytes(12);
$tag = '';
$ct = openssl_encrypt(json_encode($data), 'aes-256-gcm', bb_cookie_key(),
OPENSSL_RAW_DATA, $iv, $tag);
if ($ct === false) throw new RuntimeException('Cookie encryption failed');
return bb_b64url($iv . $tag . $ct); // iv(12) | tag(16) | ciphertext
}
function bb_cookie_decode(string $raw): ?array {
if ($raw === '') return null;
$bin = bb_b64url_decode($raw);
if ($bin === false || strlen($bin) < 28) return null; // 12 + 16 minimum
$iv = substr($bin, 0, 12);
$tag = substr($bin, 12, 16);
$ct = substr($bin, 28);
$pt = openssl_decrypt($ct, 'aes-256-gcm', bb_cookie_key(), OPENSSL_RAW_DATA, $iv, $tag);
if ($pt === false) return null; // tampered, wrong key, or corrupt
$data = json_decode($pt, true);
return is_array($data) ? $data : null;
}
/**
* Server-side JSON POST. Sends the token both as a Bearer header and (by the
* caller) in the body, since the content endpoints read it from the body.
*/
function bb_http_post_json(string $url, array $body, ?string $bearer = null): array {
$headers = ['Content-Type: application/json', 'Accept: application/json'];
if ($bearer) $headers[] = 'Authorization: Bearer ' . $bearer;
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($body),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => $headers,
]);
$raw = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
return ['http' => $code, 'json' => json_decode($raw, true), 'raw' => $raw, 'error' => $err];
}
/** Server-side JSON GET with a Bearer token. */
function bb_http_get_json(string $url, ?string $bearer = null): array {
$headers = ['Accept: application/json'];
if ($bearer) $headers[] = 'Authorization: Bearer ' . $bearer;
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => $headers,
]);
$raw = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
return ['http' => $code, 'json' => json_decode($raw, true), 'raw' => $raw, 'error' => $err];
}
/**
* ---- OAuth round trip -------------------------------------------------------
* bb_authorize_url() makes a random state, keeps it in a short-lived encrypted
* cookie and returns the BentBox consent URL. bb_check_state() verifies the
* state that comes back on the callback, once.
*/
function bb_authorize_url(): string {
$state = bin2hex(random_bytes(16));
setcookie(BB_STATE_COOKIE, bb_cookie_encode(['s' => $state, 't' => time()]),
bb_cookie_opts(time() + BB_STATE_TTL));
return BB_AUTHORIZE_URL . '?' . http_build_query([
'client_id' => BB_CLIENT_ID,
'redirect_uri' => BB_REDIRECT_URI,
'scope' => BB_SCOPES,
'state' => $state,
'response_type' => 'code',
]);
}
function bb_check_state(string $returned): bool {
$saved = bb_cookie_decode($_COOKIE[BB_STATE_COOKIE] ?? '');
setcookie(BB_STATE_COOKIE, '', bb_cookie_opts(time() - 3600)); // single use
return $saved
&& isset($saved['s'], $saved['t'])
&& time() - (int)$saved['t'] <= BB_STATE_TTL
&& hash_equals((string)$saved['s'], $returned);
}
/** Exchange the authorization code for tokens and store them. */
function bb_exchange_code(string $code): array {
$r = bb_http_post_json(BB_TOKEN_URL, [
'grant_type' => 'authorization_code',
'code' => $code,
'client_id' => BB_CLIENT_ID,
'client_secret' => BB_CLIENT_SECRET,
'redirect_uri' => BB_REDIRECT_URI,
]);
if (empty($r['json']['access_token']) || empty($r['json']['connection_id'])) {
return ['ok' => false, 'error' => bb_error_text($r, 'Could not connect your BentBox account')];
}
bb_store_connection($r['json']);
return ['ok' => true];
}
/**
* Refresh the access token using the stored refresh token. Updates the store
* on success (standard OAuth refresh_token grant).
*/
function bb_refresh(array &$conn): bool {
if (empty($conn['refresh_token'])) return false;
$r = bb_http_post_json(BB_TOKEN_URL, [
'grant_type' => 'refresh_token',
'refresh_token' => $conn['refresh_token'],
'client_id' => BB_CLIENT_ID,
'client_secret' => BB_CLIENT_SECRET,
]);
if (!empty($r['json']['access_token'])) {
bb_store_connection(array_merge($conn, $r['json']));
$conn = bb_get_connection();
return true;
}
return false;
}
/**
* Publish, part 1: create the video, then get a presigned upload URL.
* (Part 2, sending the file, happens in bentbox_upload.php.)
* Retries once after a token refresh on 403 (expired token).
*/
function bb_create_and_sign(array $meta, bool $allowRefresh = true): array {
$conn = bb_get_connection();
$token = $conn['access_token'];
$body = [
'access_token' => $token,
'connection_id' => $conn['connection_id'],
'title' => $meta['title'],
'filename' => $meta['filename'],
'filesize' => $meta['filesize'],
'duration' => $meta['duration'],
'description' => $meta['description'],
'tags' => $meta['tags'],
'price' => $meta['price'],
'is_premium' => $meta['is_premium'],
];
// Consent: BentBox requires sole_performer = true OR a ProntoID
// release_form_id. Only send what the creator actually chose.
if (array_key_exists('sole_performer', $meta)) {
$body['sole_performer'] = (bool)$meta['sole_performer'];
}
if (!empty($meta['release_form_id'])) {
$body['release_form_id'] = $meta['release_form_id'];
}
// Optional: false keeps the video as a draft the creator publishes on BentBox.
if (array_key_exists('publish_on_ready', $meta)) {
$body['publish_on_ready'] = (bool)$meta['publish_on_ready'];
}
$create = bb_http_post_json(BB_CREATE_VIDEO, $body, $token);
if ($create['http'] === 403 && $allowRefresh && bb_refresh($conn)) {
return bb_create_and_sign($meta, false); // retry once with fresh token
}
if (empty($create['json']['success']) || empty($create['json']['video_id'])) {
return ['ok' => false, 'stage' => 'create_metadata', 'http' => $create['http'],
'error' => bb_error_text($create, 'Failed to create metadata')];
}
$videoId = $create['json']['video_id'];
$sign = bb_http_post_json(BB_UPLOAD_URL, [
'access_token' => $token,
'connection_id' => $conn['connection_id'],
'video_id' => $videoId,
'title' => $meta['title'],
'price' => $meta['price'],
'duration' => $meta['duration'],
'description' => $meta['description'],
'tags' => $meta['tags'],
'adult_content' => $meta['adult_content'],
], $token);
if (empty($sign['json']['success']) || empty($sign['json']['upload_url'])) {
return ['ok' => false, 'stage' => 'get_upload_url', 'http' => $sign['http'],
'video_id' => $videoId,
'error' => bb_error_text($sign, 'Failed to get upload URL')];
}
return ['ok' => true, 'video_id' => $videoId,
'upload_url' => $sign['json']['upload_url'],
'content_type' => $sign['json']['content_type'] ?? 'video/mp4'];
}
/**
* Track: read a video's status (GET /v1/content/video/{video_id}).
* Returns ['ok' => true, 'status' => …, …] or ['ok' => false, 'error' => …].
* Retries once after a token refresh when the token is rejected.
*/
function bb_video_status(string $videoId, bool $allowRefresh = true): array {
$conn = bb_get_connection();
$url = BB_VIDEO_STATUS . rawurlencode($videoId)
. '?connection_id=' . rawurlencode($conn['connection_id']);
$r = bb_http_get_json($url, $conn['access_token']);
$code = $r['json']['error'] ?? '';
if ($allowRefresh && in_array($r['http'], [401, 403], true)
&& in_array($code, ['invalid_token', 'unauthorized'], true) && bb_refresh($conn)) {
return bb_video_status($videoId, false);
}
if (empty($r['json']['success'])) {
return ['ok' => false, 'http' => $r['http'], 'code' => $code,
'error' => bb_error_text($r, 'Could not read the video status')];
}
// Pass through only what the page needs.
$keep = ['status', 'status_description', 'is_live', 'title',
'video_page_url', 'video_settings_url', 'checked_at'];
return ['ok' => true] + array_intersect_key($r['json'], array_flip($keep));
}
/**
* Opaque, encrypted reference to a video for the browser, so the page can ask
* for a status without ever holding the raw video_id. Bound to the current
* connection: a ref from another connection is rejected.
*/
function bb_video_ref(string $videoId): string {
$conn = bb_get_connection();
return bb_cookie_encode(['v' => $videoId, 'c' => $conn['connection_id'] ?? '']);
}
function bb_video_from_ref(string $ref): ?string {
$d = bb_cookie_decode($ref);
$conn = bb_get_connection();
if (!$d || empty($d['v']) || ($d['c'] ?? null) !== ($conn['connection_id'] ?? '')) return null;
return (string)$d['v'];
}
/**
* Best human-readable error from a BentBox response. The API puts it in
* error_description; errors passed straight through from a Lambda often only
* have a readable sentence in "error".
*/
function bb_error_text(array $resp, string $fallback): string {
$j = $resp['json'] ?? [];
if (!empty($j['error_description'])) return (string)$j['error_description'];
if (!empty($j['error']) && strpos((string)$j['error'], ' ') !== false) return (string)$j['error'];
if (!empty($j['error'])) return $fallback . ' (' . $j['error'] . ')';
if (!empty($resp['error'])) return $fallback . ': ' . $resp['error'];
return $fallback . ' (HTTP ' . ($resp['http'] ?? 0) . ')';
}
function bb_json($payload): void {
header('Content-Type: application/json');
echo json_encode($payload);
exit;
}<?php
/**
* bentbox_connect.php
* Step 1: send the creator to BentBox to approve your platform.
*
* Link your "Connect BentBox" button here. It stores a one-time CSRF state in
* an encrypted cookie and redirects to the BentBox consent screen. If the
* creator isn't signed in to BentBox, BentBox asks them to sign in first and
* then shows the consent screen. BentBox then sends them to bentbox_callback.php.
*/
require_once __DIR__ . '/bentbox_config.php';
// Already connected? Go straight to the upload page.
if (bb_is_connected() && !isset($_GET['reconnect'])) {
header('Location: bentbox_upload.php');
exit;
}
header('Cache-Control: no-store');
header('Location: ' . bb_authorize_url());
exit;<?php
/**
* bentbox_callback.php
* Steps 2 + 3: BentBox sends the creator back here after they approve (or cancel).
*
* Its full URL must be registered with BentBox and set as BENTBOX_REDIRECT_URI,
* character for character (same scheme, host and path, no trailing slash).
*
* ?code=…&state=… approved: check state, exchange the code
* ?error=access_denied&state=… the creator cancelled
*/
require_once __DIR__ . '/bentbox_config.php';
header('Cache-Control: no-store');
$state = (string)($_GET['state'] ?? '');
$code = (string)($_GET['code'] ?? '');
$error = (string)($_GET['error'] ?? '');
$ok = false;
$message = '';
if (!bb_check_state($state)) {
// Stale tab, a reused link, or a forged request. Never exchange the code.
$message = 'This sign-in link has expired or was already used. Please try connecting again.';
} elseif ($error === 'access_denied') {
$message = 'You cancelled the connection, so nothing was changed. You can connect any time.';
} elseif ($error !== '' || $code === '') {
$message = 'BentBox didn\'t complete the connection. Please try again.';
} else {
$r = bb_exchange_code($code); // server-to-server; uses your client_secret
if ($r['ok']) {
header('Location: bentbox_upload.php?connected=1');
exit;
}
error_log('BentBox token exchange failed: ' . $r['error']);
$message = 'We couldn\'t finish connecting your BentBox account. Please try again in a moment.';
}
?><!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Connect BentBox</title>
<style>
body{font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;background:#f1f5f9;color:#0f172a;
margin:0;padding:60px 20px;line-height:1.6;}
.card{max-width:520px;margin:0 auto;background:#fff;border-radius:16px;padding:40px 32px;text-align:center;
box-shadow:0 8px 30px rgba(0,0,0,.06);}
h1{font-size:1.35rem;margin:0 0 10px;}
p{color:#64748b;margin:0 0 24px;}
a.btn{display:inline-block;background:#2563eb;color:#fff;text-decoration:none;padding:12px 24px;
border-radius:10px;font-weight:700;}
a.alt{display:block;margin-top:14px;color:#64748b;font-size:.9rem;}
</style>
</head>
<body>
<div class="card">
<h1>BentBox isn't connected</h1>
<p><?= htmlspecialchars($message, ENT_QUOTES, 'UTF-8') ?></p>
<a class="btn" href="bentbox_connect.php">Connect BentBox</a>
<a class="alt" href="/">Back to the site</a>
</div>
</body>
</html><?php
/**
* bentbox_upload.php
* Steps 4 + 5: the upload page for a connected creator, and each video's status.
*
* The browser only ever calls THIS page. Tokens stay server-side; the page
* never renders an access_token, connection_id, video_id or presigned URL.
*
* AJAX actions (all require an active connection):
* ?action=prepare (direct mode) JSON metadata in -> {upload_url, ref} out
* ?action=upload (proxy mode) multipart in -> streams to S3 server-side, {ref} out
* ?action=status&ref=… -> the video's BentBox status
* ?action=disconnect clears the stored connection
*
* "ref" is an encrypted, connection-bound reference to the video (see
* bb_video_ref), so the browser can ask for a status without the video_id.
*/
require_once __DIR__ . '/bentbox_config.php';
// --------------------------------------------------------------------------
// AJAX ACTIONS (run and exit before any HTML)
// --------------------------------------------------------------------------
$action = $_GET['action'] ?? null;
if ($action === 'disconnect') {
bb_clear_connection();
bb_json(['ok' => true]);
}
if ($action !== null) {
// Everything below requires a live connection.
if (!bb_is_connected()) {
http_response_code(401);
bb_json(['ok' => false, 'error' => 'not_connected']);
}
}
/** Normalise metadata from either a JSON body or POST fields. */
function read_meta(array $src): array {
$tags = $src['tags'] ?? [];
if (is_string($tags)) {
$tags = array_filter(array_map('trim', explode(',', $tags)));
}
return [
'title' => trim($src['title'] ?? ''),
'filename' => trim($src['filename'] ?? ''),
'filesize' => (int)($src['filesize'] ?? 0),
'duration' => (float)($src['duration'] ?? 0),
'description' => trim($src['description'] ?? ''),
'price' => (float)($src['price'] ?? 0),
'is_premium' => filter_var($src['is_premium'] ?? false, FILTER_VALIDATE_BOOLEAN),
'adult_content' => filter_var($src['adult_content'] ?? true, FILTER_VALIDATE_BOOLEAN),
'tags' => array_values($tags),
] + read_consent($src);
}
/**
* Consent for POST /v1/content/video: exactly one of
* sole_performer = true (the creator is the only person in the video)
* release_form_id = "<ProntoID>" (signed release covering the other performers)
* Keys are only included when set, so they pass straight through to the API.
*/
function read_consent(array $src): array {
$performers = $src['performers'] ?? '';
if ($performers === 'solo') {
return ['sole_performer' => true];
}
if ($performers === 'others') {
$rid = trim((string)($src['release_form_id'] ?? ''));
return ['sole_performer' => false, 'release_form_id' => $rid];
}
return [];
}
function validate_meta(array $m): ?string {
if ($m['title'] === '') return 'A title is required.';
if ($m['filename'] === '') return 'A filename is required.';
if ($m['filesize'] <= 0) return 'File size could not be determined.';
if ($m['duration'] <= 0) return 'Video duration could not be determined.';
if (!array_key_exists('sole_performer', $m)) {
return 'Choose who appears in the video.';
}
if ($m['sole_performer'] === false) {
$rid = $m['release_form_id'] ?? '';
if ($rid === '') return 'Enter the ProntoID release form ID for the other performers.';
if (strlen($rid) > 128 || !preg_match('/^[A-Za-z0-9_.:-]+$/', $rid)) {
return 'That release form ID doesn\'t look right. Copy it exactly as ProntoID shows it.';
}
}
return null;
}
// ---- DIRECT MODE: return only the presigned URL to the browser -------------
if ($action === 'prepare') {
$in = json_decode(file_get_contents('php://input'), true) ?: [];
$m = read_meta($in);
if ($err = validate_meta($m)) bb_json(['ok' => false, 'error' => $err]);
$r = bb_create_and_sign($m);
if (!$r['ok']) {
bb_json(['ok' => false, 'error' => $r['error'], 'stage' => $r['stage'] ?? null]);
}
// The presigned URL is used by JS but not displayed anywhere.
bb_json(['ok' => true, 'upload_url' => $r['upload_url'],
'content_type' => $r['content_type'],
'ref' => bb_video_ref($r['video_id'])]);
}
// ---- STATUS: where is this video on BentBox? --------------------------------
if ($action === 'status') {
$videoId = bb_video_from_ref((string)($_GET['ref'] ?? ''));
if ($videoId === null) {
bb_json(['ok' => false, 'error' => 'unknown_video']);
}
$s = bb_video_status($videoId);
if (!$s['ok'] && ($s['code'] ?? '') === 'not_found') {
bb_json(['ok' => false, 'error' => 'unknown_video']);
}
bb_json($s);
}
// ---- PROXY MODE: browser -> this server -> S3 (nothing S3 reaches browser) --
if ($action === 'upload') {
if (!isset($_FILES['file']['tmp_name']) || !is_uploaded_file($_FILES['file']['tmp_name'])) {
bb_json(['ok' => false, 'error' => 'No file received.']);
}
$m = read_meta($_POST);
if ($err = validate_meta($m)) bb_json(['ok' => false, 'error' => $err]);
$r = bb_create_and_sign($m);
if (!$r['ok']) {
bb_json(['ok' => false, 'error' => $r['error'], 'stage' => $r['stage'] ?? null]);
}
// Stream the temp file straight to S3 (no full in-memory buffering).
$tmp = $_FILES['file']['tmp_name'];
$fh = fopen($tmp, 'rb');
$ch = curl_init($r['upload_url']);
curl_setopt_array($ch, [
CURLOPT_PUT => true,
CURLOPT_INFILE => $fh,
CURLOPT_INFILESIZE => filesize($tmp),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 0,
CURLOPT_HTTPHEADER => ['Content-Type: ' . $r['content_type']],
]);
curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
fclose($fh);
$ok = ($code >= 200 && $code < 300);
bb_json(['ok' => $ok, 'http' => $code] + ($ok ? ['ref' => bb_video_ref($r['video_id'])] : []));
}
// --------------------------------------------------------------------------
// PAGE RENDER
// --------------------------------------------------------------------------
$connected = bb_is_connected();
$conn = bb_get_connection();
?><!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Upload to BentBox</title>
<style>
:root{--bl:#2563eb;--ink:#0f172a;--mut:#64748b;--brd:#e2e8f0;--ok:#10b981;--err:#ef4444;}
*{box-sizing:border-box;}
body{font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;background:#f1f5f9;
color:var(--ink);margin:0;padding:40px 20px;line-height:1.6;}
.wrap{max-width:600px;margin:0 auto;}
.topbar{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px;}
.brand{font-weight:800;font-size:1.25rem;}
.conn{font-size:.82rem;color:var(--mut);}
.conn a{color:var(--mut);}
.card{background:#fff;border-radius:16px;padding:32px;box-shadow:0 8px 30px rgba(0,0,0,.06);}
h1{font-size:1.5rem;margin:0 0 6px;}
.sub{color:var(--mut);margin:0 0 24px;}
label{display:block;font-weight:600;font-size:.85rem;margin:16px 0 6px;}
input[type=text],input[type=number],textarea{width:100%;padding:12px 14px;border:1px solid var(--brd);
border-radius:10px;font:inherit;font-size:.95rem;}
textarea{min-height:80px;resize:vertical;}
.row{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
.drop{border:2px dashed var(--brd);border-radius:12px;padding:28px;text-align:center;
color:var(--mut);cursor:pointer;transition:.15s;background:#f8fafc;}
.drop.hl{border-color:var(--bl);background:#eff6ff;color:var(--bl);}
.drop strong{color:var(--ink);}
.fileinfo{font-size:.82rem;color:var(--mut);margin-top:10px;}
.chk{display:flex;align-items:center;gap:8px;margin-top:14px;font-size:.9rem;font-weight:600;}
.chk input{width:auto;}
.btn{width:100%;margin-top:24px;background:var(--bl);color:#fff;border:none;border-radius:11px;
padding:15px;font:inherit;font-weight:700;font-size:1rem;cursor:pointer;transition:.15s;}
.btn:hover{background:#1d4ed8;}
.btn:disabled{opacity:.5;cursor:not-allowed;}
.progress{height:10px;background:var(--brd);border-radius:6px;overflow:hidden;margin-top:20px;display:none;}
.progress>div{height:100%;width:0;background:var(--bl);transition:width .2s;}
.msg{margin-top:18px;padding:14px 16px;border-radius:10px;font-size:.92rem;display:none;}
.msg.ok{background:#ecfdf5;color:#065f46;display:block;}
.msg.err{background:#fef2f2;color:#991b1b;display:block;}
fieldset{border:0;padding:0;margin:20px 0 0;}
legend{font-weight:600;font-size:.85rem;margin-bottom:8px;padding:0;}
.opt{display:flex;gap:10px;align-items:flex-start;padding:12px 14px;border:1px solid var(--brd);
border-radius:10px;margin-top:8px;cursor:pointer;font-size:.92rem;}
.opt:has(input:checked){border-color:var(--bl);background:#eff6ff;}
.opt input{margin-top:4px;}
.opt span{display:block;color:var(--mut);font-size:.82rem;font-weight:400;}
.release{margin:10px 0 0 30px;display:none;}
.release.show{display:block;}
.release input{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;}
.hint{font-size:.8rem;color:var(--mut);margin-top:6px;}
/* Your uploads */
.uploads{margin-top:24px;}
.uploads h2{font-size:1.05rem;margin:0 0 4px;}
.uploads .sub{margin-bottom:14px;font-size:.88rem;}
.vid{border:1px solid var(--brd);border-radius:12px;padding:16px 18px;margin-top:10px;}
.vid.new{border-color:var(--bl);box-shadow:0 0 0 3px #dbeafe;}
.vid-head{display:flex;justify-content:space-between;gap:12px;align-items:baseline;}
.vid-title{font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.chip{flex-shrink:0;font-size:.76rem;font-weight:700;padding:3px 9px;border-radius:999px;background:#f1f5f9;color:var(--mut);}
.chip.wait{background:#eff6ff;color:#1d4ed8;}
.chip.act{background:#fffbeb;color:#b45309;}
.chip.live{background:#ecfdf5;color:#047857;}
.chip.stop{background:#fef2f2;color:#b91c1c;}
.track{display:grid;grid-template-columns:repeat(4,1fr);gap:4px;margin:12px 0 6px;list-style:none;padding:0;}
.track li{font-size:.72rem;color:var(--mut);padding-top:8px;border-top:3px solid var(--brd);}
.track li.done{border-top-color:var(--bl);color:var(--ink);}
.track li.now{border-top-color:var(--bl);color:var(--ink);font-weight:700;}
.track.act li.now{border-top-color:#f59e0b;}
.track.live li{border-top-color:#10b981;color:var(--ink);}
.track.stop li.now{border-top-color:var(--err);}
.vid-desc{font-size:.88rem;color:var(--mut);margin:6px 0 0;}
.vid-foot{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-top:12px;flex-wrap:wrap;}
.vid-link{display:inline-block;font-size:.88rem;font-weight:700;color:#fff;background:var(--bl);text-decoration:none;padding:8px 14px;border-radius:9px;}
.vid-link.quiet{background:#fff;color:var(--ink);border:1px solid var(--brd);}
.vid-meta{font-size:.76rem;color:var(--mut);}
.linkbtn{background:none;border:0;color:var(--bl);font:inherit;font-size:.76rem;cursor:pointer;padding:0;text-decoration:underline;}
.connect-card{text-align:center;padding:48px 32px;}
.connect-card p{color:var(--mut);}
.connect-btn{display:inline-block;background:var(--bl);color:#fff;text-decoration:none;
padding:14px 30px;border-radius:11px;font-weight:700;margin-top:12px;}
</style>
</head>
<body>
<div class="wrap">
<div class="topbar">
<div class="brand">Your site</div>
<?php if ($connected): ?>
<div class="conn">Connected to BentBox · <a href="#" id="disconnect">Disconnect</a></div>
<?php endif; ?>
</div>
<?php if (!$connected): ?>
<div class="card connect-card">
<h1>Connect your BentBox account</h1>
<p>To publish videos to BentBox, connect your account once. You'll be asked to approve access, then brought back here to upload.</p>
<a class="connect-btn" href="bentbox_connect.php">Connect BentBox Account</a>
</div>
<?php else: ?>
<div class="card">
<h1>Upload a video</h1>
<p class="sub">Your video will be published to your BentBox profile.</p>
<div class="drop" id="drop">
<strong>Choose a video</strong> or drag it here
<div class="fileinfo" id="fileinfo">MP4, MOV — up to 2 GB</div>
<input type="file" id="file" accept="video/*" hidden>
</div>
<label>Title</label>
<input type="text" id="title" maxlength="200" placeholder="Give your video a title">
<label>Description</label>
<textarea id="description" maxlength="5000" placeholder="Optional"></textarea>
<div class="row">
<div>
<label>Price (USD)</label>
<input type="number" id="price" min="0" max="9999.99" step="0.01" value="0">
</div>
<div>
<label>Tags</label>
<input type="text" id="tags" placeholder="comma, separated">
</div>
</div>
<fieldset id="performers">
<legend>Who's in this video?</legend>
<label class="opt"><input type="radio" name="performers" value="solo">
<div>Only me<span>You're the only person who appears in the video.</span></div></label>
<label class="opt"><input type="radio" name="performers" value="others">
<div>Me and other performers<span>Everyone else must have signed a release on ProntoID.</span></div></label>
<div class="release" id="release">
<label for="release_form_id" style="margin-top:6px">ProntoID release form ID</label>
<input type="text" id="release_form_id" maxlength="128" autocomplete="off" spellcheck="false" placeholder="e.g. pid_rel_a1b2c3">
<div class="hint">Copy it from the signed release in ProntoID. One release can cover several performers.</div>
</div>
</fieldset>
<label class="chk"><input type="checkbox" id="is_premium"> Premium content</label>
<label class="chk"><input type="checkbox" id="adult_content" checked> Adult content (18+)</label>
<button class="btn" id="submit">Publish to BentBox</button>
<div class="progress" id="progress"><div id="bar"></div></div>
<div class="msg" id="msg"></div>
</div>
<div class="card uploads" id="uploads" hidden>
<h2>Your uploads</h2>
<p class="sub">Videos you've sent to BentBox from here, and where each one is. This updates by itself.</p>
<div id="vids"></div>
</div>
<?php endif; ?>
</div>
<script>
const PROXY = <?= BB_PROXY_UPLOAD ? 'true' : 'false' ?>;
</script>
<script>
(function(){
const $ = id => document.getElementById(id);
if (!$('submit')) {
// Not connected — only wire up disconnect if present.
if ($('disconnect')) wireDisconnect();
return;
}
let meta = { filename:'', filesize:0, duration:0 };
const drop = $('drop'), fileInput = $('file');
drop.addEventListener('click', () => fileInput.click());
['dragover','dragenter'].forEach(e => drop.addEventListener(e, ev => {
ev.preventDefault(); drop.classList.add('hl');
}));
['dragleave','drop'].forEach(e => drop.addEventListener(e, ev => {
ev.preventDefault(); drop.classList.remove('hl');
}));
drop.addEventListener('drop', ev => { if (ev.dataTransfer.files[0]) setFile(ev.dataTransfer.files[0]); });
fileInput.addEventListener('change', () => { if (fileInput.files[0]) setFile(fileInput.files[0]); });
function setFile(f){
fileInput._file = f;
meta.filename = f.name; meta.filesize = f.size; meta.duration = 0;
if (!$('title').value) $('title').value = f.name.replace(/\.[^.]+$/, '');
const v = document.createElement('video');
v.preload = 'metadata';
v.onloadedmetadata = () => { URL.revokeObjectURL(v.src); meta.duration = Math.round((v.duration||0)*10)/10; info(); };
v.onerror = info;
v.src = URL.createObjectURL(f);
info();
}
function info(){
const mb = (meta.filesize/1048576).toFixed(1);
$('fileinfo').innerHTML = '<strong>'+meta.filename+'</strong> · '+mb+' MB · '+(meta.duration||'…')+'s';
}
function showMsg(text, ok){ const m=$('msg'); m.className='msg '+(ok?'ok':'err'); m.textContent=text; }
// Show the release field only for "Me and other performers"
const performers = () => (document.querySelector('input[name="performers"]:checked') || {}).value || '';
document.querySelectorAll('input[name="performers"]').forEach(r => r.addEventListener('change', () => {
const others = performers() === 'others';
$('release').classList.toggle('show', others);
if (others) $('release_form_id').focus();
}));
$('submit').addEventListener('click', async function(){
const f = fileInput._file;
if (!f) return showMsg('Please choose a video file first.', false);
if (!$('title').value.trim()) return showMsg('Please add a title.', false);
if (!performers()) return showMsg('Choose who appears in the video.', false);
if (performers() === 'others' && !$('release_form_id').value.trim()) {
$('release_form_id').focus();
return showMsg('Enter the ProntoID release form ID for the other performers.', false);
}
this.disabled = true;
$('progress').style.display = 'block';
$('bar').style.width = '0%';
$('msg').className = 'msg';
const md = {
title: $('title').value.trim(),
filename: meta.filename, filesize: meta.filesize, duration: meta.duration,
description: $('description').value.trim(),
price: parseFloat($('price').value)||0,
is_premium: $('is_premium').checked,
adult_content: $('adult_content').checked,
tags: $('tags').value.split(',').map(t=>t.trim()).filter(Boolean).slice(0,20),
performers: performers(),
release_form_id: performers() === 'others' ? $('release_form_id').value.trim() : '',
};
try {
const ref = PROXY ? await proxyUpload(f, md) : await directUpload(f, md);
$('bar').style.width = '100%';
showMsg('Uploaded. BentBox is processing your video; follow it under “Your uploads” below.', true);
this.disabled = false;
if (ref) Uploads.add(ref, md.title);
} catch (e) {
this.disabled = false;
showMsg('Upload failed: ' + e.message, false);
}
});
// Direct: ask our server for a presigned URL, then PUT straight to S3.
async function directUpload(f, md){
const r = await fetch('bentbox_upload.php?action=prepare', {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(md)
});
const j = await r.json();
if (!j.ok) throw new Error(j.error || 'preparation failed');
await putWithProgress(j.upload_url, f, f.type || j.content_type || 'video/mp4');
return j.ref;
}
function putWithProgress(url, f, ct){
return new Promise((res, rej) => {
const x = new XMLHttpRequest();
x.open('PUT', url, true);
x.setRequestHeader('Content-Type', ct);
x.upload.onprogress = e => { if (e.lengthComputable) $('bar').style.width = Math.round(e.loaded/e.total*100)+'%'; };
x.onload = () => (x.status>=200 && x.status<300) ? res() : rej(new Error('storage returned '+x.status));
x.onerror = () => rej(new Error('network error (if this is CORS, enable proxy mode)'));
x.send(f);
});
}
// Proxy: send the file to our server, which relays it to S3.
function proxyUpload(f, md){
return new Promise((res, rej) => {
const fd = new FormData();
Object.entries(md).forEach(([k,v]) => fd.append(k, Array.isArray(v)?v.join(','):v));
fd.append('file', f);
const x = new XMLHttpRequest();
x.open('POST', 'bentbox_upload.php?action=upload', true);
x.upload.onprogress = e => { if (e.lengthComputable) $('bar').style.width = Math.round(e.loaded/e.total*100)+'%'; };
x.onload = () => { let j={}; try{j=JSON.parse(x.responseText);}catch(_){}
j.ok ? res(j.ref) : rej(new Error(j.error || ('server returned '+x.status))); };
x.onerror = () => rej(new Error('network error'));
x.send(fd);
});
}
// ------------------------------------------------------------------
// Your uploads: status from GET /v1/content/video/{id}, via ?action=status
// ------------------------------------------------------------------
const Uploads = (function(){
const KEY = 'bb_uploads', MAX = 5;
const STEPS = ['Uploaded', 'Processing', 'In review', 'Live'];
// status -> [chip label, tone, current step index, label for that step, what it means for you]
const VIEW = {
awaiting_upload: ['Waiting for file', 'wait', 0, null, 'BentBox hasn’t received the file yet.'],
processing: ['Processing', 'wait', 1, null, 'BentBox is preparing your video. This usually takes a few minutes.'],
draft: ['Draft', 'act', 2, 'Publish', 'Your video is ready but not published. Publish it on BentBox when you’re ready.'],
pending_review: ['In review', 'wait', 2, null, 'Your video is published and waiting for BentBox review. It goes live once approved.'],
action_required: ['Needs your input', 'act', 2, 'Finish settings', 'Approved! Finish your video’s settings on BentBox so it can go live.'],
live: ['Live', 'live', 3, null, 'Your video is live on BentBox.'],
upload_expired: ['Upload expired', 'stop', 0, null, 'The file didn’t arrive within 24 hours. Upload the video again.'],
processing_failed: ['Processing failed','stop', 1, null, 'BentBox couldn’t process this file. Check it plays correctly, then upload it again.'],
removed: ['Removed', 'stop', 3, null, 'This video has been removed from BentBox.'],
};
const DONE = ['live', 'removed', 'upload_expired', 'processing_failed'];
let items = load(), timer = null, startedAt = Date.now(), newest = null;
function load(){ try { return JSON.parse(localStorage.getItem(KEY)) || []; } catch(_) { return []; } }
function save(){ try { localStorage.setItem(KEY, JSON.stringify(items.map(({ref,title,at,s}) => ({ref,title,at,s})))); } catch(_){} }
function esc(s){ return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
function safeUrl(u){ return /^https:\/\/bentbox\.co\//.test(u || '') ? u : null; }
function add(ref, title){
items = [{ref, title, at: Date.now(), s: {status:'processing'}}].concat(items).slice(0, MAX);
newest = ref; startedAt = Date.now(); save(); render(); refresh();
}
function render(){
$('uploads').hidden = items.length === 0;
$('vids').innerHTML = items.map(it => {
const s = it.s || {}, v = VIEW[s.status] || ['Checking…', '', 0, null, null];
const steps = STEPS.map((name, i) => {
const cls = s.status === 'live' ? 'done' : (i < v[2] ? 'done' : (i === v[2] ? 'now' : ''));
return '<li class="'+cls+'">'+esc(i === v[2] && v[3] ? v[3] : name)+'</li>';
}).join('');
const page = safeUrl(s.video_page_url), settings = safeUrl(s.video_settings_url);
let action = '';
if (s.status === 'live' && page)
action = '<a class="vid-link" href="'+esc(page)+'" target="_blank" rel="noopener">View on BentBox</a>';
else if ((s.status === 'draft' || s.status === 'action_required') && settings)
action = '<a class="vid-link" href="'+esc(settings)+'" target="_blank" rel="noopener">'+(s.status === 'draft' ? 'Publish on BentBox' : 'Finish on BentBox')+'</a>';
else if (settings && !DONE.includes(s.status))
action = '<a class="vid-link quiet" href="'+esc(settings)+'" target="_blank" rel="noopener">Edit on BentBox</a>';
const checked = it.checked ? 'Checked ' + new Date(it.checked).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'}) : '';
return '<div class="vid'+(it.ref === newest ? ' new' : '')+'">'
+ '<div class="vid-head"><span class="vid-title">'+esc(s.title || it.title || 'Untitled video')+'</span>'
+ '<span class="chip '+v[1]+'">'+esc(v[0])+'</span></div>'
+ '<ol class="track '+v[1]+'">'+steps+'</ol>'
+ '<p class="vid-desc">'+esc(it.err || v[4] || s.status_description || 'Checking with BentBox…')+'</p>'
+ '<div class="vid-foot"><span>'+action+'</span>'
+ '<span class="vid-meta">'+checked+(checked ? ' · ' : '')+'<button class="linkbtn" data-check>Check now</button></span></div>'
+ '</div>';
}).join('');
}
async function check(it){
try {
const r = await fetch('bentbox_upload.php?action=status&ref=' + encodeURIComponent(it.ref));
const j = await r.json();
if (j.ok) { it.s = j; it.err = null; }
else if (j.error === 'unknown_video' || j.error === 'not_connected') { it.gone = true; }
else { it.err = 'Couldn’t check right now. We’ll try again shortly.'; }
} catch(_) { it.err = 'Couldn’t reach the server. We’ll try again shortly.'; }
it.checked = Date.now();
}
async function refresh(){
clearTimeout(timer);
const open = items.filter(it => !DONE.includes((it.s || {}).status) || !it.checked);
await Promise.all(open.map(check));
items = items.filter(it => !it.gone); // drop videos from another connection or deleted refs
save(); render(); schedule();
}
// Poll quickly while processing, slowly during review, never once everything is final.
function schedule(){
const st = items.map(it => (it.s || {}).status);
if (st.every(s => DONE.includes(s))) return;
if (Date.now() - startedAt > 30 * 60 * 1000) return; // stop after 30 min; "Check now" restarts
const busy = st.some(s => s === 'processing' || s === 'awaiting_upload' || !s);
timer = setTimeout(() => { if (!document.hidden) refresh(); else schedule(); }, busy ? 10000 : 60000);
}
$('vids').addEventListener('click', e => {
if (e.target.matches('[data-check]')) { startedAt = Date.now(); refresh(); }
});
document.addEventListener('visibilitychange', () => { if (!document.hidden && items.length) refresh(); });
if (items.length) { render(); refresh(); }
return { add, clear(){ items = []; save(); } };
})();
wireDisconnect();
function wireDisconnect(){
const d = $('disconnect'); if (!d) return;
d.addEventListener('click', async e => {
e.preventDefault();
try { localStorage.removeItem('bb_uploads'); } catch(_){}
await fetch('bentbox_upload.php?action=disconnect');
location.href = 'bentbox_upload.php';
});
}
})();
</script>
</body>
</html># BentBox PHP sample integration
Four PHP files that let creators on your site connect their BentBox account,
upload videos to it, and follow each video until it's live.
| File | What it does |
|---|---|
| `bentbox_config.php` | Settings, encrypted-cookie token store, and the calls to the BentBox API |
| `bentbox_connect.php` | Step 1: sends the creator to BentBox to approve your platform |
| `bentbox_callback.php` | Steps 2–3: checks the return, exchanges the code for tokens, stores them |
| `bentbox_upload.php` | Steps 4–5: upload form (create, sign, upload) and the "Your uploads" status list |
No framework, no Composer packages. Needs PHP 7.4 or later with the `curl`
and `openssl` extensions, and HTTPS.
## Setup
1. **Copy the four files** into a folder on your site, served over HTTPS.
2. **Register your redirect URI.** Send BentBox (api@bentbox.co) the full URL of
`bentbox_callback.php`, for example `https://example.com/bentbox/bentbox_callback.php`.
3. **Set the environment variables** in your web server or PHP-FPM pool:
| Variable | Value |
|---|---|
| `BENTBOX_CLIENT_ID` | Your client ID (`mp_live_…`) |
| `BENTBOX_CLIENT_SECRET` | Your client secret (`mp_secret_…`) |
| `BENTBOX_REDIRECT_URI` | The callback URL from step 2, exactly |
| `BENTBOX_COOKIE_KEY` | `php -r "echo base64_encode(random_bytes(32)).PHP_EOL;"` |
| `BENTBOX_COOKIE_DOMAIN` | Optional, e.g. `.example.com` to share across www and apex |
With PHP-FPM, for example: `env[BENTBOX_CLIENT_ID] = mp_live_…` in the pool file.
4. **Link to `bentbox_connect.php`** from your site ("Connect BentBox").
## How it works
1. `bentbox_connect.php` stores a random `state` in an encrypted cookie and
redirects to `https://api.bentbox.co/oauth/authorize`.
2. The creator signs in to BentBox and approves. BentBox redirects to
`bentbox_callback.php?code=…&state=…`.
3. The callback checks `state`, then exchanges the code server to server at
`/oauth/token` and stores the tokens in an encrypted, HttpOnly cookie.
4. On `bentbox_upload.php` the creator picks a file and says who's in it.
Your server calls `POST /v1/content/video` and `POST /v1/content/upload-url`;
the browser then `PUT`s the file to the presigned URL.
5. The page polls `GET /v1/content/video/{video_id}` through your server and
shows each video's status, with a link to BentBox when the creator needs to act.
## Security notes
- Tokens, the client secret and video IDs never reach the browser. The status
list uses encrypted references that only work for the connection that made them.
- Every server behind a load balancer must share the same `BENTBOX_COOKIE_KEY`.
Changing it signs every creator out of BentBox on your site.
- Ask creators who appears in each video. Send `sole_performer: true` only when
they're the only person in it; otherwise a ProntoID `release_form_id`.
Full API reference: https://api.bentbox.co/docsBefore you go live
- Consent. The upload form asks who appears in the video. Keep that question: BentBox rejects uploads without
sole_performer: trueor a ProntoIDrelease_form_id. - Secrets. Keep the client secret and cookie key in environment variables or a secrets manager. Never commit them or put them in web-served files.
- Errors. Turn
display_errorsoff in production. The sample logs details witherror_log()and shows creators plain messages. - Large files. Keep
BB_PROXY_UPLOADatfalseso the browser uploads straight to storage. Proxy mode sends the file through PHP and is limited byupload_max_filesizeandpost_max_size. - Your own look. The upload page's styles are self-contained in one
<style>block; restyle it to match your site.