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

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
97 lines · 3.6 KB Raw History
// Assemble a gallery that lives in a permissioned space.
//   GET /xrpc/social.grain.unspecced.getPrivateGallery?space=...
//
// Nothing here comes from the index, because nothing in a space ever reaches a
// firehose to be indexed from. The gallery is read from the repo that holds it,
// on the host that holds it, on every request — through the author's own session
// when the author is asking, and through a credential the space's authority
// issues when anyone else is.
//
// `cid` rides along per item so the client can ask /space-blob for the bytes;
// they cannot come from the CDN.

import { defineQuery, InvalidRequestError } from "$hatk";
import { blobCid, listSpaceRecords, parseSpaceUri } from "../spaces/client.ts";
import { throwSpaceError } from "../spaces/errors.ts";

interface PhotoValue {
  photo?: { ref?: { $link?: string } };
  alt?: string;
  aspectRatio?: { width: number; height: number };
}

interface ItemValue {
  item?: string;
  position?: number;
}

export default defineQuery("social.grain.unspecced.getPrivateGallery", async (ctx) => {
  const { ok, db, viewer, params } = ctx;
  if (!viewer) throw new InvalidRequestError("Authentication required");

  const space = params.space;
  let authority: string;
  let skey: string;
  try {
    ({ authority, skey } = parseSpaceUri(space));
  } catch {
    throw new InvalidRequestError(`Not a space uri: ${space}`);
  }

  try {
    const [galleries, items, photos] = await Promise.all([
      listSpaceRecords(ctx, viewer.did, space, authority, "social.grain.gallery"),
      listSpaceRecords(ctx, viewer.did, space, authority, "social.grain.gallery.item"),
      listSpaceRecords(ctx, viewer.did, space, authority, "social.grain.photo"),
    ]);

    // An item names its photo by the at-uri the record would have in a public
    // repo, so photos are keyed the same way. A space record has no uri of its
    // own — its address is the space, the repo, the collection and the rkey.
    const byUri = new Map(photos.map((p) => [`at://${authority}/social.grain.photo/${p.rkey}`, p]));

    const view = items
      .map((item) => {
        const value = item.value as ItemValue;
        const photo = value.item ? byUri.get(value.item) : undefined;
        if (!photo) return null;
        const photoValue = photo.value as PhotoValue;
        const cid = blobCid(photoValue.photo);
        if (!cid) return null;
        return {
          uri: `at://${authority}/social.grain.photo/${photo.rkey}`,
          did: authority,
          cid,
          ...(photoValue.alt ? { alt: photoValue.alt } : {}),
          ...(photoValue.aspectRatio ? { aspectRatio: photoValue.aspectRatio } : {}),
          position: value.position ?? 0,
        };
      })
      .filter((item): item is NonNullable<typeof item> => item !== null)
      .sort((a, b) => a.position - b.position);

    const galleryRecord = galleries.find((g) => g.rkey === skey) ?? galleries[0];
    const galleryValue = galleryRecord?.value as
      | { title?: string; description?: string; createdAt?: string }
      | undefined;

    return ok({
      space,
      authority,
      viewerIsAuthor: viewer.did === authority,
      ...(galleryRecord && galleryValue?.title
        ? {
            gallery: {
              uri: `at://${authority}/social.grain.gallery/${skey}`,
              title: galleryValue.title,
              ...(galleryValue.description ? { description: galleryValue.description } : {}),
              ...(galleryValue.createdAt ? { createdAt: galleryValue.createdAt } : {}),
            },
          }
        : {}),
      items: view,
    });
  } catch (err) {
    return throwSpaceError(err, db, viewer.did);
  }
});