Skip to content

Commit c17d589

Browse files
authored
Merge pull request #13033 from owncloud/chore/backport-archiver-fix
chore: [OCISDEV-147] backport sign public link archiver download URL
2 parents 51096ef + dd56a3f commit c17d589

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)