Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Shortcut url followup #9908

Merged
merged 25 commits into from
Nov 8, 2023
Merged
Show file tree
Hide file tree
Changes from 11 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
141 changes: 124 additions & 17 deletions packages/web-pkg/src/components/CreateShortcutModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,64 @@
:button-confirm-text="$gettext('Create')"
:button-confirm-disabled="confirmButtonDisabled"
@cancel="cancel"
@confirm="createShortcut"
@confirm="createShortcut(inputUrl, inputFilename)"
@keydown.enter="onKeyDownEnter"
>
<template #content>
url
<oc-text-input v-model="inputUrl" />
<div class="oc-flex oc-flex-bottom oc-width-1-1 oc-mt-m">
<oc-text-input
id="create-shortcut-modal-url-input"
v-model="inputUrl"
:label="$gettext('Shortcut to a webpage or file')"
/>
<oc-drop
v-if="showDrop"
ref="dropRef"
class="oc-pt-s"
padding-size="remove"
drop-id="create-shortcut-modal-contextmenu"
toggle="#create-shortcut-modal-url-input"
:close-on-click="true"
>
<oc-list>
<li class="oc-p-s">
<oc-button
class="oc-width-1-1"
appearance="raw"
justify-content="left"
@click="dropItemUrlClicked"
>
<oc-icon name="external-link" />
<span v-text="dropItemUrl" />
</oc-button>
</li>
</oc-list>
</oc-drop>
<div class="oc-flex oc-width-1-1 oc-mt-m">
<oc-text-input
v-model="inputFilename"
class="oc-width-1-1"
:label="$gettext('Shortcut name')"
:error-message="inputFileNameErrorMessage"
:fix-message-line="true"
/>
<span class="oc-ml-s">.url</span>
<span class="oc-ml-s oc-flex oc-flex-bottom create-shortcut-modal-url-extension"
>.url</span
>
</div>
</template>
</oc-modal>
</portal>
</template>

<script lang="ts">
import { defineComponent, PropType, ref, unref, computed } from 'vue'
import { SpaceResource } from '@ownclouders/web-client'
import { defineComponent, PropType, ref, unref, computed, watch, nextTick } from 'vue'
import { Resource, SpaceResource } from '@ownclouders/web-client'
import { useClientService, useStore } from '../composables'
import { urlJoin } from '@ownclouders/web-client/src/utils'
import { useGettext } from 'vue3-gettext'
import DOMPurify from 'dompurify'
import { OcDrop } from '@ownclouders/design-system/src/components'
import { resolveFileNameDuplicate } from '../helpers'

export default defineComponent({
name: 'CreateShortcutModal',
Expand All @@ -48,24 +81,70 @@ export default defineComponent({
const clientService = useClientService()
const { $gettext } = useGettext()
const store = useStore()
const dropRef = ref(null)
const inputUrl = ref('')
const inputFilename = ref('')
const confirmButtonDisabled = computed(() => !(unref(inputUrl) && unref(inputFilename)))

const currentFolder = computed(() => {
return store.getters['Files/currentFolder']
const dropItemUrl = computed(() => {
let url = unref(inputUrl).trim()

if (isMaybeUrl(url)) {
return url
}

return `https://${url}`
})

const showDrop = computed(() => unref(inputUrl).trim())
const confirmButtonDisabled = computed(
() => unref(fileAlreadyExists) || !unref(inputFilename) || !unref(inputUrl)
)
const currentFolder = computed(() => store.getters['Files/currentFolder'])
const files = computed((): Array<Resource> => store.getters['Files/files'])
const fileAlreadyExists = computed(
() => !!unref(files).find((file) => file.name === `${unref(inputFilename)}.url`)
)

const inputFileNameErrorMessage = computed(() => {
if (unref(fileAlreadyExists)) {
return $gettext('%{name} already exists', { name: `${unref(inputFilename)}.url` })
}

return ''
})

const createShortcut = async () => {
const isMaybeUrl = (input: string) => {
const urlPrefixes = ['http://', 'https://']
return urlPrefixes.some((prefix) => prefix.startsWith(input) || input.startsWith(prefix))
}

const dropItemUrlClicked = () => {
inputUrl.value = unref(dropItemUrl)
try {
let fileName = new URL(unref(dropItemUrl)).host
if (unref(files).some((f) => f.name === `${fileName}.url`)) {
fileName = resolveFileNameDuplicate(`${fileName}.url`, 'url', unref(files)).slice(0, -4)
}
inputFilename.value = fileName
} catch (_) {}
}

const onKeyDownEnter = () => {
if (!unref(confirmButtonDisabled)) {
createShortcut(unref(inputUrl), unref(inputFilename))
}
}

const createShortcut = async (url: string, filename: string) => {
// Closes the modal
props.cancel()

try {
// Omit possible xss code
const sanitizedUrl = DOMPurify.sanitize(unref(inputUrl), { USE_PROFILES: { html: true } })
const sanitizedUrl = DOMPurify.sanitize(url, { USE_PROFILES: { html: true } })

const content = `[InternetShortcut]\nURL=${unref(sanitizedUrl)}`
const path = urlJoin(unref(currentFolder).path, `${unref(inputFilename)}.url`)
const content = `[InternetShortcut]\nURL=${sanitizedUrl}`
const path = urlJoin(unref(currentFolder).path, `${filename}.url`)
const resource = await clientService.webdav.putFileContents(props.space, {
path,
content
Expand All @@ -83,12 +162,40 @@ export default defineComponent({
}
}

watch(inputUrl, async () => {
await nextTick()
if (unref(showDrop) && unref(dropRef)) {
;(unref(dropRef) as InstanceType<typeof OcDrop>).show()
}
})

return {
confirmButtonDisabled,
createShortcut,
inputUrl,
inputFilename
inputFilename,
showDrop,
dropRef,
dropItemUrl,
dropItemUrlClicked,
createShortcut,
confirmButtonDisabled,
inputFileNameErrorMessage,
onKeyDownEnter
}
}
})
</script>
<style lang="scss" scoped>
.create-shortcut-modal {
&-url-extension {
margin-bottom: calc(var(--oc-space-xsmall) + 1.3125rem);
}
}

#create-shortcut-modal-contextmenu {
width: 458px;

li:hover {
background-color: var(--oc-color-background-highlight);
}
}
</style>
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ import { useGettext } from 'vue3-gettext'
import { Store } from 'vuex'
import { useClientService } from '../../clientService'
import DOMPurify from 'dompurify'
import { useConfigurationManager } from '../../configuration'

export const useFileActionsOpenShortcut = ({ store }: { store?: Store<any> } = {}) => {
const router = useRouter()
const { $gettext } = useGettext()
const isFilesAppActive = useIsFilesAppActive()
const isSearchActive = useIsSearchActive()
const clientService = useClientService()
const configurationManger = useConfigurationManager()

const extractUrl = (fileContents: string) => {
const regex = /URL=(.+)/
Expand All @@ -34,12 +36,20 @@ export const useFileActionsOpenShortcut = ({ store }: { store?: Store<any> } = {
const handler = async ({ resources, space }: FileActionOptions) => {
try {
const fileContents = (await clientService.webdav.getFileContents(space, resources[0])).body
const url = extractUrl(fileContents)
let url = extractUrl(fileContents)

// Add protocol if missing
url = url.match(/^http[s]?:\/\//) ? url : `https://${url}`

// Omit possible xss code
const sanitizedUrl = DOMPurify.sanitize(url, { USE_PROFILES: { html: true } })
url = DOMPurify.sanitize(url, { USE_PROFILES: { html: true } })

if (url.startsWith(configurationManger.serverUrl)) {
AlexAndBear marked this conversation as resolved.
Show resolved Hide resolved
window.location.href = url
return
}

window.open(sanitizedUrl)
window.open(url)
} catch (e) {
console.error(e)
store.dispatch('showErrorMessage', {
Expand Down
70 changes: 70 additions & 0 deletions packages/web-pkg/tests/unit/components/CreateShortcutModal.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import CreateShortcutModal from '../../../src/components/CreateShortcutModal.vue'
import {
createStore,
defaultComponentMocks,
defaultPlugins,
defaultStoreMockOptions,
mockAxiosReject,
RouteLocation,
shallowMount
} from 'web-test-helpers'
import { SpaceResource } from '@ownclouders/web-client'
import { mock } from 'jest-mock-extended'
import { FileResource } from '@ownclouders/web-client/src/helpers'

describe('CreateShortcutModal', () => {
describe('method "createShortcut"', () => {
it('should show message on success', async () => {
const { wrapper, storeOptions } = getWrapper()
await wrapper.vm.createShortcut('https://owncloud.com', 'owncloud.url')
expect(storeOptions.modules.Files.mutations.UPSERT_RESOURCE).toHaveBeenCalled()
expect(storeOptions.actions.showMessage).toHaveBeenCalled()
})
it('should show error message on fail', async () => {
console.error = jest.fn()
const { wrapper, storeOptions } = getWrapper({ rejectPutFileContents: true })
await wrapper.vm.createShortcut('https://owncloud.com', 'owncloud.url')
expect(storeOptions.modules.Files.mutations.UPSERT_RESOURCE).not.toHaveBeenCalled()
expect(storeOptions.actions.showErrorMessage).toHaveBeenCalled()
})
})
})

function getWrapper({ rejectPutFileContents = false } = {}) {
const storeOptions = {
...defaultStoreMockOptions
}

storeOptions.modules.Files.getters.currentFolder.mockImplementation(() => mock<FileResource>())

const store = createStore(storeOptions)

const mocks = {
...defaultComponentMocks({
currentRoute: mock<RouteLocation>({ name: 'files-spaces-generic' })
})
}

if (rejectPutFileContents) {
mocks.$clientService.webdav.putFileContents.mockRejectedValue(() => mockAxiosReject())
} else {
mocks.$clientService.webdav.putFileContents.mockResolvedValue(mock<FileResource>())
}

return {
mocks,
storeOptions,
wrapper: shallowMount(CreateShortcutModal, {
props: {
cancel: jest.fn(),
space: mock<SpaceResource>(),
title: 'Personal quota'
},
global: {
plugins: [...defaultPlugins(), store],
mocks,
provide: mocks
}
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
RouteLocation,
getComposableWrapper
} from 'web-test-helpers'
import { useFileActionsOpenShortcut, useRoute } from '../../../../../src'
import { ConfigurationManager, useFileActionsOpenShortcut, useRoute } from '../../../../../src'
import { Resource } from '@ownclouders/web-client'
import { GetFileContentsResponse } from '@ownclouders/web-client/src/webdav/getFileContents'

Expand All @@ -16,6 +16,13 @@ jest.mock('../../../../../src/composables/router', () => ({
useRoute: jest.fn()
}))

jest.mock('../../../../../src/composables/configuration', () => ({
useConfigurationManager: () =>
mock<ConfigurationManager>({
serverUrl: 'https://demo.owncloud.com'
})
}))

window = Object.create(window)
Object.defineProperty(window, 'location', {
value: {
Expand Down Expand Up @@ -55,6 +62,18 @@ describe('openShortcut', () => {
})
})
describe('method "handler"', () => {
it('adds http(s) protocol if missing and opens the url in a new tab', () => {
getWrapper({
getFileContentsValue: '[InternetShortcut]\nURL=owncloud.com',
setup: async ({ actions }) => {
await unref(actions)[0].handler({
resources: [mock<Resource>()],
space: null
})
expect(window.open).toHaveBeenCalledWith('https://owncloud.com')
}
})
})
it('omits xss code and opens the url in a new tab', () => {
getWrapper({
getFileContentsValue:
Expand All @@ -68,6 +87,18 @@ describe('openShortcut', () => {
}
})
})
it('opens the url in the same window if url links to OCIS instance', () => {
getWrapper({
getFileContentsValue: '[InternetShortcut]\nURL=https://demo.owncloud.com',
setup: async ({ actions }) => {
await unref(actions)[0].handler({
resources: [mock<Resource>()],
space: null
})
expect(window.location.href).toBe('https://demo.owncloud.com')
}
})
})
})
})
describe('method "extractUrl"', () => {
Expand Down Expand Up @@ -108,7 +139,6 @@ function getWrapper({
})
}

// url contains xss code to test xss protection
mocks.$clientService.webdav.getFileContents.mockResolvedValue(
mock<GetFileContentsResponse>({
body: getFileContentsValue
Expand Down