~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
129 lines · 4.7 KB Raw History
// 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}` });
});