Publish the unified MIT-licensed Stuffbox SDK source
Hop-State: A_06FPAF0M83E7T0JJ7EP2WQ0 Hop-Proposal: R_06FPAF04S0D4G8TA8TNK3C0 Hop-Task: T_06FPADTV2YD4PD5GMTK3150 Hop-Attempt: AT_06FPADTV2ZYKEY8GH3JTK98
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Integration examples
|
||||
|
||||
- [`server-connection.ts`](server-connection.ts) shows PKCE connection, authorization-code exchange, and gallery pagination on an application server.
|
||||
- [`react-upload.tsx`](react-upload.tsx) shows a native-feeling upload control using `@stuffbox/sdk/react` and app-owned same-origin endpoints.
|
||||
|
||||
The same-origin endpoints in the React example are intentional. Keep Stuffbox access and refresh tokens on your server; return only the short-lived upload session or completed asset to browser code.
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { ChangeEvent } from "react";
|
||||
import {
|
||||
useStuffboxUpload,
|
||||
type UploadTransport,
|
||||
} from "@stuffbox/sdk/react";
|
||||
|
||||
async function responseJson<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload endpoint failed with status ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const uploadTransport: UploadTransport = {
|
||||
async createUpload(input, options) {
|
||||
const response = await fetch("/api/stuffbox/uploads", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
...(options?.signal ? { signal: options.signal } : {}),
|
||||
});
|
||||
return responseJson(response);
|
||||
},
|
||||
|
||||
async completeUpload(uploadId, input, options) {
|
||||
const response = await fetch(`/api/stuffbox/uploads/${uploadId}/complete`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(input ?? {}),
|
||||
...(options?.signal ? { signal: options.signal } : {}),
|
||||
});
|
||||
return responseJson(response);
|
||||
},
|
||||
};
|
||||
|
||||
export function StuffboxUploadButton() {
|
||||
const { state, upload, abort, reset } = useStuffboxUpload(uploadTransport);
|
||||
|
||||
function chooseFile(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.currentTarget.files?.[0];
|
||||
if (file) void upload(file).catch(() => undefined);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input type="file" onChange={chooseFile} disabled={state.status === "uploading"} />
|
||||
|
||||
{state.status === "uploading" && (
|
||||
<button type="button" onClick={abort}>
|
||||
Cancel upload ({Math.round(state.progress.percent)}%)
|
||||
</button>
|
||||
)}
|
||||
|
||||
{state.status === "complete" && (
|
||||
<p>
|
||||
Uploaded <a href={state.asset.url}>{state.asset.filename}</a>{" "}
|
||||
<button type="button" onClick={reset}>Upload another</button>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{state.status === "error" && <p role="alert">{state.error.message}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
StuffboxClient,
|
||||
createPkcePair,
|
||||
generateState,
|
||||
type Asset,
|
||||
type StuffboxScope,
|
||||
type TokenSet,
|
||||
} from "@stuffbox/sdk";
|
||||
|
||||
const stuffbox = new StuffboxClient({ baseUrl: "https://stuffbox.xyz" });
|
||||
|
||||
export interface PendingConnection {
|
||||
clientId: string;
|
||||
callbackUrl: string;
|
||||
codeVerifier: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
interface BeginConnectionInput {
|
||||
callbackUrl: string;
|
||||
scopes: readonly StuffboxScope[];
|
||||
clientId?: string;
|
||||
savePending(value: PendingConnection): Promise<void>;
|
||||
}
|
||||
|
||||
export async function beginConnection(
|
||||
input: BeginConnectionInput,
|
||||
): Promise<string> {
|
||||
const pkce = await createPkcePair();
|
||||
const state = generateState();
|
||||
const common = {
|
||||
callbackUrl: input.callbackUrl,
|
||||
codeChallenge: pkce.challenge,
|
||||
scopes: input.scopes,
|
||||
state,
|
||||
};
|
||||
const connection = await stuffbox.createConnectionRequest(
|
||||
input.clientId
|
||||
? { ...common, clientId: input.clientId }
|
||||
: { ...common, selfHosted: true },
|
||||
);
|
||||
|
||||
await input.savePending({
|
||||
clientId: connection.clientId,
|
||||
callbackUrl: connection.callbackUrl,
|
||||
codeVerifier: pkce.verifier,
|
||||
state,
|
||||
});
|
||||
|
||||
return connection.authorizationUrl;
|
||||
}
|
||||
|
||||
interface FinishConnectionInput {
|
||||
code: string;
|
||||
returnedState: string;
|
||||
pending: PendingConnection;
|
||||
}
|
||||
|
||||
export async function finishConnection(
|
||||
input: FinishConnectionInput,
|
||||
): Promise<TokenSet> {
|
||||
if (input.returnedState !== input.pending.state) {
|
||||
throw new Error("Stuffbox connection state did not match");
|
||||
}
|
||||
|
||||
return stuffbox.exchangeAuthorizationCode({
|
||||
clientId: input.pending.clientId,
|
||||
code: input.code,
|
||||
codeVerifier: input.pending.codeVerifier,
|
||||
redirectUri: input.pending.callbackUrl,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadEntireLibrary(accessToken: string): Promise<Asset[]> {
|
||||
const media = new StuffboxClient({
|
||||
baseUrl: "https://stuffbox.xyz",
|
||||
accessToken,
|
||||
});
|
||||
const assets: Asset[] = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
do {
|
||||
const page = await media.listAssets({
|
||||
limit: 100,
|
||||
...(cursor ? { cursor } : {}),
|
||||
});
|
||||
assets.push(...page.items);
|
||||
cursor = page.nextCursor;
|
||||
} while (cursor);
|
||||
|
||||
return assets;
|
||||
}
|
||||
Reference in New Issue
Block a user