249 lines
6.6 KiB
TypeScript
249 lines
6.6 KiB
TypeScript
import * as oauth from "@atcute/oauth-browser-client";
|
|
|
|
import {
|
|
CompositeDidDocumentResolver,
|
|
CompositeHandleResolver,
|
|
DohJsonHandleResolver,
|
|
LocalActorResolver,
|
|
PlcDidDocumentResolver,
|
|
WebDidDocumentResolver,
|
|
WellKnownHandleResolver,
|
|
} from "@atcute/identity-resolver";
|
|
|
|
// import type { Infer as InferLexicon } from "@char/lexicon.ts";
|
|
// import type { ATProtoUniverse } from "@char/lexicon.ts/atproto";
|
|
// type UploadBlob = InferLexicon<ATProtoUniverse, "com.atproto.repo.uploadBlob">;
|
|
type UploadBlob = any;
|
|
|
|
const didDocumentResolver = new CompositeDidDocumentResolver({
|
|
methods: {
|
|
plc: new PlcDidDocumentResolver(),
|
|
web: new WebDidDocumentResolver(),
|
|
},
|
|
});
|
|
|
|
oauth.configureOAuth({
|
|
metadata: {
|
|
client_id: true
|
|
? "http://localhost" +
|
|
"?redirect_uri=" +
|
|
encodeURIComponent("http://127.0.0.1:4080/upload/") +
|
|
"&scope=" +
|
|
encodeURIComponent("atproto transition:generic")
|
|
: "https://video.cerulea.blue/upload/client-metadata.json",
|
|
redirect_uri: true ? "http://127.0.0.1:4080/upload/" : "https://video.cerulea.blue/upload/",
|
|
},
|
|
identityResolver: new LocalActorResolver({
|
|
handleResolver: new CompositeHandleResolver({
|
|
methods: {
|
|
dns: new DohJsonHandleResolver({
|
|
dohUrl: "https://mozilla.cloudflare-dns.com/dns-query",
|
|
}),
|
|
http: new WellKnownHandleResolver(),
|
|
},
|
|
}),
|
|
didDocumentResolver,
|
|
}),
|
|
});
|
|
|
|
type Did = `did:${"web" | "plc"}:${string}`;
|
|
type ActorIdentifier = Did | `${string}.${string}`;
|
|
const login = async (identifier: ActorIdentifier) => {
|
|
const authUrl = await oauth.createAuthorizationUrl({
|
|
target: { type: "account", identifier: identifier },
|
|
scope: "atproto transition:generic",
|
|
});
|
|
await new Promise(resolve => setTimeout(resolve, 200));
|
|
globalThis.location.assign(authUrl);
|
|
};
|
|
|
|
const loginForm = (): HTMLFormElement => {
|
|
const handleField = (
|
|
<input
|
|
type="text"
|
|
name="handle"
|
|
title="Enter a valid handle or DID"
|
|
placeholder="my-handle.example.com"
|
|
pattern="(did:.*)|(.*\..*)"
|
|
required
|
|
/>
|
|
) as HTMLInputElement;
|
|
|
|
const form = (
|
|
<form>
|
|
{handleField}
|
|
<button type="submit">Login</button>
|
|
</form>
|
|
) as HTMLFormElement;
|
|
|
|
form.addEventListener("submit", e => {
|
|
e.preventDefault();
|
|
if (handleField.value) {
|
|
login(handleField.value as ActorIdentifier);
|
|
}
|
|
});
|
|
|
|
return form;
|
|
};
|
|
|
|
const auth = async (): Promise<oauth.OAuthUserAgent | undefined> => {
|
|
for (const sessionId of oauth.listStoredSessions()) {
|
|
try {
|
|
const session = await oauth.getSession(sessionId);
|
|
const agent = new oauth.OAuthUserAgent(session);
|
|
return agent;
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
const params = new URLSearchParams(globalThis.location.hash.slice(1));
|
|
if (params.has("state") && params.has("code")) {
|
|
history.replaceState(null, "", location.pathname + location.search);
|
|
const { session } = await oauth.finalizeAuthorization(params);
|
|
const agent = new oauth.OAuthUserAgent(session);
|
|
return agent;
|
|
}
|
|
|
|
return undefined;
|
|
};
|
|
|
|
const uploadForm = (agent: oauth.OAuthUserAgent): HTMLFormElement => {
|
|
const uploadField = (<input type="file" accept="video/*" required />) as HTMLInputElement;
|
|
const preview = (<video controls />) as HTMLVideoElement;
|
|
preview.volume = 0.66;
|
|
const uploadButton = (<button type="submit">Upload</button>) as HTMLButtonElement;
|
|
|
|
const slugField = (
|
|
<input
|
|
type="text"
|
|
name="slug"
|
|
placeholder="slug (optional)"
|
|
title="Optional: Custom identifier for your video"
|
|
/>
|
|
) as HTMLInputElement;
|
|
|
|
const titleField = (
|
|
<input
|
|
type="text"
|
|
name="title"
|
|
placeholder="title (optional)"
|
|
title="Optional: Title for your video"
|
|
/>
|
|
) as HTMLInputElement;
|
|
|
|
const descriptionField = (
|
|
<textarea
|
|
name="description"
|
|
placeholder="description (optional)"
|
|
rows={4}
|
|
title="Optional: Description of your video"
|
|
/>
|
|
) as HTMLTextAreaElement;
|
|
|
|
const uploadStatus = <span style={{ display: "none" }}>Uploading…</span>;
|
|
|
|
uploadField.addEventListener("change", e => {
|
|
const target = e.target as HTMLInputElement;
|
|
if (target.files && target.files[0]) {
|
|
const file = target.files[0];
|
|
preview.src = URL.createObjectURL(file);
|
|
}
|
|
});
|
|
|
|
const uploadWidget = (
|
|
<div>
|
|
{uploadButton}
|
|
{uploadStatus}
|
|
</div>
|
|
);
|
|
|
|
const form = (
|
|
<form>
|
|
{uploadField}
|
|
{slugField}
|
|
{titleField}
|
|
{descriptionField}
|
|
<div id="player">{preview}</div>
|
|
{uploadWidget}
|
|
</form>
|
|
) as HTMLFormElement;
|
|
|
|
form.addEventListener("submit", async e => {
|
|
e.preventDefault();
|
|
|
|
const file = uploadField.files?.[0];
|
|
if (!file) return;
|
|
|
|
uploadButton.style.display = "none";
|
|
uploadStatus.style.display = "inline";
|
|
|
|
const uploadBlobRes: UploadBlob["output"] = await agent
|
|
.handle("/xrpc/com.atproto.repo.uploadBlob", {
|
|
method: "POST",
|
|
headers: { "content-type": file.type },
|
|
body: file,
|
|
})
|
|
.then(r => r.json());
|
|
const blobRef = uploadBlobRes.blob;
|
|
const cid = "$type" in blobRef ? blobRef.ref.$link : blobRef.cid;
|
|
console.log({ cid });
|
|
|
|
uploadStatus.textContent = "Writing record…";
|
|
|
|
const putRecordRes = await agent
|
|
.handle("/xrpc/com.atproto.repo.createRecord", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
repo: agent.sub,
|
|
collection: "blue.cerulea.video.video",
|
|
rkey: slugField.value || undefined,
|
|
record: {
|
|
$type: "blue.cerulea.video.video",
|
|
title: titleField.value || undefined,
|
|
description: descriptionField.value || undefined,
|
|
video: blobRef,
|
|
},
|
|
validate: false,
|
|
}),
|
|
})
|
|
.then(r => r.json());
|
|
|
|
const [repo, _collection, rkey] = putRecordRes.uri.substring("at://".length).split("/");
|
|
globalThis.location.replace(`/${repo}/video/${rkey}`);
|
|
});
|
|
|
|
return form;
|
|
};
|
|
|
|
const resolveHandle = async (did: string) => {
|
|
try {
|
|
const doc = await didDocumentResolver.resolve(did as Did);
|
|
return (
|
|
doc.alsoKnownAs?.find(it => it.startsWith("at://"))?.substring("at://".length) ?? did
|
|
);
|
|
} catch {
|
|
return did;
|
|
}
|
|
};
|
|
|
|
const main = async () => {
|
|
const mainElem = document.querySelector("main")!;
|
|
|
|
const agent = await auth();
|
|
if (!agent) {
|
|
mainElem.appendChild(loginForm());
|
|
return;
|
|
}
|
|
|
|
const handle = await resolveHandle(agent.session.info.sub);
|
|
mainElem.appendChild(
|
|
<p>
|
|
signed in as <strong>{handle}</strong>
|
|
</p>,
|
|
);
|
|
mainElem.appendChild(uploadForm(agent));
|
|
};
|
|
|
|
main();
|