0
2
// Create a gallery inside a permissioned space.
// POST /xrpc/social.grain.unspecced.createPrivateGallery
//
// One gallery is one space: the space's skey is the gallery's rkey, and its
// authority is the author. Members are readers — in this shape only the author
// writes, which keeps curation whole. A member's records would live in their own
// repo, where the authority can evict them from the space but cannot remove a
// single photo from the gallery.
//
// Blobs are uploaded beforehand through the ordinary blob endpoint: a space
// record's blobs are ordinary account blobs, and only reading them back goes
// through the space.
import { defineProcedure, InvalidRequestError } from "$hatk";
import { resolveActor } from "../helpers/resolveActor.ts";
import { getSpaceSupport, pdsEndpointFor } from "../helpers/spaceSupport.ts";
import { addGalleryReader, createGallerySpace, spaceUri } from "../spaces/client.ts";
interface PhotoInput {
photo: unknown;
alt?: string;
aspectRatio: { width: number; height: number };
}
export default defineProcedure("social.grain.unspecced.createPrivateGallery", async (ctx) => {
const { ok, db, viewer, pds, input } = ctx;
if (!viewer) throw new InvalidRequestError("Authentication required");
const { rkey, title, description } = input;
const memberActors = (input.members ?? []) as string[];
const photos = (input.photos ?? []) as PhotoInput[];
const endpoint = await pdsEndpointFor(db, viewer.did);
if (!endpoint) throw new InvalidRequestError("No PDS session for this account");
const support = await getSpaceSupport(db, endpoint);
if (!support.supported) {
throw new InvalidRequestError(
"Your PDS does not serve permissioned spaces",
"SpacesUnsupported",
);
}
// Handles or DIDs: a person shares with @someone, the space stores a DID.
// An unresolvable entry stops the gallery being made, rather than quietly
// creating one that is shared with fewer people than was asked for.
const members: string[] = [];
for (const actor of memberActors) {
const did = await resolveActor(db, pds, actor);
if (!did) throw new InvalidRequestError(`Could not find ${actor}`, "ActorNotFound");
members.push(did);
}
const space = spaceUri(viewer.did, rkey);
const createdAt = new Date().toISOString();
// Anchored on the caller: nobody makes a space on somebody else's account.
await createGallerySpace(pds, viewer.did, rkey);
// Sequential rather than concurrent: the authority applies these to one
// member list, and a handful of readers is not worth racing.
//
// Each member is also recorded here, because nothing else will tell them.
// Being added to a space leaves no trace on the member's own PDS, so without
// this row a reader can only reach the gallery through a link somebody sent
// them. It grants nothing on its own — the read still needs a credential the
// authority issues.
for (const did of members) {
if (did === viewer.did) continue; // the authority is authorized without being listed
await addGalleryReader(pds, space, did);
await db.run(
`INSERT INTO _space_invites (space, member_did, author_did, created_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (space, member_did) DO NOTHING`,
[space, did, viewer.did, createdAt],
);
}
// The gallery, its photos and the items joining them, in one commit. A
// half-written gallery is worse here than in the public repo: there is no
// indexer to reconcile it against later.
const writes: Record<string, unknown>[] = [
{
$type: "com.atproto.space.applyWrites#create",
collection: "social.grain.gallery",
rkey,
value: {
$type: "social.grain.gallery",
title,
...(description ? { description } : {}),
createdAt,
},
},
];
photos.forEach((photo, index) => {
const photoRkey = `${rkey}-${index}`;
writes.push({
$type: "com.atproto.space.applyWrites#create",
collection: "social.grain.photo",
rkey: photoRkey,
value: {
$type: "social.grain.photo",
photo: photo.photo,
alt: photo.alt || "",
aspectRatio: photo.aspectRatio,
createdAt,
},
});
writes.push({
$type: "com.atproto.space.applyWrites#create",
collection: "social.grain.gallery.item",
rkey: photoRkey,
value: {
$type: "social.grain.gallery.item",
gallery: `at://${viewer.did}/social.grain.gallery/${rkey}`,
item: `at://${viewer.did}/social.grain.photo/${photoRkey}`,
position: index,
createdAt,
},
});
});
await pds("com.atproto.space.applyWrites", {
method: "POST",
body: { space, repo: viewer.did, writes },
});
return ok({ space, uri: `at://${viewer.did}/social.grain.gallery/${rkey}` });
});