Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,27 @@ const client = new PicGoCloudClient({

API requests use native fetch; you can inject an implementation through the `fetch` option. File transfers use native XMLHttpRequest for upload progress and do not use the custom fetch implementation. Importing the module does not access the DOM. Uploads require the browser's Blob/File, XMLHttpRequest, Web Crypto, and AbortSignal.any APIs. Use an HTTPS or localhost secure context.

## Redeeming membership vouchers (OAuth)

```ts
const client = new PicGoCloudClient({ token: async () => getOAuthAccessToken() })
const { entitlementId } = await client.redeem({ code: voucherCode })
// Optional cancellation: client.redeem({ code: voucherCode }, { signal })
```

Requires an OAuth access token with `entitlements:redeem` and a Worker that supports `POST /api/redeem`. Submission confirms redemption for the token's user, not the browser's logged-in account. No `userId` parameter is accepted. Keep client secrets and refresh tokens on the partner backend; the browser token provider should return only short-lived access tokens.

Existing grants must obtain explicit consent for this new scope. Refreshing an old token does not add permissions. Active membership blocks redemption without consuming the voucher; a retry by the same user after successful redemption returns the original entitlement. API failures throw `PicGoCloudError` with `status` and `code` (for example `REDEMPTION_ACTIVE_MEMBERSHIP`, HTTP 409). The SDK does not automatically retry redemption. This method is new and is not included in the previously published `0.1.0` package.

## Storage, membership and upload task status (OAuth)

```ts
const usage = await client.getStorageUsage() // storage:usage:read
const membership = await client.getEntitlements() // entitlements:read
const task = await client.getUploadStatus({ objectKey, uploadId }) // files:upload; uploadId only for multipart
```

`getStorageUsage()` returns used/limit/remaining for bytes and media count plus the upload size and extension limits. `getEntitlements()` returns the plan, lifecycle phase, phase end time and an informational `redemption.eligible` hint; `redeem()` still decides on the server. `getUploadStatus()` reports whether the object of an upload started by the same user and OAuth client has arrived (`awaiting_object`, `object_uploaded` or `aborted`). These methods are new and not included in `0.1.0`.
## Uploading files

```ts
Expand Down
21 changes: 21 additions & 0 deletions README_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,27 @@ const client = new PicGoCloudClient({

API 使用原生 fetch,可通过 `fetch` 选项注入自定义实现。上传字节使用原生 XMLHttpRequest 以提供浏览器上传进度,不经过自定义 fetch。模块导入本身不访问 DOM;实际上传需要浏览器的 Blob/File、XMLHttpRequest、Web Crypto 和 AbortSignal.any,建议在 HTTPS 或 localhost 安全上下文使用。

## 兑换会员(OAuth)

```ts
const client = new PicGoCloudClient({ token: async () => getOAuthAccessToken() })
const { entitlementId } = await client.redeem({ code: voucherCode })
// 可选取消:client.redeem({ code: voucherCode }, { signal })
```

需要具备 `entitlements:redeem` 的 OAuth access token,以及支持 `POST /api/redeem` 的 Worker。提交即确认兑换,权益只发给 token 对应的用户,不依赖浏览器当前登录账号,不接受 `userId`。client secret 和 refresh token 必须留在合作方后端,浏览器 token provider 只取得短期 access token。

老授权需要用户明确同意新增权限,刷新旧 token 不会自动获得权限。有生效会员时拒绝兑换且不消耗兑换码;同一用户重试已成功的兑换返回原 entitlement。失败抛出带 `status`、`code` 的 `PicGoCloudError`,例如 HTTP 409 / `REDEMPTION_ACTIVE_MEMBERSHIP`。SDK 不自动重试兑换。此方法是新增功能,已发布的 `0.1.0` 包尚不包含它。

## 容量、会员与上传任务状态(OAuth)

```ts
const usage = await client.getStorageUsage() // storage:usage:read
const membership = await client.getEntitlements() // entitlements:read
const task = await client.getUploadStatus({ objectKey, uploadId }) // files:upload;仅分片上传需要 uploadId
```

`getStorageUsage()` 返回容量字节数和文件数的已用/上限/剩余,以及单文件大小和扩展名限制。`getEntitlements()` 返回套餐、生命周期阶段、阶段结束时间,以及仅供参考的 `redemption.eligible`;能否兑换仍以 `redeem()` 的服务端结果为准。`getUploadStatus()` 报告同一用户、同一 OAuth 客户端发起的上传对象是否已到达(`awaiting_object`、`object_uploaded` 或 `aborted`)。这些方法是新增功能,`0.1.0` 尚不包含。
## 上传

```ts
Expand Down
39 changes: 31 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,17 +1,34 @@
{
"name": "@picgo/cloud-sdk",
"version": "0.1.0",
"version": "0.2.0-beta.0",
"description": "PicGo Cloud uploads and media management for modern browsers",
"type": "module",
"license": "MIT",
"repository": { "type": "git", "url": "git+https://github.com/PicGo/picgo-cloud-sdk.git" },
"publishConfig": { "access": "public", "registry": "https://registry.npmjs.org" },
"repository": {
"type": "git",
"url": "git+https://github.com/PicGo/picgo-cloud-sdk.git"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org"
},
"sideEffects": false,
"files": ["dist", "README.md", "README_ZH.md", "CHANGELOG.md", "LICENSE"],
"files": [
"dist",
"README.md",
"README_ZH.md",
"CHANGELOG.md",
"LICENSE"
],
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"dev": "pnpm build && node --env-file-if-exists=.env scripts/dev.mjs",
"build": "rolldown -c && tsc -p tsconfig.build.json",
Expand All @@ -28,11 +45,17 @@
"prepare": "husky"
},
"config": {
"commitizen": { "path": "./node_modules/cz-customizable" },
"cz-customizable": { "config": "./node_modules/@picgo/bump-version/.cz-config.cjs" }
"commitizen": {
"path": "./node_modules/cz-customizable"
},
"cz-customizable": {
"config": "./node_modules/@picgo/bump-version/.cz-config.cjs"
}
},
"commitlint": {
"extends": ["./node_modules/@picgo/bump-version/commitlint-picgo/index.cjs"]
"extends": [
"./node_modules/@picgo/bump-version/commitlint-picgo/index.cjs"
]
},
"packageManager": "pnpm@11.7.0",
"devDependencies": {
Expand Down
26 changes: 25 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { HttpClient } from './http.js'
import type { HttpClientConfig } from './http.js'
import { MediaService } from './media.js'
import type { RequestOptions, WhoAmI, MediaItem } from './types.js'
import type {
RequestOptions, WhoAmI, MediaItem, RedeemInput, RedeemResult, StorageUsage, Entitlements, UploadTaskQuery, UploadTaskStatus,
} from './types.js'
import { UploadTask } from './upload/task.js'
import type { UploadConfig, UploadOptions } from './upload/types.js'

Expand All @@ -20,6 +22,28 @@ export class PicGoCloudClient {
return this.http.request<WhoAmI>('/api/whoami', options)
}

/** Redeem for the OAuth token's user. Requires entitlements:redeem; submission confirms redemption. */
redeem(input: RedeemInput, options?: RequestOptions): Promise<RedeemResult> {
return this.http.request<RedeemResult>('/api/redeem', { ...options, method: 'POST', body: input })
}

/** Requires storage:usage:read. */
getStorageUsage(options?: RequestOptions): Promise<StorageUsage> {
return this.http.request<StorageUsage>('/api/storage/usage', options)
}

/** Requires entitlements:read. */
getEntitlements(options?: RequestOptions): Promise<Entitlements> {
return this.http.request<Entitlements>('/api/entitlements', options)
}

/** Server-side upload task state for an upload started by the same user and OAuth client. Requires files:upload. */
getUploadStatus(query: UploadTaskQuery, options?: RequestOptions): Promise<UploadTaskStatus> {
const params = new URLSearchParams({ objectKey: query.objectKey })
if (query.uploadId) params.set('uploadId', query.uploadId)
return this.http.request<UploadTaskStatus>(`/api/upload/status?${params.toString()}`, options)
}

createUpload(file: Blob, options?: UploadOptions): UploadTask {
return new UploadTask(this.http, file, options, this.options)
}
Expand Down
48 changes: 48 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,54 @@ export interface RequestOptions {
signal?: AbortSignal
}

export interface RedeemInput {
code: string
}

export interface RedeemResult {
entitlementId: string
}

export interface UsageCounter {
used: number
limit: number
remaining: number
}

export interface StorageUsage {
/** Bytes. */
storage: UsageCounter
mediaCount: UsageCounter
upload: { maxFileBytes: number; allowedExtensions: string[] }
}

export type LifecyclePhase = 'active' | 'grace' | 'frozen' | 'pending_cleanup'

export interface Entitlements {
plan: string
effectiveQuotaPlan: string
phase: LifecyclePhase
/** ISO timestamp, or null when the phase has no end. */
currentPhaseEndsAt: string | null
/** Informational; redeem() rechecks on the server. code is an API error code when not eligible. */
redemption: { eligible: boolean; code: string | null }
}

export interface UploadTaskQuery {
objectKey: string
/** Required for multipart uploads. */
uploadId?: string
}

export interface UploadTaskStatus {
objectKey: string
uploadId: string | null
kind: 'single' | 'multipart'
state: 'awaiting_object' | 'object_uploaded' | 'aborted'
sizeBytes: number | null
expiresAt: string
}

export interface WhoAmI {
/** Available on current Workers; optional for compatibility with older deployments. */
userId?: string
Expand Down
36 changes: 36 additions & 0 deletions tests/account.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { PicGoCloudClient } from '../src/index.js'

function clientReturning(data: unknown, seen: string[]) {
return new PicGoCloudClient({
baseUrl: 'https://api.example.test',
token: 'pgoa_access',
fetch: async (url, init) => {
seen.push(`${init?.method ?? 'GET'} ${String(url)}`)
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer pgoa_access')
return Response.json({ success: true, data })
},
})
}

describe('partner account reads', () => {
it('reads storage usage and entitlements', async () => {
const seen: string[] = []
const usage = { storage: { used: 1, limit: 10, remaining: 9 }, mediaCount: { used: 1, limit: 5, remaining: 4 }, upload: { maxFileBytes: 5, allowedExtensions: ['png'] } }
expect(await clientReturning(usage, seen).getStorageUsage()).toEqual(usage)
const entitlements = { plan: 'pro', effectiveQuotaPlan: 'pro', phase: 'active', currentPhaseEndsAt: null, redemption: { eligible: false, code: 'REDEMPTION_ACTIVE_MEMBERSHIP' } }
expect(await clientReturning(entitlements, seen).getEntitlements()).toEqual(entitlements)
expect(seen).toEqual(['GET https://api.example.test/api/storage/usage', 'GET https://api.example.test/api/entitlements'])
})

it('encodes the upload task reference and omits uploadId for single uploads', async () => {
const seen: string[] = []
const status = { objectKey: 'u/a b.png', uploadId: null, kind: 'single', state: 'awaiting_object', sizeBytes: null, expiresAt: '2026-10-17T00:00:00.000Z' }
expect(await clientReturning(status, seen).getUploadStatus({ objectKey: 'u/a b.png' })).toEqual(status)
await clientReturning(status, seen).getUploadStatus({ objectKey: 'u/x', uploadId: 'up&1' })
expect(seen).toEqual([
'GET https://api.example.test/api/upload/status?objectKey=u%2Fa+b.png',
'GET https://api.example.test/api/upload/status?objectKey=u%2Fx&uploadId=up%261',
])
})
})
36 changes: 36 additions & 0 deletions tests/redeem.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { PicGoCloudClient } from '../src/index.js'

describe('PicGoCloudClient.redeem', () => {
it('posts the voucher with the provider token, omits cookies and unwraps the entitlement', async () => {
const client = new PicGoCloudClient({
baseUrl: 'https://api.example.test',
token: async () => 'pgoa_access',
fetch: async (url, init) => {
expect(url).toBe('https://api.example.test/api/redeem')
expect(init?.method).toBe('POST')
expect(init?.credentials).toBe('omit')
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer pgoa_access')
expect(new Headers(init?.headers).get('Content-Type')).toBe('application/json')
expect(JSON.parse(String(init?.body))).toEqual({ code: 'a'.repeat(43) })
return Response.json({ success: true, data: { entitlementId: '42' } })
},
})
expect(await client.redeem({ code: 'a'.repeat(43) })).toEqual({ entitlementId: '42' })
})

it('preserves redemption error status and code', async () => {
const client = new PicGoCloudClient({
token: 'pgoa_access',
fetch: async () => Response.json({ success: false, code: 'REDEMPTION_ACTIVE_MEMBERSHIP', message: 'Active membership' }, { status: 409 }),
})
await expect(client.redeem({ code: 'a'.repeat(43) })).rejects.toMatchObject({
kind: 'api', status: 409, code: 'REDEMPTION_ACTIVE_MEMBERSHIP',
})
})

it('honors an aborted request without calling the transport', async () => {
const client = new PicGoCloudClient({ token: 'token', fetch: async () => { throw new Error('must not send') } })
await expect(client.redeem({ code: 'a'.repeat(43) }, { signal: AbortSignal.abort() })).rejects.toMatchObject({ kind: 'aborted' })
})
})
Loading