Skip to content

Refactor packages/storage into business-specific public image API #462

@taterhead247

Description

@taterhead247

Summary

packages/storage currently abstracts some GCS mechanics, but it still leaves bucket selection, object paths, and business naming rules up to each app. That flexibility is now working against us: we have consolidated on one public-images bucket per environment, fixed folder names, and canonical object names, but apps/me, apps/admin, and apps/map still encode those rules themselves (or bypass the package entirely).

We should move the business-specific public-image behavior into packages/storage and migrate all callers to use that API.

Problem

Today the package exports generic primitives like uploadFile(path, data, contentType) and deleteFile(path), which means apps still have to know:

  • which bucket to use
  • which folder to write into
  • how to name the object
  • which image format to store
  • which cache policy / public URL shape is canonical

That has already led to drift:

  • apps/me wraps @acme/storage, but still hardcodes user-avatars/{userId}.jpg
  • apps/admin wraps @acme/storage, but still hardcodes org-logos/{orgId}.jpg
  • apps/map still uploads directly to GCS and uses a different filename scheme (${orgId}-${requestId}...) plus app-specific env vars
  • apps/me also hardcodes bucket-specific URL validation for avatar URLs

Desired behavior

packages/storage should own the public-image business rules end-to-end.

Buckets

  • staging bucket: f3-public-images-staging
  • production bucket: f3-public-images

Folders

  • org-logos/
  • user-avatars/

Canonical filenames

  • files should be stored as JPG
  • object names should be stable and business-specific
  • examples:
    • org-logos/123.jpg
    • user-avatars/4.jpg

Proposed package API

Instead of accepting credentials and channel on every call, the package should expose a factory that is called once at app startup. The resulting client object carries the configured bucket and GCS credentials, and all domain operations are methods on it.

// Called once at startup (e.g. in apps/me/src/lib/storage.ts)
export function createPublicImageStorage(config: {
  channel: "staging" | "prod";
  credentials: string; // base64-encoded service-account JSON (GCS_CREDENTIALS env var)
}): PublicImageStorage;

export interface PublicImageStorage {
  uploadOrgLogo(orgId: number, file: Buffer, options?: { size?: number }): Promise<string>;
  deleteOrgLogo(orgId: number): Promise<void>;

  uploadUserAvatar(userId: number, file: Buffer, options?: { size?: number }): Promise<string>;
  deleteUserAvatar(userId: number): Promise<void>;

  isAllowedPublicImageUrl(url: string): boolean;
}

The generic low-level helpers (uploadFile, deleteFile, prepareImageForStorage) remain internal to the package. App code should stop depending on raw bucket names and raw object paths. Apps store the URL returned by the upload call in the database and retrieve it from there; the storage package does not need to provide URL getter methods.

Suggested implementation

Add a public-images domain module inside packages/storage:

// packages/storage/src/public-images.ts
import { deleteFile } from "./delete";
import { prepareImageForStorage } from "./resize";
import { uploadFile } from "./upload";

const BUCKETS = {
  prod: "f3-public-images",
  staging: "f3-public-images-staging",
} as const;

function orgLogoPath(orgId: number): string {
  return `org-logos/${orgId}.jpg`;
}

function userAvatarPath(userId: number): string {
  return `user-avatars/${userId}.jpg`;
}

export function createPublicImageStorage(config: {
  channel: "staging" | "prod";
  credentials: string; // base64-encoded GCS_CREDENTIALS
}) {
  const bucket = BUCKETS[config.channel];
  const baseUrl = `https://storage.googleapis.com/${bucket}`;

  function isAllowedPublicImageUrl(url: string): boolean {
    return (
      url.startsWith(`https://storage.googleapis.com/${BUCKETS.prod}/`) ||
      url.startsWith(`https://storage.googleapis.com/${BUCKETS.staging}/`)
    );
  }

  async function uploadOrgLogo(
    orgId: number,
    file: Buffer,
    options?: { size?: number },
  ): Promise<string> {
    const size = options?.size ?? 640;
    const jpg = await prepareImageForStorage(file, { width: size, height: size });
    return uploadFile(bucket, orgLogoPath(orgId), jpg, "image/jpeg", config.credentials, {
      cacheControl: "public, max-age=300",
    });
  }

  async function deleteOrgLogo(orgId: number): Promise<void> {
    await deleteFile(bucket, orgLogoPath(orgId), config.credentials);
  }

  async function uploadUserAvatar(
    userId: number,
    file: Buffer,
    options?: { size?: number },
  ): Promise<string> {
    const size = options?.size ?? 512;
    const jpg = await prepareImageForStorage(file, { width: size, height: size });
    return uploadFile(bucket, userAvatarPath(userId), jpg, "image/jpeg", config.credentials, {
      cacheControl: "public, max-age=300",
    });
  }

  async function deleteUserAvatar(userId: number): Promise<void> {
    await deleteFile(bucket, userAvatarPath(userId), config.credentials);
  }

  return { uploadOrgLogo, deleteOrgLogo, uploadUserAvatar, deleteUserAvatar, isAllowedPublicImageUrl };
}

The existing uploadFile and deleteFile internals will need to be updated to accept the bucket name and credentials as parameters (rather than reading from env.GCS_BUCKET / env.GCS_CREDENTIALS) so that the public-images layer controls both.

App migrations

apps/me

  • Create apps/me/src/lib/storage.ts that calls createPublicImageStorage once with GCS_CREDENTIALS and the channel derived from F3_CHANNEL
  • Replace uploadAvatar in apps/me/src/lib/gcs.ts with storage.uploadUserAvatar
  • Stop hardcoding user-avatars/{userId}.jpg
  • Replace the avatar allow-list regex in apps/me/src/app/api/profile/route.ts with storage.isAllowedPublicImageUrl
  • Remove app-local ALLOWED_AVATAR_HOST_PATTERN
  • Update tests to use .jpg and both-bucket coverage

apps/admin

  • Create apps/admin/src/lib/storage.ts that calls createPublicImageStorage once with GCS_CREDENTIALS and the channel
  • Replace prepareImageForStorage + uploadFile(...) in apps/admin/src/app/api/upload-logo/route.ts with storage.uploadOrgLogo
  • Stop hardcoding org-logos/{orgId}.jpg
  • Keep route-level validation (file required, allowed MIME types, max size), but delegate storage decisions to the package

apps/map

  • Create apps/map/src/lib/storage.ts that calls createPublicImageStorage once with the appropriate credentials and channel
  • Replace the hand-rolled GCS upload in apps/map/src/app/api/upload-logo/route.ts with storage.uploadOrgLogo
  • Remove the current request-id-based filename scheme and write to canonical org-logos/{orgId}.jpg
  • Remove direct GoogleAuth usage from the app route
  • Keep route-level request validation, but delegate upload behavior to the package

Env cleanup

As part of this refactor, align all apps on the storage package's env contract.

At minimum:

  • packages/storage should be the only place that knows which public-images bucket is canonical for staging/prod
  • apps should no longer reference bucket names directly
  • apps/map should stop carrying separate GOOGLE_LOGO_BUCKET_* env vars for this use case; it should instead use the same GCS_CREDENTIALS / F3_CHANNEL pattern as apps/me and apps/admin

Acceptance criteria

  • packages/storage exports createPublicImageStorage with a factory that accepts credentials and channel once
  • bucket names and folder names are not hardcoded in app code
  • canonical filenames are {entity-id}.jpg
  • both uploadOrgLogo and uploadUserAvatar accept an optional size parameter
  • isAllowedPublicImageUrl is a method on the storage client, not a standalone app-local regex
  • apps/me, apps/admin, and apps/map each create a single storage client at startup and use it for all storage operations
  • apps/map no longer talks to GCS directly for logo upload
  • tests cover upload, delete, and URL validation for both production and staging configurations

Current call sites / context

  • packages/storage/src/upload.ts
  • packages/storage/src/delete.ts
  • apps/me/src/lib/gcs.ts
  • apps/me/src/app/api/profile/route.ts
  • apps/admin/src/app/api/upload-logo/route.ts
  • apps/map/src/app/api/upload-logo/route.ts

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    Status
    In progress

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions