aboutsummaryrefslogtreecommitdiffstats
path: root/apps
diff options
context:
space:
mode:
authorskjnldsv <skjnldsv@protonmail.com>2024-06-20 14:02:53 +0200
committerJohn Molakvoæ <skjnldsv@users.noreply.github.com>2024-07-12 20:14:30 +0200
commit443c48aefb70983afefce48652880a1bcaad4d53 (patch)
tree791143357ce720357653a03489b81534d03f3699 /apps
parentb6f635f6f6dd79b073107bde7f7e378e00ceaccf (diff)
downloadnextcloud-server-443c48aefb70983afefce48652880a1bcaad4d53.tar.gz
nextcloud-server-443c48aefb70983afefce48652880a1bcaad4d53.zip
feat(files_sharing): add `new file request` option
Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
Diffstat (limited to 'apps')
-rw-r--r--apps/files_sharing/lib/Controller/ShareAPIController.php49
-rw-r--r--apps/files_sharing/src/components/NewFileRequestDialog.vue330
-rw-r--r--apps/files_sharing/src/components/NewFileRequestDialog/FileRequestDatePassword.vue227
-rw-r--r--apps/files_sharing/src/components/NewFileRequestDialog/FileRequestFinish.vue217
-rw-r--r--apps/files_sharing/src/components/NewFileRequestDialog/FileRequestIntro.vue153
-rw-r--r--apps/files_sharing/src/components/SharingEntryLink.vue6
-rw-r--r--apps/files_sharing/src/components/SharingInput.vue3
-rw-r--r--apps/files_sharing/src/init.ts5
-rw-r--r--apps/files_sharing/src/new/newFileRequest.ts50
-rw-r--r--apps/files_sharing/src/utils/GeneratePassword.ts (renamed from apps/files_sharing/src/utils/GeneratePassword.js)13
-rw-r--r--apps/files_sharing/src/views/SharingDetailsTab.vue6
-rw-r--r--apps/sharebymail/lib/ShareByMailProvider.php2
12 files changed, 1047 insertions, 14 deletions
diff --git a/apps/files_sharing/lib/Controller/ShareAPIController.php b/apps/files_sharing/lib/Controller/ShareAPIController.php
index dd2df96bd19..5a939e88914 100644
--- a/apps/files_sharing/lib/Controller/ShareAPIController.php
+++ b/apps/files_sharing/lib/Controller/ShareAPIController.php
@@ -12,14 +12,17 @@ namespace OCA\Files_Sharing\Controller;
use Exception;
use OC\Files\FileInfo;
use OC\Files\Storage\Wrapper\Wrapper;
+use OC\Share20\Exception\ProviderException;
use OCA\Files\Helper;
use OCA\Files_Sharing\Exceptions\SharingRightsException;
use OCA\Files_Sharing\External\Storage;
use OCA\Files_Sharing\ResponseDefinitions;
use OCA\Files_Sharing\SharedStorage;
+use OCA\ShareByMail\ShareByMailProvider;
use OCP\App\IAppManager;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
+use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\OCS\OCSBadRequestException;
use OCP\AppFramework\OCS\OCSException;
use OCP\AppFramework\OCS\OCSForbiddenException;
@@ -46,6 +49,7 @@ use OCP\Server;
use OCP\Share\Exceptions\GenericShareException;
use OCP\Share\Exceptions\ShareNotFound;
use OCP\Share\IManager;
+use OCP\Share\IProviderFactory;
use OCP\Share\IShare;
use OCP\UserStatus\IManager as IUserStatusManager;
use Psr\Container\ContainerExceptionInterface;
@@ -81,6 +85,7 @@ class ShareAPIController extends OCSController {
private IPreview $previewManager,
private IDateTimeZone $dateTimeZone,
private LoggerInterface $logger,
+ private IProviderFactory $factory,
?string $userId = null
) {
parent::__construct($appName, $request);
@@ -2025,6 +2030,50 @@ class ShareAPIController extends OCSController {
}
}
}
+ }
+
+ public function sendShareEmail(int $shareId, $emails = []) {
+ try {
+ $share = $this->shareManager->getShareById($shareId);
+
+ // Only mail and link shares are supported
+ if ($share->getShareType() !== IShare::TYPE_EMAIL
+ && $share->getShareType() !== IShare::TYPE_LINK) {
+ throw new OCSBadRequestException('Only email and link shares are supported');
+ }
+
+ // Allow sending the mail again if the share is an email share
+ if ($share->getShareType() === IShare::TYPE_EMAIL && count($emails) !== 0) {
+ throw new OCSBadRequestException('Emails should not be provided for email shares');
+ }
+
+ // Allow sending a mail if the share is a link share AND a list of emails is provided
+ if ($share->getShareType() === IShare::TYPE_LINK && count($emails) === 0) {
+ throw new OCSBadRequestException('Emails should be provided for link shares');
+ }
+
+ $link = $this->urlGenerator->linkToRouteAbsolute('files_sharing.sharecontroller.showShare',
+ ['token' => $share->getToken()]);
+ try {
+ /** @var ShareByMailProvider */
+ $provider = $this->factory->getProviderForType(IShare::TYPE_EMAIL);
+ $provider->sendMailNotification(
+ $share->getNode()->getName(),
+ $link,
+ $share->getSharedBy(),
+ $share->getSharedWith(),
+ $share->getExpirationDate(),
+ $share->getNote()
+ );
+ return new JSONResponse(['message' => 'ok']);
+ } catch (ProviderException $e) {
+ throw new OCSBadRequestException($this->l->t('Sending mail notification is not enabled'));
+ } catch (Exception $e) {
+ throw new OCSException($this->l->t('Error while sending mail notification'));
+ }
+ } catch (ShareNotFound $e) {
+ throw new OCSNotFoundException($this->l->t('Wrong share ID, share does not exist'));
+ }
}
}
diff --git a/apps/files_sharing/src/components/NewFileRequestDialog.vue b/apps/files_sharing/src/components/NewFileRequestDialog.vue
new file mode 100644
index 00000000000..53b4408b263
--- /dev/null
+++ b/apps/files_sharing/src/components/NewFileRequestDialog.vue
@@ -0,0 +1,330 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <NcDialog can-close
+ class="file-request-dialog"
+ data-cy-file-request-dialog
+ :close-on-click-outside="false"
+ :name="currentStep !== STEP.LAST ? t('files_sharing', 'Create a file request') : t('files_sharing', 'File request created')"
+ size="normal"
+ @closing="onCancel">
+ <!-- Header -->
+ <NcNoteCard v-show="currentStep === STEP.FIRST" type="info" class="file-request-dialog__header">
+ <p id="file-request-dialog-description" class="file-request-dialog__description">
+ {{ t('files_sharing', 'Collect files from others even if they don\'t have an account.') }}
+ {{ t('files_sharing', 'To ensure you can receive files, verify you have enough storage available.') }}
+ </p>
+ </NcNoteCard>
+
+ <!-- Main form -->
+ <form ref="form"
+ class="file-request-dialog__form"
+ aria-labelledby="file-request-dialog-description"
+ data-cy-file-request-dialog-form
+ @submit.prevent.stop="onSubmit">
+ <FileRequestIntro v-if="currentStep === STEP.FIRST"
+ :context="context"
+ :destination.sync="destination"
+ :disabled="loading"
+ :label.sync="label"
+ :note.sync="note" />
+
+ <FileRequestDatePassword v-else-if="currentStep === STEP.SECOND"
+ :deadline.sync="deadline"
+ :disabled="loading"
+ :password.sync="password" />
+
+ <FileRequestFinish v-else-if="share"
+ :emails="emails"
+ :share="share"
+ @add-email="email => emails.push(email)"
+ @remove-email="onRemoveEmail" />
+ </form>
+
+ <!-- Controls -->
+ <template #actions>
+ <!-- Cancel the creation -->
+ <NcButton :aria-label="t('files_sharing', 'Cancel')"
+ :disabled="loading"
+ :title="t('files_sharing', 'Cancel the file request creation')"
+ data-cy-file-request-dialog-controls="cancel"
+ type="tertiary"
+ @click="onCancel">
+ {{ t('files_sharing', 'Cancel') }}
+ </NcButton>
+
+ <!-- Align right -->
+ <span class="dialog__actions-separator" />
+
+ <!-- Back -->
+ <NcButton v-show="currentStep === STEP.SECOND"
+ :aria-label="t('files_sharing', 'Previous step')"
+ :disabled="loading"
+ data-cy-file-request-dialog-controls="back"
+ type="tertiary"
+ @click="currentStep = STEP.FIRST">
+ {{ t('files_sharing', 'Previous') }}
+ </NcButton>
+
+ <!-- Next -->
+ <NcButton v-if="currentStep !== STEP.LAST"
+ :aria-label="t('files_sharing', 'Continue')"
+ :disabled="loading"
+ data-cy-file-request-dialog-controls="next"
+ @click="onPageNext">
+ <template #icon>
+ <NcLoadingIcon v-if="loading" />
+ <IconNext v-else :size="20" />
+ </template>
+ {{ continueButtonLabel }}
+ </NcButton>
+
+ <!-- Finish -->
+ <NcButton v-else
+ :aria-label="t('files_sharing', 'Close the creation dialog')"
+ data-cy-file-request-dialog-controls="finish"
+ type="primary"
+ @click="$emit('close')">
+ <template #icon>
+ <IconCheck :size="20" />
+ </template>
+ {{ finishButtonLabel }}
+ </NcButton>
+ </template>
+ </NcDialog>
+</template>
+
+<script lang="ts">
+import type { AxiosError } from 'axios'
+import type { Folder, Node } from '@nextcloud/files'
+import type { OCSResponse } from '@nextcloud/typings/ocs'
+import type { PropType } from 'vue'
+
+import { defineComponent } from 'vue'
+import { emit } from '@nextcloud/event-bus'
+import { generateOcsUrl } from '@nextcloud/router'
+import { showError } from '@nextcloud/dialogs'
+import { translate, translatePlural } from '@nextcloud/l10n'
+import { Type } from '@nextcloud/sharing'
+import axios from '@nextcloud/axios'
+
+import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
+import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js'
+import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
+import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
+
+import IconCheck from 'vue-material-design-icons/Check.vue'
+import IconNext from 'vue-material-design-icons/ArrowRight.vue'
+
+import FileRequestDatePassword from './NewFileRequestDialog/FileRequestDatePassword.vue'
+import FileRequestFinish from './NewFileRequestDialog/FileRequestFinish.vue'
+import FileRequestIntro from './NewFileRequestDialog/FileRequestIntro.vue'
+import Share from '../models/Share'
+import logger from '../services/logger'
+
+enum STEP {
+ FIRST = 0,
+ SECOND = 1,
+ LAST = 2,
+}
+
+export default defineComponent({
+ name: 'NewFileRequestDialog',
+
+ components: {
+ FileRequestDatePassword,
+ FileRequestFinish,
+ FileRequestIntro,
+ IconCheck,
+ IconNext,
+ NcButton,
+ NcDialog,
+ NcLoadingIcon,
+ NcNoteCard,
+ },
+
+ props: {
+ context: {
+ type: Object as PropType<Folder>,
+ required: true,
+ },
+ content: {
+ type: Array as PropType<Node[]>,
+ required: true,
+ },
+ },
+
+ setup() {
+ return {
+ n: translatePlural,
+ t: translate,
+ STEP,
+ }
+ },
+
+ data() {
+ return {
+ currentStep: STEP.FIRST,
+ loading: false,
+
+ destination: this.context.path || '/',
+ label: '',
+ note: '',
+
+ deadline: null as Date | null,
+ password: null as string | null,
+
+ share: null as Share | null,
+ emails: [] as string[],
+ }
+ },
+
+ computed: {
+ continueButtonLabel() {
+ if (this.currentStep === STEP.LAST) {
+ return this.t('files_sharing', 'Close')
+ }
+ return this.t('files_sharing', 'Continue')
+ },
+
+ finishButtonLabel() {
+ if (this.emails.length === 0) {
+ return this.t('files_sharing', 'Close')
+ }
+ return this.n('files_sharing', 'Close and send email', 'Close and send {count} emails', this.emails.length, { count: this.emails.length })
+ },
+ },
+
+ methods: {
+ onPageNext() {
+ const form = this.$refs.form as HTMLFormElement
+ if (!form.checkValidity()) {
+ form.reportValidity()
+ }
+
+ // custom destination validation
+ // cannot share root
+ if (this.destination === '/' || this.destination === '') {
+ const destinationInput = form.querySelector('input[name="destination"]') as HTMLInputElement
+ destinationInput?.setCustomValidity(this.t('files_sharing', 'Please select a folder, you cannot share the root directory.'))
+ form.reportValidity()
+ return
+ }
+
+ if (this.currentStep === STEP.FIRST) {
+ this.currentStep = STEP.SECOND
+ return
+ }
+
+ this.createShare()
+ },
+
+ onRemoveEmail(email: string) {
+ const index = this.emails.indexOf(email)
+ this.emails.splice(index, 1)
+ },
+
+ onCancel() {
+ this.$emit('close')
+ },
+
+ onSubmit() {
+ this.$emit('submit')
+ },
+
+ async createShare() {
+ this.loading = true
+
+ const shareUrl = generateOcsUrl('apps/files_sharing/api/v1/shares')
+ // Format must be YYYY-MM-DD
+ const expireDate = this.deadline ? this.deadline.toISOString().split('T')[0] : undefined
+ try {
+ const request = await axios.post(shareUrl, {
+ path: this.destination,
+ shareType: Type.SHARE_TYPE_LINK,
+ publicUpload: 'true',
+ password: this.password || undefined,
+ expireDate,
+ label: this.label,
+ attributes: JSON.stringify({ is_file_request: true })
+ })
+
+ // If not an ocs request
+ if (!request?.data?.ocs) {
+ throw request
+ }
+
+ const share = new Share(request.data.ocs.data)
+ this.share = share
+
+ logger.info('New file request created', { share })
+ emit('files_sharing:share:created', { share })
+
+ // Move to the last page
+ this.currentStep = STEP.LAST
+ } catch (error) {
+ const errorMessage = (error as AxiosError<OCSResponse>)?.response?.data?.ocs?.meta?.message
+ showError(
+ errorMessage
+ ? this.t('files_sharing', 'Error creating the share: {errorMessage}', { errorMessage })
+ : this.t('files_sharing', 'Error creating the share'),
+ )
+ logger.error('Error while creating share', { error, errorMessage })
+ throw error
+ } finally {
+ this.loading = false
+ }
+ },
+ },
+})
+</script>
+
+<style scoped lang="scss">
+.file-request-dialog {
+ --margin: 36px;
+ --secondary-margin: 18px;
+
+ &__header {
+ margin: 0 var(--margin);
+ }
+
+ &__form {
+ position: relative;
+ overflow: auto;
+ padding: var(--secondary-margin) var(--margin);
+ // overlap header bottom padding
+ margin-top: calc(-1 * var(--secondary-margin));
+ }
+
+ :deep(fieldset) {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ margin-top: var(--secondary-margin);
+
+ :deep(legend) {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ }
+ }
+
+ :deep(.dialog__actions) {
+ width: auto;
+ margin-inline: 12px;
+ // align left and remove margin
+ margin-left: 0;
+ span.dialog__actions-separator {
+ margin-left: auto;
+ }
+ }
+
+ :deep(.input-field__helper-text-message) {
+ // reduce helper text standing out
+ color: var(--color-text-maxcontrast);
+ }
+}
+</style>
diff --git a/apps/files_sharing/src/components/NewFileRequestDialog/FileRequestDatePassword.vue b/apps/files_sharing/src/components/NewFileRequestDialog/FileRequestDatePassword.vue
new file mode 100644
index 00000000000..6da342da0f1
--- /dev/null
+++ b/apps/files_sharing/src/components/NewFileRequestDialog/FileRequestDatePassword.vue
@@ -0,0 +1,227 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <div>
+ <!-- Password and expiration summary -->
+ <NcNoteCard v-if="passwordAndExpirationSummary" type="success">
+ {{ passwordAndExpirationSummary }}
+ </NcNoteCard>
+
+ <!-- Expiration date -->
+ <fieldset class="file-request-dialog__expiration" data-cy-file-request-dialog-fieldset="expiration">
+ <NcNoteCard v-if="defaultExpireDateEnforced" type="info">
+ {{ t('files_sharing', 'Your administrator has enforced a default expiration date with a maximum {days} days.', { days: defaultExpireDate }) }}
+ </NcNoteCard>
+
+ <!-- Enable expiration -->
+ <legend>{{ t('files_sharing', 'When should the request expire ?') }}</legend>
+ <NcCheckboxRadioSwitch v-show="!defaultExpireDateEnforced"
+ :checked="defaultExpireDateEnforced || deadline !== null"
+ :disabled="disabled || defaultExpireDateEnforced"
+ @update:checked="onToggleDeadline">
+ {{ t('files_sharing', 'Set a submission deadline') }}
+ </NcCheckboxRadioSwitch>
+
+ <!-- Date picker -->
+ <NcDateTimePickerNative v-if="deadline !== null"
+ id="file-request-dialog-deadline"
+ :disabled="disabled"
+ :hide-label="true"
+ :max="maxDate"
+ :min="minDate"
+ :placeholder="t('files_sharing', 'Select a date')"
+ :required="defaultExpireDateEnforced"
+ :value="deadline"
+ name="deadline"
+ type="date"
+ @update:value="$emit('update:deadline', $event)"/>
+ </fieldset>
+
+ <!-- Password -->
+ <fieldset class="file-request-dialog__password" data-cy-file-request-dialog-fieldset="password">
+ <NcNoteCard v-if="enforcePasswordForPublicLink" type="info">
+ {{ t('files_sharing', 'Your administrator has enforced a password protection.') }}
+ </NcNoteCard>
+
+ <!-- Enable password -->
+ <legend>{{ t('files_sharing', 'What password should be used for the request ?') }}</legend>
+ <NcCheckboxRadioSwitch v-show="!enforcePasswordForPublicLink"
+ :checked="enforcePasswordForPublicLink || password !== null"
+ :disabled="disabled || enforcePasswordForPublicLink"
+ @update:checked="onTogglePassword">
+ {{ t('files_sharing', 'Set a password') }}
+ </NcCheckboxRadioSwitch>
+
+ <div v-if="password !== null" class="file-request-dialog__password-field">
+ <NcPasswordField ref="passwordField"
+ :check-password-strength="true"
+ :disabled="disabled"
+ :label-outside="true"
+ :placeholder="t('files_sharing', 'Enter a valid password')"
+ :required="false"
+ :value="password"
+ name="password"
+ @update:value="$emit('update:password', $event)" />
+ <NcButton :aria-label="t('files_sharing', 'Generate a new password')"
+ :title="t('files_sharing', 'Generate a new password')"
+ type="tertiary-no-background"
+ @click="generatePassword(); showPassword()">
+ <template #icon>
+ <IconPasswordGen :size="20" />
+ </template>
+ </NcButton>
+ </div>
+ </fieldset>
+ </div>
+</template>
+
+<script lang="ts">
+import { defineComponent, type PropType } from 'vue'
+import { translate } from '@nextcloud/l10n'
+
+import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
+import NcDateTimePickerNative from '@nextcloud/vue/dist/Components/NcDateTimePickerNative.js'
+import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
+import NcPasswordField from '@nextcloud/vue/dist/Components/NcPasswordField.js'
+
+import IconPasswordGen from 'vue-material-design-icons/AutoFix.vue'
+
+import GeneratePassword from '../../utils/GeneratePassword'
+
+export default defineComponent({
+ name: 'FileRequestDatePassword',
+
+ components: {
+ IconPasswordGen,
+ NcButton,
+ NcCheckboxRadioSwitch,
+ NcDateTimePickerNative,
+ NcNoteCard,
+ NcPasswordField,
+ },
+
+ props: {
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ deadline: {
+ type: Date as PropType<Date | null>,
+ required: false,
+ default: null,
+ },
+ password: {
+ type: String as PropType<string | null>,
+ required: false,
+ default: null,
+ },
+ },
+
+ emits: [
+ 'update:deadline',
+ 'update:password',
+ ],
+
+ setup() {
+ return {
+ t: translate,
+
+ // Default expiration date if defaultExpireDateEnabled is true
+ defaultExpireDate: window.OC.appConfig.core.defaultExpireDate as number,
+ // Default expiration date is enabled for public links (can be disabled)
+ defaultExpireDateEnabled: window.OC.appConfig.core.defaultExpireDateEnabled === true,
+ // Default expiration date is enforced for public links (can't be disabled)
+ defaultExpireDateEnforced: window.OC.appConfig.core.defaultExpireDateEnforced === true,
+
+ // Default password protection is enabled for public links (can be disabled)
+ enableLinkPasswordByDefault: window.OC.appConfig.core.enableLinkPasswordByDefault === true,
+ // Password protection is enforced for public links (can't be disabled)
+ enforcePasswordForPublicLink: window.OC.appConfig.core.enforcePasswordForPublicLink === true,
+ }
+ },
+
+ data() {
+ return {
+ maxDate: null as Date | null,
+ minDate: new Date(new Date().setDate(new Date().getDate() + 1)),
+ }
+ },
+
+ computed: {
+ passwordAndExpirationSummary(): string {
+ if (this.deadline && this.password) {
+ return this.t('files_sharing', 'The request will expire on {date} at midnight and will be password protected.', {
+ date: this.deadline.toLocaleDateString(),
+ })
+ }
+
+ if (this.deadline) {
+ return this.t('files_sharing', 'The request will expire on {date} at midnight.', {
+ date: this.deadline.toLocaleDateString(),
+ })
+ }
+
+ if (this.password) {
+ return this.t('files_sharing', 'The request will be password protected.')
+ }
+
+ return ''
+ },
+ },
+
+ mounted() {
+ // If defined, we set the default expiration date
+ if (this.defaultExpireDate > 0) {
+ this.$emit('update:deadline', new Date(new Date().setDate(new Date().getDate() + this.defaultExpireDate)))
+ }
+
+ // If enforced, we cannot set a date before the default expiration days (see admin settings)
+ if (this.defaultExpireDateEnforced) {
+ this.maxDate = new Date(new Date().setDate(new Date().getDate() + this.defaultExpireDate))
+ }
+
+ // If enabled by default, we generate a valid password
+ if (this.enableLinkPasswordByDefault) {
+ this.generatePassword()
+ }
+ },
+
+ methods: {
+ onToggleDeadline(checked: boolean) {
+ this.$emit('update:deadline', checked ? new Date() : null)
+ },
+
+ async onTogglePassword(checked: boolean) {
+ if (checked) {
+ this.generatePassword()
+ return
+ }
+ this.$emit('update:password', null)
+ },
+
+ generatePassword() {
+ GeneratePassword().then(password => {
+ this.$emit('update:password', password)
+ })
+ },
+
+ showPassword() {
+ // @ts-expect-error isPasswordHidden is private
+ this.$refs.passwordField.isPasswordHidden = false
+ },
+ },
+})
+</script>
+
+<style scoped lang="scss">
+.file-request-dialog__password-field {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+}
+</style>
diff --git a/apps/files_sharing/src/components/NewFileRequestDialog/FileRequestFinish.vue b/apps/files_sharing/src/components/NewFileRequestDialog/FileRequestFinish.vue
new file mode 100644
index 00000000000..1162414e049
--- /dev/null
+++ b/apps/files_sharing/src/components/NewFileRequestDialog/FileRequestFinish.vue
@@ -0,0 +1,217 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <div>
+ <!-- Request note -->
+ <NcNoteCard type="success">
+ {{ t('files_sharing', 'You can now share the link below to allow others to upload files to your directory.') }}
+ </NcNoteCard>
+
+ <!-- Copy share link -->
+ <NcInputField ref="clipboard"
+ :value="shareLink"
+ :label="t('files_sharing', 'Share link')"
+ :readonly="true"
+ :show-trailing-button="true"
+ :trailing-button-label="t('files_sharing', 'Copy to clipboard')"
+ @click="copyShareLink"
+ @click-trailing-button="copyShareLink">
+ <template #trailing-button-icon>
+ <IconCheck v-if="isCopied" :size="20" @click="isCopied = false" />
+ <IconClipboard v-else :size="20" @click="copyShareLink" />
+ </template>
+ </NcInputField>
+
+ <template v-if="isShareByMailEnabled">
+ <!-- Email share-->
+ <NcTextField :value.sync="email"
+ :label="t('files_sharing', 'Send link via email')"
+ :placeholder="t('files_sharing', 'Enter an email address or paste a list')"
+ type="email"
+ @keypress.enter.stop="addNewEmail"
+ @paste.stop.prevent="onPasteEmails" />
+
+ <!-- Email list -->
+ <div v-if="emails.length > 0" class="file-request-dialog__emails">
+ <NcChip v-for="mail in emails"
+ :key="mail"
+ :aria-label-close="t('files_sharing', 'Remove email')"
+ :text="mail"
+ @close="$emit('remove-email', mail)">
+ <template #icon>
+ <NcAvatar :disable-menu="true"
+ :disable-tooltip="true"
+ :is-guest="true"
+ :size="24"
+ :user="mail" />
+ </template>
+ </NcChip>
+ </div>
+ </template>
+ </div>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+import Share from '../../models/Share'
+
+import { defineComponent } from 'vue'
+import { generateUrl } from '@nextcloud/router'
+import { showError, showSuccess } from '@nextcloud/dialogs'
+import { translate, translatePlural } from '@nextcloud/l10n'
+
+import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
+import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js'
+import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
+import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
+import NcChip from '@nextcloud/vue/dist/Components/NcChip.js'
+
+import IconCheck from 'vue-material-design-icons/Check.vue'
+import IconClipboard from 'vue-material-design-icons/Clipboard.vue'
+import { getCapabilities } from '@nextcloud/capabilities'
+
+export default defineComponent({
+ name: 'FileRequestFinish',
+
+ components: {
+ IconCheck,
+ IconClipboard,
+ NcAvatar,
+ NcInputField,
+ NcNoteCard,
+ NcTextField,
+ NcChip,
+ },
+
+ props: {
+ share: {
+ type: Object as PropType<Share>,
+ required: true,
+ },
+ emails: {
+ type: Array as PropType<string[]>,
+ required: true,
+ },
+ },
+
+ emits: ['add-email', 'remove-email'],
+
+ setup() {
+ return {
+ n: translatePlural,
+ t: translate,
+ isShareByMailEnabled: getCapabilities()?.files_sharing?.sharebymail?.enabled === true,
+ }
+ },
+
+ data() {
+ return {
+ isCopied: false,
+ email: '',
+ }
+ },
+
+ computed: {
+ shareLink() {
+ return window.location.protocol + '//' + window.location.host + generateUrl('/s/') + this.share.token
+ },
+ },
+
+ methods: {
+ async copyShareLink(event: MouseEvent) {
+ if (!navigator.clipboard) {
+ // Clipboard API not available
+ showError(this.t('files_sharing', 'Clipboard is not available'))
+ return
+ }
+
+ await navigator.clipboard.writeText(this.shareLink)
+
+ showSuccess(this.t('files_sharing', 'Link copied to clipboard'))
+ this.isCopied = true
+ event.target?.select?.()
+
+ setTimeout(() => {
+ this.isCopied = false
+ }, 3000)
+ },
+
+ addNewEmail(e: KeyboardEvent) {
+ if (e.target instanceof HTMLInputElement) {
+ if (e.target.checkValidity() === false) {
+ e.target.reportValidity()
+ return
+ }
+
+ // The email is already in the list
+ if (this.emails.includes(this.email.trim())) {
+ e.target.setCustomValidity(this.t('files_sharing', 'Email already added'))
+ e.target.reportValidity()
+ return
+ }
+
+ if (!this.isValidEmail(this.email.trim())) {
+ e.target.setCustomValidity(this.t('files_sharing', 'Invalid email address'))
+ e.target.reportValidity()
+ return
+ }
+
+ this.$emit('add-email', this.email.trim())
+ this.email = ''
+ }
+ },
+
+ // Handle dumping a list of emails
+ onPasteEmails(e: ClipboardEvent) {
+ const clipboardData = e.clipboardData
+ if (!clipboardData) {
+ return
+ }
+
+ const pastedText = clipboardData.getData('text')
+ const emails = pastedText.split(/[\s,;]+/).filter(Boolean).map((email) => email.trim())
+
+ const duplicateEmails = emails.filter((email) => this.emails.includes(email))
+ const validEmails = emails.filter((email) => this.isValidEmail(email) && !duplicateEmails.includes(email))
+ const invalidEmails = emails.filter((email) => !this.isValidEmail(email))
+ validEmails.forEach((email) => this.$emit('add-email', email))
+
+ // Warn about invalid emails
+ if (invalidEmails.length > 0) {
+ showError(this.n('files_sharing', 'The following email address is not valid: {emails}', 'The following email addresses are not valid: {emails}', invalidEmails.length, { emails: invalidEmails.join(', ') }))
+ }
+
+ // Warn about duplicate emails
+ if (duplicateEmails.length > 0) {
+ showError(this.n('files_sharing', '1 email address already added', '{count} email addresses already added', duplicateEmails.length, { count: duplicateEmails.length }))
+ }
+
+ if (validEmails.length > 0) {
+ showSuccess(this.n('files_sharing', '1 email address added', '{count} email addresses added', validEmails.length, { count: validEmails.length }))
+ }
+
+ this.email = ''
+ },
+
+ isValidEmail(email) {
+ const regExpEmail = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
+ return regExpEmail.test(email)
+ },
+ },
+})
+</script>
+<style scoped>
+.input-field,
+.file-request-dialog__emails {
+ margin-top: var(--secondary-margin);
+}
+
+.file-request-dialog__emails {
+ display: flex;
+ gap: var(--default-grid-baseline);
+ flex-wrap: wrap;
+}
+</style>
diff --git a/apps/files_sharing/src/components/NewFileRequestDialog/FileRequestIntro.vue b/apps/files_sharing/src/components/NewFileRequestDialog/FileRequestIntro.vue
new file mode 100644
index 00000000000..766fbc3fc22
--- /dev/null
+++ b/apps/files_sharing/src/components/NewFileRequestDialog/FileRequestIntro.vue
@@ -0,0 +1,153 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <div>
+ <!-- Request label -->
+ <fieldset class="file-request-dialog__label" data-cy-file-request-dialog-fieldset="label">
+ <legend>
+ {{ t('files_sharing', 'What are you requesting ?') }}
+ </legend>
+ <NcTextField :value="label"
+ :disabled="disabled"
+ :label-outside="true"
+ :placeholder="t('files_sharing', 'Birthday party photos, History assignment…')"
+ :required="false"
+ name="label"
+ @update:value="$emit('update:label', $event)" />
+ </fieldset>
+
+ <!-- Request destination -->
+ <fieldset class="file-request-dialog__destination" data-cy-file-request-dialog-fieldset="destination">
+ <legend>
+ {{ t('files_sharing', 'Where should these files go ?') }}
+ </legend>
+ <NcTextField :value="destination"
+ :disabled="disabled"
+ :helper-text="t('files_sharing', 'The uploaded files are visible only to you unless you choose to share them.')"
+ :label-outside="true"
+ :minlength="2/* cannot share root */"
+ :placeholder="t('files_sharing', 'Select a destination')"
+ :readonly="false /* cannot validate a readonly input */"
+ :required="true /* cannot be empty */"
+ :show-trailing-button="destination !== context.path"
+ :trailing-button-icon="'undo'"
+ :trailing-button-label="t('files_sharing', 'Revert to default')"
+ name="destination"
+ @click="onPickDestination"
+ @keypress.prevent.stop="/* prevent typing in the input, we use the picker */"
+ @paste.prevent.stop="/* prevent pasting in the input, we use the picker */"
+ @trailing-button-click="$emit('update:destination', '')">
+ <IconFolder :size="18" />
+ </NcTextField>
+ </fieldset>
+
+ <!-- Request note -->
+ <fieldset class="file-request-dialog__note" data-cy-file-request-dialog-fieldset="note">
+ <legend>
+ {{ t('files_sharing', 'Add a note') }}
+ </legend>
+ <NcTextArea :value="note"
+ :disabled="disabled"
+ :label-outside="true"
+ :placeholder="t('files_sharing', 'Add a note to help people understand what you are requesting.')"
+ :required="false"
+ name="note"
+ @update:value="$emit('update:note', $event)" />
+ </fieldset>
+ </div>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+import type { Folder, Node } from '@nextcloud/files'
+
+import { defineComponent } from 'vue'
+import { getFilePickerBuilder } from '@nextcloud/dialogs'
+import { translate } from '@nextcloud/l10n'
+
+import IconFolder from 'vue-material-design-icons/Folder.vue'
+import NcTextArea from '@nextcloud/vue/dist/Components/NcTextArea.js'
+import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
+
+export default defineComponent({
+ name: 'FileRequestIntro',
+
+ components: {
+ IconFolder,
+ NcTextArea,
+ NcTextField,
+ },
+
+ props: {
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ context: {
+ type: Object as PropType<Folder>,
+ required: true,
+ },
+ label: {
+ type: String,
+ required: true,
+ },
+ destination: {
+ type: String,
+ required: true,
+ },
+ note: {
+ type: String,
+ required: true,
+ },
+ },
+
+ emits: [
+ 'update:destination',
+ 'update:label',
+ 'update:note',
+ ],
+
+ setup() {
+ return {
+ t: translate,
+ }
+ },
+
+ methods: {
+ onPickDestination() {
+ const filepicker = getFilePickerBuilder(this.t('files_sharing', 'Select a destination'))
+ .addMimeTypeFilter('httpd/unix-directory')
+ .allowDirectories(true)
+ .addButton({
+ label: this.t('files_sharing', 'Select'),
+ callback: this.onPickedDestination,
+ })
+ .setFilter(node => node.path !== '/')
+ .startAt(this.destination)
+ .build()
+ try {
+ filepicker.pick()
+ } catch (e) {
+ // ignore cancel
+ }
+ },
+
+ onPickedDestination(nodes: Node[]) {
+ const node = nodes[0]
+ if (node) {
+ this.$emit('update:destination', node.path)
+ }
+ },
+ },
+})
+</script>
+<style scoped>
+.file-request-dialog__note :deep(textarea) {
+ width: 100% !important;
+ min-height: 80px;
+}
+</style>
diff --git a/apps/files_sharing/src/components/SharingEntryLink.vue b/apps/files_sharing/src/components/SharingEntryLink.vue
index f93e6a9c953..9410f980a54 100644
--- a/apps/files_sharing/src/components/SharingEntryLink.vue
+++ b/apps/files_sharing/src/components/SharingEntryLink.vue
@@ -240,7 +240,7 @@ import PlusIcon from 'vue-material-design-icons/Plus.vue'
import SharingEntryQuickShareSelect from './SharingEntryQuickShareSelect.vue'
import ExternalShareAction from './ExternalShareAction.vue'
-import GeneratePassword from '../utils/GeneratePassword.js'
+import GeneratePassword from '../utils/GeneratePassword.ts'
import Share from '../models/Share.js'
import SharesMixin from '../mixins/SharesMixin.js'
import ShareDetails from '../mixins/ShareDetails.js'
@@ -369,7 +369,7 @@ export default {
},
async set(enabled) {
// TODO: directly save after generation to make sure the share is always protected
- Vue.set(this.share, 'password', enabled ? await GeneratePassword() : '')
+ Vue.set(this.share, 'password', enabled ? await GeneratePassword(true) : '')
Vue.set(this.share, 'newPassword', this.share.password)
},
},
@@ -590,7 +590,7 @@ export default {
// ELSE, show the pending popovermenu
// if password default or enforced, pre-fill with random one
if (this.config.enableLinkPasswordByDefault || this.config.enforcePasswordForPublicLink) {
- shareDefaults.password = await GeneratePassword()
+ shareDefaults.password = await GeneratePassword(true)
}
// create share & close menu
diff --git a/apps/files_sharing/src/components/SharingInput.vue b/apps/files_sharing/src/components/SharingInput.vue
index f2b2f573700..e986ff4491f 100644
--- a/apps/files_sharing/src/components/SharingInput.vue
+++ b/apps/files_sharing/src/components/SharingInput.vue
@@ -27,14 +27,15 @@
</template>
<script>
+import { generateOcsUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'
import { getCapabilities } from '@nextcloud/capabilities'
-import { generateOcsUrl } from '@nextcloud/router'
import axios from '@nextcloud/axios'
import debounce from 'debounce'
import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
import Config from '../services/ConfigService.js'
+import GeneratePassword from '../utils/GeneratePassword.ts'
import Share from '../models/Share.js'
import ShareRequests from '../mixins/ShareRequests.js'
import ShareTypes from '../mixins/ShareTypes.js'
diff --git a/apps/files_sharing/src/init.ts b/apps/files_sharing/src/init.ts
index 0c8cd349f1b..4652026861d 100644
--- a/apps/files_sharing/src/init.ts
+++ b/apps/files_sharing/src/init.ts
@@ -2,9 +2,10 @@
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { registerDavProperty } from '@nextcloud/files'
+import { addNewFileMenuEntry, registerDavProperty } from '@nextcloud/files'
import registerSharingViews from './views/shares'
+import { entry as newFileRequest } from './new/newFileRequest'
import './actions/acceptShareAction'
import './actions/openInFilesAction'
import './actions/rejectShareAction'
@@ -13,6 +14,8 @@ import './actions/sharingStatusAction'
registerSharingViews()
+addNewFileMenuEntry(newFileRequest)
+
registerDavProperty('nc:share-attributes', { nc: 'http://nextcloud.org/ns' })
registerDavProperty('oc:share-types', { oc: 'http://owncloud.org/ns' })
registerDavProperty('ocs:share-permissions', { ocs: 'http://open-collaboration-services.org/ns' })
diff --git a/apps/files_sharing/src/new/newFileRequest.ts b/apps/files_sharing/src/new/newFileRequest.ts
new file mode 100644
index 00000000000..07adacd67d3
--- /dev/null
+++ b/apps/files_sharing/src/new/newFileRequest.ts
@@ -0,0 +1,50 @@
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Entry, Folder, Node } from '@nextcloud/files'
+
+import { translate as t } from '@nextcloud/l10n'
+import Vue, { defineAsyncComponent } from 'vue'
+import FileUploadSvg from '@mdi/svg/svg/file-upload.svg?raw'
+
+const NewFileRequestDialogVue = defineAsyncComponent(() => import('../components/NewFileRequestDialog.vue'))
+
+export const entry = {
+ id: 'file-request',
+ displayName: t('files', 'Create new file request'),
+ iconSvgInline: FileUploadSvg,
+ order: 30,
+ enabled(): boolean {
+ // determine requirements
+ // 1. user can share the root folder
+ // 2. OR user can create subfolders ?
+ return true
+ },
+ async handler(context: Folder, content: Node[]) {
+ // Create document root
+ const mountingPoint = document.createElement('div')
+ mountingPoint.id = 'file-request-dialog'
+ document.body.appendChild(mountingPoint)
+
+ // Init vue app
+ const NewFileRequestDialog = new Vue({
+ name: 'NewFileRequestDialogRoot',
+ render: (h) => h(
+ NewFileRequestDialogVue,
+ {
+ props: {
+ context,
+ content,
+ },
+ on: {
+ close: () => {
+ NewFileRequestDialog.$destroy()
+ },
+ },
+ },
+ ),
+ el: mountingPoint,
+ })
+ },
+} as Entry
diff --git a/apps/files_sharing/src/utils/GeneratePassword.js b/apps/files_sharing/src/utils/GeneratePassword.ts
index e40b12c53d1..c7839fe6302 100644
--- a/apps/files_sharing/src/utils/GeneratePassword.js
+++ b/apps/files_sharing/src/utils/GeneratePassword.ts
@@ -6,6 +6,7 @@
import axios from '@nextcloud/axios'
import Config from '../services/ConfigService.js'
import { showError, showSuccess } from '@nextcloud/dialogs'
+import { translate as t } from '@nextcloud/l10n'
const config = new Config()
// note: some chars removed on purpose to make them human friendly when read out
@@ -15,21 +16,23 @@ const passwordSet = 'abcdefgijkmnopqrstwxyzABCDEFGHJKLMNPQRSTWXYZ23456789'
* Generate a valid policy password or
* request a valid password if password_policy
* is enabled
- *
- * @return {string} a valid password
*/
-export default async function() {
+export default async function(verbose = false): Promise<string> {
// password policy is enabled, let's request a pass
if (config.passwordPolicy.api && config.passwordPolicy.api.generate) {
try {
const request = await axios.get(config.passwordPolicy.api.generate)
if (request.data.ocs.data.password) {
- showSuccess(t('files_sharing', 'Password created successfully'))
+ if (verbose) {
+ showSuccess(t('files_sharing', 'Password created successfully'))
+ }
return request.data.ocs.data.password
}
} catch (error) {
console.info('Error generating password from password_policy', error)
- showError(t('files_sharing', 'Error generating password from password policy'))
+ if (verbose) {
+ showError(t('files_sharing', 'Error generating password from password policy'))
+ }
}
}
diff --git a/apps/files_sharing/src/views/SharingDetailsTab.vue b/apps/files_sharing/src/views/SharingDetailsTab.vue
index 6141d92e33e..a9ade99f556 100644
--- a/apps/files_sharing/src/views/SharingDetailsTab.vue
+++ b/apps/files_sharing/src/views/SharingDetailsTab.vue
@@ -272,7 +272,7 @@ import DotsHorizontalIcon from 'vue-material-design-icons/DotsHorizontal.vue'
import ExternalShareAction from '../components/ExternalShareAction.vue'
-import GeneratePassword from '../utils/GeneratePassword.js'
+import GeneratePassword from '../utils/GeneratePassword.ts'
import Share from '../models/Share.js'
import ShareRequests from '../mixins/ShareRequests.js'
import ShareTypes from '../mixins/ShareTypes.js'
@@ -470,7 +470,7 @@ export default {
},
async set(enabled) {
if (enabled) {
- this.share.password = await GeneratePassword()
+ this.share.password = await GeneratePassword(true)
this.$set(this.share, 'newPassword', this.share.password)
} else {
this.share.password = ''
@@ -772,7 +772,7 @@ export default {
if (this.isNewShare) {
if (this.isPasswordEnforced && this.isPublicShare) {
- this.$set(this.share, 'newPassword', await GeneratePassword())
+ this.$set(this.share, 'newPassword', await GeneratePassword(true))
this.advancedSectionAccordionExpanded = true
}
/* Set default expiration dates if configured */
diff --git a/apps/sharebymail/lib/ShareByMailProvider.php b/apps/sharebymail/lib/ShareByMailProvider.php
index 6b37f99df79..4b3162c1982 100644
--- a/apps/sharebymail/lib/ShareByMailProvider.php
+++ b/apps/sharebymail/lib/ShareByMailProvider.php
@@ -277,7 +277,7 @@ class ShareByMailProvider implements IShareProvider {
/**
* @throws \Exception If mail couldn't be sent
*/
- protected function sendMailNotification(
+ public function sendMailNotification(
string $filename,
string $link,
string $initiator,