Skip to content

Commit 8d1b65c

Browse files
authored
Merge pull request #1486 from nextcloud-libraries/backport/1478/stable5
[stable5] feat: Rate-limit image previews
2 parents 4764cd5 + cca1f93 commit 8d1b65c

File tree

8 files changed

+136
-50
lines changed

8 files changed

+136
-50
lines changed

lib/components/FilePicker/FilePreview.vue

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
- SPDX-License-Identifier: AGPL-3.0-or-later
44
-->
55
<template>
6-
<div :style="canLoadPreview ? { backgroundImage: `url(${previewURL})`} : undefined"
6+
<div :style="previewLoaded ? { backgroundImage: `url(${previewURL})`} : undefined"
77
:class="fileListIconStyles['file-picker__file-icon']">
8-
<template v-if="!canLoadPreview">
8+
<template v-if="!previewLoaded">
99
<IconFile v-if="isFile" :size="20" />
1010
<IconFolder v-else :size="20" />
1111
</template>
@@ -14,14 +14,15 @@
1414

1515
<script setup lang="ts">
1616
import { FileType, type Node } from '@nextcloud/files'
17-
import { computed, ref, watchEffect } from 'vue'
18-
import { getPreviewURL } from '../../composables/preview'
17+
import { computed, ref, toRef } from 'vue'
18+
import { usePreviewURL } from '../../composables/preview'
1919
2020
import IconFile from 'vue-material-design-icons/File.vue'
2121
import IconFolder from 'vue-material-design-icons/Folder.vue'
2222
2323
// CSS modules
2424
import fileListIconStylesModule from './FileListIcon.module.scss'
25+
2526
// workaround for vue2.7 bug, can be removed with vue3
2627
const fileListIconStyles = ref(fileListIconStylesModule)
2728
@@ -30,21 +31,12 @@ const props = defineProps<{
3031
cropImagePreviews: boolean
3132
}>()
3233
33-
const previewURL = computed(() => getPreviewURL(props.node, { cropPreview: props.cropImagePreviews }))
34+
const {
35+
previewURL,
36+
previewLoaded,
37+
} = usePreviewURL(toRef(props, 'node'), computed(() => ({ cropPreview: props.cropImagePreviews })))
3438
3539
const isFile = computed(() => props.node.type === FileType.File)
36-
const canLoadPreview = ref(false)
37-
38-
watchEffect(() => {
39-
canLoadPreview.value = false
40-
41-
if (previewURL.value) {
42-
const loader = new Image()
43-
loader.src = previewURL.value.href
44-
loader.onerror = () => loader.remove()
45-
loader.onload = () => { canLoadPreview.value = true; loader.remove() }
46-
}
47-
})
4840
</script>
4941

5042
<script lang="ts">

lib/composables/preview.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
*/
55

66
import type { Node } from '@nextcloud/files'
7+
import type { MaybeRef } from '@vueuse/core'
78
import type { Ref } from 'vue'
89

910
import { generateUrl } from '@nextcloud/router'
1011
import { toValue } from '@vueuse/core'
1112
import { ref, watchEffect } from 'vue'
13+
import { preloadImage } from '../utils/imagePreload'
1214

1315
interface PreviewOptions {
1416
/**
@@ -66,14 +68,22 @@ export function getPreviewURL(node: Node, options: PreviewOptions = {}) {
6668
}
6769
}
6870

69-
export const usePreviewURL = (node: Node | Ref<Node>, options?: PreviewOptions | Ref<PreviewOptions>) => {
71+
export const usePreviewURL = (node: Node | Ref<Node>, options?: MaybeRef<PreviewOptions>) => {
7072
const previewURL = ref<URL|null>(null)
73+
const previewLoaded = ref(false)
7174

7275
watchEffect(() => {
76+
previewLoaded.value = false
7377
previewURL.value = getPreviewURL(toValue(node), toValue(options || {}))
78+
if (previewURL.value) {
79+
preloadImage(previewURL.value.href).then((success: boolean) => {
80+
previewLoaded.value = success
81+
})
82+
}
7483
})
7584

7685
return {
7786
previewURL,
87+
previewLoaded,
7888
}
7989
}

lib/utils/imagePreload.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import PQueue from 'p-queue'
7+
8+
const queue = new PQueue({ concurrency: 5 })
9+
10+
/**
11+
* Preload an image URL
12+
* @param url URL of the image
13+
*/
14+
export function preloadImage(url: string): Promise<boolean> {
15+
const { resolve, promise } = Promise.withResolvers<boolean>()
16+
queue.add(() => {
17+
const image = new Image()
18+
image.onerror = () => resolve(false)
19+
image.onload = () => resolve(true)
20+
image.src = url
21+
return promise
22+
})
23+
24+
return promise
25+
}

package-lock.json

Lines changed: 47 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
"@types/toastify-js": "^1.12.3",
6868
"@vueuse/core": "^10.11.1",
6969
"cancelable-promise": "^4.3.1",
70+
"p-queue": "^8.0.1",
7071
"toastify-js": "^1.12.0",
7172
"vue-frag": "^1.4.3",
7273
"webdav": "^5.7.1"
@@ -82,6 +83,7 @@
8283
"@vue/test-utils": "^1.3.6",
8384
"@vue/tsconfig": "^0.5.1",
8485
"@zamiell/typedoc-plugin-not-exported": "^0.3.0",
86+
"core-js": "^3.39.0",
8587
"gettext-extractor": "^3.8.0",
8688
"gettext-parser": "^8.0.0",
8789
"happy-dom": "^14.12.3",

test/setup.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: CC0-1.0
4+
*/
5+
6+
// Polyfill like the server does
7+
import 'core-js/stable/index.js'

vite.config.ts

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -31,29 +31,6 @@ export default defineConfig((env) => {
3131
// Fix for vite config, TODO: remove with next release
3232
cssCodeSplit: false,
3333
},
34-
// vitest configuration
35-
test: {
36-
environment: 'happy-dom',
37-
coverage: {
38-
all: true,
39-
provider: 'v8',
40-
include: ['lib/**/*.ts', 'lib/*.ts'],
41-
exclude: ['lib/**/*.spec.ts'],
42-
},
43-
css: {
44-
modules: {
45-
classNameStrategy: 'non-scoped',
46-
},
47-
},
48-
server: {
49-
deps: {
50-
inline: [
51-
/@nextcloud\/vue/, // Fix unresolvable .css extension for ssr
52-
/@nextcloud\/files/, // Fix CommonJS cancelable-promise not supporting named exports
53-
],
54-
},
55-
},
56-
},
5734
},
5835
// We build for ESM and legacy common js
5936
libraryFormats: ['es', 'cjs'],

vitest.config.ts

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,41 @@
33
* SPDX-License-Identifier: CC0-1.0
44
*/
55
import type { ConfigEnv } from 'vite'
6+
import { defineConfig, type ViteUserConfig } from 'vitest/config'
67
import config from './vite.config'
78

8-
export default async (env: ConfigEnv) => {
9+
export default defineConfig(async (env: ConfigEnv): Promise<ViteUserConfig> => {
910
const cfg = await config(env)
10-
// filter node-externals which will interfere with vitest
11-
cfg.plugins = cfg.plugins!.filter((plugin) => plugin && (!('name' in plugin) || plugin.name !== 'node-externals'))
12-
return cfg
13-
}
11+
12+
return {
13+
...cfg,
14+
15+
// filter node-externals which will interfere with vitest
16+
plugins: cfg.plugins!.filter((plugin) => plugin && (!('name' in plugin) || plugin.name !== 'node-externals')),
17+
18+
// vitest configuration
19+
test: {
20+
environment: 'happy-dom',
21+
coverage: {
22+
all: true,
23+
provider: 'v8',
24+
include: ['lib/**/*.ts', 'lib/*.ts'],
25+
exclude: ['lib/**/*.spec.ts'],
26+
},
27+
css: {
28+
modules: {
29+
classNameStrategy: 'non-scoped',
30+
},
31+
},
32+
setupFiles: 'test/setup.ts',
33+
server: {
34+
deps: {
35+
inline: [
36+
/@nextcloud\/vue/, // Fix unresolvable .css extension for ssr
37+
/@nextcloud\/files/, // Fix CommonJS cancelable-promise not supporting named exports
38+
],
39+
},
40+
},
41+
},
42+
}
43+
})

0 commit comments

Comments
 (0)