-
Notifications
You must be signed in to change notification settings - Fork 3
feat(repository): support existing blob refs for profile avatar/banner #138
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| --- | ||
| "@hypercerts-org/sdk-core": minor | ||
| --- | ||
|
|
||
| Support existing blob references for profile avatar and banner fields | ||
|
|
||
| - Add `BlobInput = Blob | JsonBlobRef` type to `interfaces.ts`; avatar/banner fields on all profile param types now | ||
| accept either a new `Blob` (uploaded automatically) or an existing `JsonBlobRef` (used directly without re-uploading) | ||
| - Add `blobRefToJsonRef(blobRef: BlobRef): JsonBlobRef` helper to `types.ts` for converting AT Protocol blob refs to | ||
| their JSON representation | ||
| - Remove stale `BlobUploadResult` interface (was already marked `@deprecated`; `blobs.upload()` returns `BlobRef` from | ||
| `@atproto/lexicon`, not this shape) | ||
|
|
||
| **Potentially breaking:** any code that imported `BlobUploadResult` from `@hypercerts-org/sdk-core` will need to switch | ||
| to `BlobRef` from `@atproto/lexicon` directly. |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -9,13 +9,16 @@ | |||||||||||||||||||||
| */ | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| import type { Agent, AppBskyActorDefs } from "@atproto/api"; | ||||||||||||||||||||||
| import { BlobRef as LexiconBlobRef } from "@atproto/lexicon"; | ||||||||||||||||||||||
| import type { JsonBlobRef } from "@atproto/lexicon"; | ||||||||||||||||||||||
| import { NetworkError, ValidationError } from "../core/errors.js"; | ||||||||||||||||||||||
| import { HYPERCERT_COLLECTIONS } from "../lexicons.js"; | ||||||||||||||||||||||
| import { extractCidFromImage, getBlobUrl } from "../lib/blob-url.js"; | ||||||||||||||||||||||
| import { isValidUri } from "../lib/url-utils.js"; | ||||||||||||||||||||||
| import { AppCertifiedActorProfile, type HypercertImageRecord } from "../services/hypercerts/types.js"; | ||||||||||||||||||||||
| import { validate } from "@hypercerts-org/lexicon"; | ||||||||||||||||||||||
| import type { | ||||||||||||||||||||||
| BlobInput, | ||||||||||||||||||||||
| BlobOperations, | ||||||||||||||||||||||
| BskyProfile, | ||||||||||||||||||||||
| CertifiedProfile, | ||||||||||||||||||||||
|
|
@@ -117,34 +120,74 @@ export class ProfileOperationsImpl implements ProfileOperations { | |||||||||||||||||||||
| return getBlobUrl(this.pdsUrl, this.repoDid, result); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * Type guard to check if a value is a JsonBlobRef (already-uploaded blob reference). | ||||||||||||||||||||||
| * | ||||||||||||||||||||||
| * Supports both typed form (`{ $type: "blob", ref, mimeType, size }`) and | ||||||||||||||||||||||
| * untyped/legacy form (`{ cid: string, mimeType: string }`). | ||||||||||||||||||||||
| * | ||||||||||||||||||||||
| * @internal | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
| private isJsonBlobRef(value: unknown): value is JsonBlobRef { | ||||||||||||||||||||||
| if (typeof value !== "object" || value === null) return false; | ||||||||||||||||||||||
| const r = value as Record<string, unknown>; | ||||||||||||||||||||||
| // Typed form: { $type: "blob", ref: object, mimeType: string, size: number } | ||||||||||||||||||||||
| if (r.$type === "blob" && "ref" in r && typeof r.mimeType === "string" && typeof r.size === "number") { | ||||||||||||||||||||||
| return true; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| // Untyped/legacy form: { cid: string, mimeType: string } | ||||||||||||||||||||||
| if (typeof r.cid === "string" && typeof r.mimeType === "string") { | ||||||||||||||||||||||
| return true; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| return false; | ||||||||||||||||||||||
|
Comment on lines
+138
to
+142
|
||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * Applies an image field (avatar/banner) with format-specific wrapping. | ||||||||||||||||||||||
| * | ||||||||||||||||||||||
| * - null: removes the field | ||||||||||||||||||||||
| * - undefined: no change | ||||||||||||||||||||||
| * - JsonBlobRef: uses the existing blob ref directly (no re-upload) | ||||||||||||||||||||||
| * - Blob: uploads and wraps according to collection format | ||||||||||||||||||||||
| * | ||||||||||||||||||||||
| * @param result - The profile record being built | ||||||||||||||||||||||
| * @param field - Field name ("avatar" or "banner") | ||||||||||||||||||||||
| * @param value - Blob to upload, null to remove, or undefined to skip | ||||||||||||||||||||||
| * @param input - BlobInput (Blob or JsonBlobRef) to use, null to remove, or undefined to skip | ||||||||||||||||||||||
| * @param collection - Profile collection NSID (determines image wrapping format) | ||||||||||||||||||||||
| * | ||||||||||||||||||||||
| * @internal | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
| private async applyImageField( | ||||||||||||||||||||||
| result: Record<string, unknown>, | ||||||||||||||||||||||
| field: string, | ||||||||||||||||||||||
| value: Blob | null | undefined, | ||||||||||||||||||||||
| input: BlobInput | null | undefined, | ||||||||||||||||||||||
| collection: ProfileCollection, | ||||||||||||||||||||||
| ): Promise<void> { | ||||||||||||||||||||||
| if (value === undefined) return; | ||||||||||||||||||||||
| if (input === undefined) return; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if (value === null) { | ||||||||||||||||||||||
| if (input === null) { | ||||||||||||||||||||||
| delete result[field]; | ||||||||||||||||||||||
| return; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const blobRef = await this.blobs.upload(value); | ||||||||||||||||||||||
| // If the input is already a JSON blob ref, convert to BlobRef instance for validation | ||||||||||||||||||||||
| // and store without re-uploading | ||||||||||||||||||||||
| if (this.isJsonBlobRef(input)) { | ||||||||||||||||||||||
| const blobRef = LexiconBlobRef.fromJsonRef(input); | ||||||||||||||||||||||
|
||||||||||||||||||||||
| const blobRef = LexiconBlobRef.fromJsonRef(input); | |
| let blobRef: LexiconBlobRef; | |
| try { | |
| blobRef = LexiconBlobRef.fromJsonRef(input as JsonBlobRef); | |
| } catch (err) { | |
| // Wrap low-level conversion errors in a domain-specific validation error | |
| throw new ValidationError( | |
| `Invalid blob reference for field "${field}" in collection "${collection}": ${JSON.stringify(input)}`, | |
| ); | |
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ | |
| */ | ||
|
|
||
| import type { AppBskyActorDefs, AppBskyActorProfile, AppBskyRichtextFacet, BlobRef } from "@atproto/api"; | ||
| import type { JsonBlobRef } from "@atproto/lexicon"; | ||
| import type { EventEmitter } from "eventemitter3"; | ||
| import type { | ||
| AppCertifiedActorProfile, | ||
|
|
@@ -671,6 +672,11 @@ export interface BlobOperations { | |
| * }); | ||
| * ``` | ||
| */ | ||
| /** | ||
| * Input type for blob fields — either a new Blob to upload or an existing JsonBlobRef to reuse. | ||
| */ | ||
| export type BlobInput = Blob | JsonBlobRef; | ||
|
||
|
|
||
| /** | ||
| * Bluesky profile type - direct from AT Protocol. | ||
| * Returned by agent.getProfile() with avatar/banner as CDN URLs. | ||
|
|
@@ -696,7 +702,7 @@ type Nullable<T> = { [K in keyof T]?: T[K] | null }; | |
|
|
||
| export type CreateBskyProfileParams = OverrideProperties< | ||
| SetOptional<AppBskyActorProfile.Record, "$type" | "createdAt">, | ||
| { avatar?: Blob; banner?: Blob } | ||
| { avatar?: BlobInput; banner?: BlobInput } | ||
| >; | ||
|
|
||
| /** | ||
|
|
@@ -706,7 +712,7 @@ export type CreateBskyProfileParams = OverrideProperties< | |
| */ | ||
| export type UpdateBskyProfileParams = OverrideProperties< | ||
| Nullable<Except<CreateBskyProfileParams, "$type" | "createdAt">>, | ||
| { avatar?: Blob | null; banner?: Blob | null } | ||
| { avatar?: BlobInput | null; banner?: BlobInput | null } | ||
| >; | ||
|
|
||
| /** | ||
|
|
@@ -716,7 +722,7 @@ export type UpdateBskyProfileParams = OverrideProperties< | |
|
|
||
| export type CreateCertifiedProfileParams = OverrideProperties< | ||
| SetOptional<AppCertifiedActorProfile.Main, "$type" | "createdAt">, | ||
| { avatar?: Blob; banner?: Blob } | ||
| { avatar?: BlobInput; banner?: BlobInput } | ||
| >; | ||
|
|
||
| /** | ||
|
|
@@ -726,7 +732,7 @@ export type CreateCertifiedProfileParams = OverrideProperties< | |
| */ | ||
| export type UpdateCertifiedProfileParams = OverrideProperties< | ||
| Nullable<Except<CreateCertifiedProfileParams, "$type" | "createdAt">>, | ||
| { avatar?: Blob | null; banner?: Blob | null } | ||
| { avatar?: BlobInput | null; banner?: BlobInput | null } | ||
| >; | ||
|
|
||
| export interface ProfileOperations { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ | |
| * @packageDocumentation | ||
| */ | ||
|
|
||
| import type { BlobRef, JsonBlobRef } from "@atproto/lexicon"; | ||
| import type { CollaboratorPermissions } from "../core/types.js"; | ||
|
|
||
| // ============================================================================ | ||
|
|
@@ -115,12 +116,9 @@ export interface ProgressStep { | |
| // ============================================================================ | ||
|
|
||
| /** | ||
| * Result from BlobOperations.upload() | ||
| * | ||
| * @deprecated Use BlobRef from @atproto/api directly | ||
| * Converts a BlobRef from @atproto/lexicon to its JSON representation | ||
| * suitable for storing in AT Protocol records. | ||
| */ | ||
| export interface BlobUploadResult { | ||
| ref: { $link: string }; | ||
| mimeType: string; | ||
| size: number; | ||
| export function blobRefToJsonRef(blobRef: BlobRef): JsonBlobRef { | ||
| return blobRef.ipld(); | ||
| } | ||
|
Comment on lines
+122
to
124
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The JSDoc comment mentions "untyped/legacy form" which is checked in the implementation, but this form is likely not a valid JsonBlobRef according to the AT Protocol specification. This documentation should be updated to either:
The inconsistency between the documentation and the actual JsonBlobRef type could lead to confusion for developers.