Browse Source

feat(files_sharing): add `new file request` option

Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
pull/46007/head
skjnldsv 1 year ago
committed by John Molakvoæ
parent
commit
443c48aefb
  1. 49
      apps/files_sharing/lib/Controller/ShareAPIController.php
  2. 330
      apps/files_sharing/src/components/NewFileRequestDialog.vue
  3. 227
      apps/files_sharing/src/components/NewFileRequestDialog/FileRequestDatePassword.vue
  4. 217
      apps/files_sharing/src/components/NewFileRequestDialog/FileRequestFinish.vue
  5. 153
      apps/files_sharing/src/components/NewFileRequestDialog/FileRequestIntro.vue
  6. 6
      apps/files_sharing/src/components/SharingEntryLink.vue
  7. 3
      apps/files_sharing/src/components/SharingInput.vue
  8. 5
      apps/files_sharing/src/init.ts
  9. 50
      apps/files_sharing/src/new/newFileRequest.ts
  10. 13
      apps/files_sharing/src/utils/GeneratePassword.ts
  11. 6
      apps/files_sharing/src/views/SharingDetailsTab.vue
  12. 2
      apps/sharebymail/lib/ShareByMailProvider.php

49
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'));
}
}
}

330
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>

227
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>

217
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>

153
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>

6
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

3
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'

5
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' })

50
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

13
apps/files_sharing/src/utils/GeneratePassword.js → 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'))
}
}
}

6
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 */

2
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,

Loading…
Cancel
Save