Skip to content

Commit dd56a3f

Browse files
committed
fix: [OCISDEV-147] sign public link archiver download URL
Start signing the archiver download URL in public link context in case the link is password protected. This allows users to download large archives without memory limits imposed by browsers.
1 parent 51096ef commit dd56a3f

File tree

11 files changed

+144
-58
lines changed

11 files changed

+144
-58
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Bugfix: Sign public link archiver download URL
2+
3+
We've started signing the archiver download URL in public link context in case the link is password protected.
4+
This allows users to download large archives without memory limits imposed by browsers.
5+
6+
https://github.com/owncloud/web/pull/12943
7+
https://github.com/owncloud/web/issues/12811

packages/web-app-files/src/helpers/user/avatarUrl.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export const avatarUrl = async (options: AvatarUrlOptions, cached = false): Prom
2323
throw new Error(statusText)
2424
}
2525

26-
return options.clientService.ocs.signUrl(url, options.username)
26+
return options.clientService.ocs.signUrl({ url, username: options.username })
2727
}
2828

2929
const cacheFactory = async (options: AvatarUrlOptions): Promise<string> => {

packages/web-app-files/tests/unit/helpers/user/avatarUrl.spec.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ describe('avatarUrl', () => {
2929
defaultOptions.clientService.httpAuthenticated.head.mockResolvedValue({
3030
status: 200
3131
} as AxiosResponse)
32-
defaultOptions.clientService.ocs.signUrl.mockImplementation((url) => {
33-
return Promise.resolve(`${url}?signed=true`)
32+
defaultOptions.clientService.ocs.signUrl.mockImplementation((payload) => {
33+
return Promise.resolve(`${payload.url}?signed=true`)
3434
})
3535
const avatarUrlPromise = avatarUrl(defaultOptions)
3636
await expect(avatarUrlPromise).resolves.toBe(`${buildUrl(defaultOptions)}?signed=true`)
@@ -40,7 +40,9 @@ describe('avatarUrl', () => {
4040
defaultOptions.clientService.httpAuthenticated.head.mockResolvedValue({
4141
status: 200
4242
} as AxiosResponse)
43-
defaultOptions.clientService.ocs.signUrl.mockImplementation((url) => Promise.resolve(url))
43+
defaultOptions.clientService.ocs.signUrl.mockImplementation((payload) =>
44+
Promise.resolve(payload.url)
45+
)
4446

4547
const avatarUrlPromiseUncached = avatarUrl(defaultOptions, true)
4648
await expect(avatarUrlPromiseUncached).resolves.toBe(buildUrl(defaultOptions))

packages/web-client/src/ocs/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { Capabilities, GetCapabilitiesFactory } from './capabilities'
22
import { AxiosInstance } from 'axios'
3-
import { UrlSign } from './urlSign'
3+
import { SignUrlPayload, UrlSign } from './urlSign'
44

55
export * from './capabilities'
66

77
export interface OCS {
88
getCapabilities: () => Promise<Capabilities>
9-
signUrl: (url: string, username: string) => Promise<string>
9+
signUrl: (payload: SignUrlPayload) => Promise<string>
1010
}
1111

1212
export const ocs = (baseURI: string, axiosClient: AxiosInstance): OCS => {
@@ -22,8 +22,8 @@ export const ocs = (baseURI: string, axiosClient: AxiosInstance): OCS => {
2222
getCapabilities: () => {
2323
return capabilitiesFactory.getCapabilities()
2424
},
25-
signUrl: (url: string, username: string) => {
26-
return urlSign.signUrl(url, username)
25+
signUrl: (payload: SignUrlPayload) => {
26+
return urlSign.signUrl(payload)
2727
}
2828
}
2929
}

packages/web-client/src/ocs/urlSign.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ export interface UrlSignOptions {
88
baseURI: string
99
}
1010

11+
export type SignUrlPayload = {
12+
url: string
13+
username: string
14+
publicToken?: string
15+
publicLinkPassword?: string
16+
}
17+
1118
export class UrlSign {
1219
private axiosClient: AxiosInstance
1320
private baseURI: string
@@ -24,30 +31,42 @@ export class UrlSign {
2431
this.baseURI = baseURI
2532
}
2633

27-
public async signUrl(url: string, username: string) {
34+
public async signUrl({ url, username, publicToken, publicLinkPassword }: SignUrlPayload) {
2835
const signedUrl = new URL(url)
2936
signedUrl.searchParams.set('OC-Credential', username)
3037
signedUrl.searchParams.set('OC-Date', new Date().toISOString())
3138
signedUrl.searchParams.set('OC-Expires', this.TTL.toString())
3239
signedUrl.searchParams.set('OC-Verb', 'GET')
3340

34-
const hashedKey = await this.createHashedKey(signedUrl.toString())
41+
const hashedKey = await this.createHashedKey(
42+
signedUrl.toString(),
43+
publicToken,
44+
publicLinkPassword
45+
)
3546

3647
signedUrl.searchParams.set('OC-Algo', `PBKDF2/${this.ITERATION_COUNT}-SHA512`)
3748
signedUrl.searchParams.set('OC-Signature', hashedKey)
3849

3950
return signedUrl.toString()
4051
}
4152

42-
private async getSignKey() {
53+
private async getSignKey(publicToken?: string, publicLinkPassword?: string) {
4354
if (this.signingKey) {
4455
return this.signingKey
4556
}
4657

4758
const data = await this.axiosClient.get(
4859
urlJoin(this.baseURI, 'ocs/v1.php/cloud/user/signing-key'),
4960
{
50-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
61+
params: {
62+
...(publicToken && { 'public-token': publicToken })
63+
},
64+
headers: {
65+
'Content-Type': 'application/x-www-form-urlencoded',
66+
...(publicLinkPassword && {
67+
Authorization: `Basic ${Buffer.from(['public', publicLinkPassword].join(':')).toString('base64')}`
68+
})
69+
}
5170
}
5271
)
5372

@@ -56,8 +75,8 @@ export class UrlSign {
5675
return this.signingKey
5776
}
5877

59-
private async createHashedKey(url: string) {
60-
const signignKey = await this.getSignKey()
78+
private async createHashedKey(url: string, publicToken?: string, publicLinkPassword?: string) {
79+
const signignKey = await this.getSignKey(publicToken, publicLinkPassword)
6180
const hashedKey = pbkdf2Sync(
6281
url,
6382
signignKey,

packages/web-client/src/webdav/getFileUrl.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export const GetFileUrlFactory = (
4848
// sign url
4949
if (isUrlSigningEnabled && username) {
5050
const ocsClient = ocs(baseUrl, axiosClient)
51-
downloadURL = await ocsClient.signUrl(downloadURL, username)
51+
downloadURL = await ocsClient.signUrl({ url: downloadURL, username })
5252
} else {
5353
signed = false
5454
}

packages/web-pkg/src/composables/actions/files/useFileActionsDownloadArchive.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,15 @@ export const useFileActionsDownloadArchive = () => {
4242
dir: path.dirname(first<Resource>(resources).path) || '/',
4343
files: resources.map((resource) => resource.name)
4444
}
45+
4546
return archiverService
4647
.triggerDownload({
4748
...fileOptions,
4849
...(space &&
4950
isPublicSpaceResource(space) && {
5051
publicToken: space.id as string,
51-
publicLinkPassword: authStore.publicLinkPassword
52+
publicLinkPassword: authStore.publicLinkPassword,
53+
publicLinkShareOwner: space.publicLinkShareOwner
5254
})
5355
})
5456
.catch((e) => {

packages/web-pkg/src/services/archiver.ts

Lines changed: 11 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
11
import { RuntimeError } from '../errors'
2-
import { HttpError } from '@ownclouders/web-client'
32
import { ClientService } from '../services'
43
import { urlJoin } from '@ownclouders/web-client'
5-
import { triggerDownloadWithFilename } from '../helpers/download'
64

75
import { Ref, ref, computed, unref } from 'vue'
86
import { ArchiverCapability } from '@ownclouders/web-client/ocs'
97
import { UserStore } from '../composables'
10-
import { AxiosResponseHeaders, RawAxiosResponseHeaders } from 'axios'
118

129
interface TriggerDownloadOptions {
1310
dir?: string
@@ -16,6 +13,7 @@ interface TriggerDownloadOptions {
1613
downloadSecret?: string
1714
publicToken?: string
1815
publicLinkPassword?: string
16+
publicLinkShareOwner?: string
1917
}
2018

2119
function sortArchivers(a: ArchiverCapability, b: ArchiverCapability): number {
@@ -85,42 +83,23 @@ export class ArchiverService {
8583
throw new RuntimeError('download url could not be built')
8684
}
8785

88-
if (options.publicToken && options.publicLinkPassword) {
89-
try {
90-
const response = await this.clientService.httpUnAuthenticated.get<Blob>(downloadUrl, {
91-
headers: {
92-
...(!!options.publicLinkPassword && {
93-
Authorization:
94-
'Basic ' +
95-
Buffer.from(['public', options.publicLinkPassword].join(':')).toString('base64')
96-
})
97-
},
98-
responseType: 'blob'
99-
})
100-
101-
const objectUrl = URL.createObjectURL(response.data)
102-
const fileName = this.getFileNameFromResponseHeaders(response.headers)
103-
triggerDownloadWithFilename(objectUrl, fileName)
104-
return downloadUrl
105-
} catch (e) {
106-
throw new HttpError('archive could not be fetched', e.response)
107-
}
108-
}
109-
110-
const url = options.publicToken
111-
? downloadUrl
112-
: await this.clientService.ocs.signUrl(
113-
downloadUrl,
114-
this.userStore.user?.onPremisesSamAccountName
115-
)
86+
const url =
87+
options.publicToken && !options.publicLinkPassword
88+
? downloadUrl
89+
: await this.clientService.ocs.signUrl({
90+
url: downloadUrl,
91+
username: options.publicLinkShareOwner || this.userStore.user?.onPremisesSamAccountName,
92+
publicToken: options.publicToken,
93+
publicLinkPassword: options.publicLinkPassword
94+
})
11695

11796
window.open(url, '_blank')
11897
return downloadUrl
11998
}
12099

121100
private buildDownloadUrl(options: TriggerDownloadOptions): string {
122101
const queryParams = []
123-
if (options.publicToken) {
102+
if (options.publicToken && !options.publicLinkPassword) {
124103
queryParams.push(`public-token=${options.publicToken}`)
125104
}
126105

@@ -138,9 +117,4 @@ export class ArchiverService {
138117
}
139118
return urlJoin(this.serverUrl, capability.archiver_url)
140119
}
141-
142-
private getFileNameFromResponseHeaders(headers: RawAxiosResponseHeaders | AxiosResponseHeaders) {
143-
const fileName = headers['content-disposition']?.split('"')[1]
144-
return decodeURI(fileName)
145-
}
146120
}

packages/web-pkg/src/services/client/client.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,8 @@ export class ClientService {
168168
return {
169169
'Accept-Language': this.currentLanguage,
170170
'X-Request-ID': uuidV4(),
171-
...(useAuth && { Authorization: 'Bearer ' + this.authStore.accessToken })
171+
...(useAuth &&
172+
this.authStore.accessToken && { Authorization: 'Bearer ' + this.authStore.accessToken })
172173
}
173174
}
174175

packages/web-pkg/tests/unit/services/archiver.spec.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { AxiosResponse } from 'axios'
77
import { ArchiverCapability } from '@ownclouders/web-client/ocs'
88
import { createTestingPinia } from '@ownclouders/web-test-helpers'
99
import { useUserStore } from '../../../src/composables/piniaStores'
10+
import { User } from '@ownclouders/web-client/graph/generated'
1011

1112
const serverUrl = 'https://demo.owncloud.com'
1213
const getArchiverServiceInstance = (capabilities: Ref<ArchiverCapability[]>) => {
@@ -18,7 +19,7 @@ const getArchiverServiceInstance = (capabilities: Ref<ArchiverCapability[]>) =>
1819
data: new ArrayBuffer(8),
1920
headers: { 'content-disposition': 'filename="download.tar"' }
2021
} as unknown as AxiosResponse)
21-
clientServiceMock.ocs.signUrl.mockImplementation((url) => Promise.resolve(url))
22+
clientServiceMock.ocs.signUrl.mockImplementation((payload) => Promise.resolve(payload.url))
2223

2324
Object.defineProperty(window, 'open', {
2425
value: vi.fn(),
@@ -109,4 +110,44 @@ describe('archiver', () => {
109110
const downloadUrl = await archiverService.triggerDownload({ fileIds: ['any'] })
110111
expect(downloadUrl.startsWith(archiverUrl + '/v2')).toBeTruthy()
111112
})
113+
114+
it('should sign the download url if a public token is not provided', async () => {
115+
const archiverService = getArchiverServiceInstance(capabilities)
116+
117+
const user = mock<User>({ onPremisesSamAccountName: 'private-owner' })
118+
archiverService.userStore.user = user
119+
120+
const fileId = 'asdf'
121+
await archiverService.triggerDownload({ fileIds: [fileId] })
122+
expect(archiverService.clientService.ocs.signUrl).toHaveBeenCalledWith({
123+
url: archiverUrl + '?id=' + fileId,
124+
username: 'private-owner',
125+
publicToken: undefined,
126+
publicLinkPassword: undefined
127+
})
128+
})
129+
130+
it('should sign the download url if a public token is provided with a password', async () => {
131+
const archiverService = getArchiverServiceInstance(capabilities)
132+
const fileId = 'asdf'
133+
await archiverService.triggerDownload({
134+
fileIds: [fileId],
135+
publicToken: 'token',
136+
publicLinkPassword: 'password',
137+
publicLinkShareOwner: 'owner'
138+
})
139+
expect(archiverService.clientService.ocs.signUrl).toHaveBeenCalledWith({
140+
url: archiverUrl + '?id=' + fileId,
141+
username: 'owner',
142+
publicToken: 'token',
143+
publicLinkPassword: 'password'
144+
})
145+
})
146+
147+
it('should not sign the download url if a public token is provided without a password', async () => {
148+
const archiverService = getArchiverServiceInstance(capabilities)
149+
const fileId = 'asdf'
150+
await archiverService.triggerDownload({ fileIds: [fileId], publicToken: 'token' })
151+
expect(archiverService.clientService.ocs.signUrl).not.toHaveBeenCalled()
152+
})
112153
})

0 commit comments

Comments
 (0)