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
43 changes: 43 additions & 0 deletions .changeset/google-drive-shared-drives.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
'@useupup/core': patch
'@useupup/vanilla': patch
---

The Google Drive picker can browse shared drives and "Shared with me", behind a
default-off `cloudDrives.googleDrive.sharedDrives` flag.

`GoogleDrivePlugin.loadFiles` and `loadMoreFiles` sent a Drive v3 `files.list`
with no `corpora`, no `includeItemsFromAllDrives` and no `supportsAllDrives`, so
the API answered from the signed-in user's own My Drive corpus only. For a
business account that is most of the person's files: a file living in a shared
drive never appeared at any depth, and the picker's search box did not
compensate because it filters the children already loaded rather than issuing a
query. The requested scope was never the limit — `drive.readonly` covers shared
drives, and `drives.list`, already.

With `sharedDrives: true`:

- Both listing calls send `corpora=allDrives`, `includeItemsFromAllDrives=true`
and `supportsAllDrives=true`, and the single-file download sends
`supportsAllDrives=true` so a file the widened listing surfaced can actually be
fetched instead of answering 404. `corpora` and `includeItemsFromAllDrives` are
`files.list`-only and stay off the download.
- The ROOT listing appends the user's shared drives, from a paginated
`drives.list`, as navigable folder rows after the My Drive children. Those
params widen which files a query CAN return, but every listing is still
`'<parentId>' in parents` and `'root'` resolves to My Drive root — so without
an entry to click, a shared drive stayed unreachable. A shared drive's root
folder id IS its drive id, so once one is listed the ordinary parent listing
walks it with no further special-casing.
- The root listing also carries one virtual "Shared with me" folder. Drive has no
parent whose children are the files others shared with you — it is the query
`sharedWithMe = true` — so that row uses a synthetic id the plugin branches on
in both `loadFiles` and `loadMoreFiles`. Every other part of the picker treats
it as an ordinary folder.

Both additions land on the root's first page only, never on a continuation page,
so they appear exactly once and pagination is unaffected in either the shared
drives or the "Shared with me" view.

The flag defaults off. Nothing is sent, no `drives.list` is issued and no extra
row appears when it is unset or false, so no existing picker changes shape.
2 changes: 1 addition & 1 deletion .size-limit.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"path": "packages/core/dist/**/*.js",
"gzip": false,
"brotli": false,
"limit": "410 KB"
"limit": "420 KB"
},
{
"name": "@useupup/react",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,8 @@ type UploadSource =

Browser-safe cloud provider configuration for client mode. Google Drive takes `clientId`, `apiKey`, and `appId`; OneDrive, Dropbox, and Box each take `clientId` plus an optional `redirectUri`.

Google Drive also takes an optional `sharedDrives` boolean, default `false`. Left off, the picker lists the signed-in user's own My Drive only — a Drive `files.list` with no corpus set answers from that corpus, so a file living in a shared drive is invisible at every depth. Turn it on and the root of the picker also lists each of the user's shared drives as a folder, plus a "Shared with me" folder, both browsable and downloadable like any other. It needs no extra OAuth scope, and the picker keeps its current shape for everyone who leaves it unset.

```tsx
<UpupUploader
sources={['local', 'googleDrive']}
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/drives/configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@ export interface GoogleDriveConfig {
clientId: string
apiKey: string
appId: string
/**
* Widen the Drive `files.list` corpus past the signed-in user's own My Drive
* (#391). Off by default, so no existing picker changes shape: a Drive
* `files.list` with no `corpora` answers from the user's own corpus only, and
* a business account's files usually live in a shared drive, which then never
* appears at any depth. With this on, the plugin sends `corpora=allDrives`,
* `includeItemsFromAllDrives=true` and `supportsAllDrives=true`, and asks for
* shared-drive items on download too. Requires no extra OAuth scope — the
* `drive.readonly` scope the plugin already requests covers shared drives.
*/
sharedDrives?: boolean
}

export interface OneDriveConfig {
Expand Down
142 changes: 137 additions & 5 deletions packages/core/src/drives/google-drive-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,28 @@ const SK_EXPIRY = 'upup_gdrive_token_expiry'

// ── Google API endpoints ──
const FILES_URL = 'https://www.googleapis.com/drive/v3/files'
const DRIVES_URL = 'https://www.googleapis.com/drive/v3/drives'
const USER_INFO_URL = 'https://www.googleapis.com/oauth2/v3/userinfo'

const FOLDER_MIME = 'application/vnd.google-apps.folder'

/**
* The synthetic folder id standing for Drive's "Shared with me" view (#391).
* It is not a real Drive file id — "shared with me" is a QUERY
* (`sharedWithMe = true`), not a parent, so `'<id>' in parents` cannot express
* it. `loadFiles`/`loadMoreFiles` branch on this one value; everything else in
* the picker (navigation, breadcrumbs, pagination) treats it as an ordinary
* folder id and needs no knowledge of it.
*/
export const SHARED_WITH_ME_FOLDER_ID = '__upup_shared_with_me__'

/**
* Shown as the label of that virtual folder. English, like the per-provider root
* names in `drive-browser-descriptors.ts` — plugins have no translator, and a
* localised label needs a marker on `DriveFile` that no other entry carries.
*/
const SHARED_WITH_ME_FOLDER_NAME = 'Shared with me'

// ── Google Drive API response shapes (only the fields this file reads) ──

interface GoogleUserInfoResponse {
Expand All @@ -26,6 +46,11 @@ interface GoogleFilesListResponse {
nextPageToken?: string
}

interface GoogleDrivesListResponse {
drives?: { id?: string; name?: string }[]
nextPageToken?: string
}

// ── Google Workspace export mapping ──

const WORKSPACE_EXPORT_MAP: Record<
Expand Down Expand Up @@ -116,6 +141,95 @@ export class GoogleDrivePlugin implements DrivePlugin {
private tokenExpiry = 0
private state: DriveState = 'idle'

/**
* The shared-drive query params, or nothing when `sharedDrives` is off (#391).
* Drive v3 answers a `files.list` from the caller's own corpus unless all
* three are present, so a file living in a shared drive is invisible at every
* depth — including to the picker's search box, which filters the children
* already loaded. Spread into the params of every listing call.
*/
private sharedDriveParams(): Record<string, string> {
return this.config.sharedDrives
? {
corpora: 'allDrives',
includeItemsFromAllDrives: 'true',
supportsAllDrives: 'true',
}
: {}
}

/**
* The `files.list` query for one picker folder. Every id is a parent except
* the synthetic "Shared with me" one, which is a query instead — Drive has no
* folder whose children are the files other people shared with you.
*/
private listQuery(parentId: string): string {
return parentId === SHARED_WITH_ME_FOLDER_ID
? 'sharedWithMe = true and trashed = false'
: `'${parentId}' in parents and trashed = false`
}

/**
* The user's shared drives as navigable folder entries (#391).
*
* `corpora=allDrives` widens which files a query CAN return, but every
* listing is still `'<parentId>' in parents`, and `'root'` resolves to My
* Drive root — so without this a shared drive has no entry to click and its
* contents stay unreachable at every depth. A shared drive's root folder id
* IS its drive id, so once one is listed the ordinary parent listing walks it
* with no further special-casing.
*
* Requires no extra OAuth scope: `drive.readonly` covers `drives.list`.
*/
private async listSharedDriveFolders(): Promise<DriveFile[]> {
const folders: DriveFile[] = []
let pageToken: string | undefined

// Bounded at 10 pages of 100. Someone in more than a thousand shared
// drives is past what a flat picker list serves anyway, and the cap means
// a malformed nextPageToken cannot spin here forever.
for (let page = 0; page < 10; page++) {
const params = new URLSearchParams({
pageSize: '100',
fields: 'nextPageToken,drives(id,name)',
key: this.config.apiKey,
})
if (pageToken) params.set('pageToken', pageToken)

// oxlint-disable-next-line no-await-in-loop -- cursor pagination: each page's token comes from the previous response, so these cannot run in parallel
const res = await this.apiRequest(
`${DRIVES_URL}?${params.toString()}`,
{ method: 'GET' },
)
// oxlint-disable-next-line no-await-in-loop -- same round trip as the request above
const data = (await res.json()) as GoogleDrivesListResponse

for (const drive of data.drives ?? []) {
folders.push(
mapGoogleEntry({
id: drive.id,
name: drive.name,
mimeType: FOLDER_MIME,
}),
)
}

pageToken = data.nextPageToken
if (!pageToken) break
}

return folders
}

/** The virtual "Shared with me" entry, shaped like any other folder row. */
private sharedWithMeFolder(): DriveFile {
return mapGoogleEntry({
id: SHARED_WITH_ME_FOLDER_ID,
name: SHARED_WITH_ME_FOLDER_NAME,
mimeType: FOLDER_MIME,
})
}

// ── Plugin lifecycle ──

configure(config: GoogleDriveConfig): this {
Expand Down Expand Up @@ -257,13 +371,13 @@ export class GoogleDrivePlugin implements DrivePlugin {

try {
const parentId = folderId || 'root'
const q = `'${parentId}' in parents and trashed = false`

const params = new URLSearchParams({
q,
q: this.listQuery(parentId),
fields: 'nextPageToken,files(fileExtension,id,mimeType,name,parents,size,thumbnailLink)',
key: this.config.apiKey,
pageSize: '1000',
...this.sharedDriveParams(),
})

const res = await this.apiRequest(
Expand All @@ -273,6 +387,17 @@ export class GoogleDrivePlugin implements DrivePlugin {

const data = (await res.json()) as GoogleFilesListResponse
const files: DriveFile[] = (data.files ?? []).map(mapGoogleEntry)

// The two doors out of My Drive, appended to the root page only —
// after its own children, and never on a continuation page, so they
// appear exactly once (#391).
if (this.config.sharedDrives && parentId === 'root') {
files.push(
...(await this.listSharedDriveFolders()),
this.sharedWithMeFolder(),
)
}

const hasMore = !!data.nextPageToken
const cursor = hasMore
? JSON.stringify({
Expand Down Expand Up @@ -317,14 +442,16 @@ export class GoogleDrivePlugin implements DrivePlugin {
folderId: string
pageToken: string
}
const q = `'${folderId}' in parents and trashed = false`

const params = new URLSearchParams({
q,
// Same builder as loadFiles, so page 2 of the "Shared with me"
// view stays a sharedWithMe query and does not silently become
// `'__upup_shared_with_me__' in parents`, which matches nothing.
q: this.listQuery(folderId),
fields: 'nextPageToken,files(fileExtension,id,mimeType,name,parents,size,thumbnailLink)',
key: this.config.apiKey,
pageSize: '1000',
pageToken,
...this.sharedDriveParams(),
})

const res = await this.apiRequest(
Expand Down Expand Up @@ -392,9 +519,14 @@ export class GoogleDrivePlugin implements DrivePlugin {
private async downloadRegularFile(
driveFile: DriveFile,
): Promise<File | null> {
// `supportsAllDrives` is the files.get half of #391: without it a file
// the widened listing surfaced answers 404 on download, which would make
// the picker list shared-drive files it cannot fetch. `corpora` and
// `includeItemsFromAllDrives` are files.list-only and stay out of here.
const params = new URLSearchParams({
key: this.config.apiKey,
alt: 'media',
...(this.config.sharedDrives ? { supportsAllDrives: 'true' } : {}),
})

const res = await this.apiRequest(
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/types/uploader-props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,13 @@ export type UploaderBaseProps = {
/** Cloud drive configurations. */
cloudDrives?:
| {
googleDrive?: { clientId: string; apiKey: string; appId: string }
googleDrive?: {
clientId: string
apiKey: string
appId: string
/** Reach shared drives, not just My Drive (#391). Default false. */
sharedDrives?: boolean
}
oneDrive?: { clientId: string; redirectUri?: string }
dropbox?: { clientId: string; redirectUri?: string }
box?: { clientId: string; redirectUri?: string }
Expand Down
18 changes: 18 additions & 0 deletions packages/core/tests/drive-configs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,24 @@ describe('drive configs exported from core (one camelCase shape)', () => {
appId: string
}>()
})
it('GoogleDriveConfig accepts an optional sharedDrives flag and still typechecks without it (#391)', () => {
const withoutFlag: GoogleDriveConfig = {
clientId: 'g',
apiKey: 'k',
appId: 'a',
}
const withFlag: GoogleDriveConfig = {
clientId: 'g',
apiKey: 'k',
appId: 'a',
sharedDrives: true,
}
expectTypeOf(withoutFlag).toMatchTypeOf<GoogleDriveConfig>()
expectTypeOf(withFlag).toMatchTypeOf<GoogleDriveConfig>()
expectTypeOf<GoogleDriveConfig['sharedDrives']>().toEqualTypeOf<
boolean | undefined
>()
})
it('OneDriveConfig requires clientId; redirectUri optional', () => {
const _: OneDriveConfig = { clientId: 'c' }
expectTypeOf(_).toMatchTypeOf<OneDriveConfig>()
Expand Down
Loading
Loading