Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
385 changes: 381 additions & 4 deletions backend/src/applications/files/services/files-manager.service.spec.ts

Large diffs are not rendered by default.

160 changes: 107 additions & 53 deletions backend/src/applications/files/services/files-manager.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import { FilesQueries } from './files-queries.service'
import { FileEvent, FileTaskEvent } from '../events/file-events'
import { ACTION } from '../../../common/constants'
import { pipeline } from 'node:stream/promises'
import { isMultipartFileTooLargeError, maxUploadSizeExceededError, uploadTmpFilePath } from '../utils/upload-file'

@Injectable()
export class FilesManager {
Expand Down Expand Up @@ -195,13 +196,15 @@ export class FilesManager {
*/
this.checkNotTrashRepository(space)
const overwrite = req.method === HTTP_METHOD.PUT
const patch = req.method === HTTP_METHOD.PATCH
const patchMethod = req.method === HTTP_METHOD.PATCH
const postMethod = req.method === HTTP_METHOD.POST
const realParentPath = dirName(space.realPath)

// For POST, space.realPath can be either the final file path or the root directory for a folder upload.
if (postMethod && (await isPathExists(space.realPath))) {
throw new FileError(HttpStatus.METHOD_NOT_ALLOWED, 'Resource already exists')
}
if (!overwrite) {
if (!patch && (await isPathExists(space.realPath))) {
throw new FileError(HttpStatus.BAD_REQUEST, 'Resource already exists')
}
if (!(await isPathExists(realParentPath))) {
throw new FileError(HttpStatus.BAD_REQUEST, 'Parent must exists')
}
Expand All @@ -212,60 +215,111 @@ export class FilesManager {

const basePath = realParentPath + path.sep

for await (const part of req.files()) {
// If the request uses the PATCH method, the file name corresponds to the space
const partFileName = patch ? fileName(space.realPath) : part.filename
// `part.filename` may contain a path like foo/bar.txt
const dstFile = path.resolve(basePath, partFileName)
const dstExists = await isPathExists(dstFile)
const dstIsDir = dstExists ? await isPathIsDir(dstFile) : false
// Prevent path traversal
if (!dstFile.startsWith(basePath)) {
throw new FileError(HttpStatus.FORBIDDEN, 'Location is not allowed')
}
try {
for await (const part of req.files({ throwFileSizeLimit: false })) {
// If the request uses the PATCH method, the file name corresponds to the space
const partFileName = patchMethod ? fileName(space.realPath) : part.filename
// `part.filename` may contain a path like foo/bar.txt
const dstFile = path.resolve(basePath, partFileName)
// Prevent path traversal
if (!dstFile.startsWith(basePath)) {
throw new FileError(HttpStatus.FORBIDDEN, 'Location is not allowed')
}
const dstExists = await isPathExists(dstFile)
const dstIsDir = dstExists ? await isPathIsDir(dstFile) : false
if (postMethod && dstExists) {
throw new FileError(HttpStatus.METHOD_NOT_ALLOWED, 'Resource already exists')
}
if (patchMethod && !dstExists) {
throw new FileError(HttpStatus.NOT_FOUND, 'Location not found')
}
// PUT/PATCH write outside the destination first, so a failed upload does not corrupt an existing file.
const tmpFile = overwrite || patchMethod ? uploadTmpFilePath(user.tmpPath, partFileName) : undefined
const writePath = tmpFile || dstFile

const dstDir = dirName(dstFile)
// For overwrite conflicts, defer destructive deletes until the upload stream is fully validated.
let dstSpaceToDeleteBeforeMove: SpaceEnv | undefined
let dstParentSpaceToDeleteBeforeMove: SpaceEnv | undefined

if (overwrite) {
// Prevent errors when an uploaded file would replace a directory with the same name
// Only applies in `overwrite` cases
if (dstExists && dstIsDir) {
// If a directory already exists at the destination path, delete it to allow overwriting with the uploaded file
const dstUrl = path.join(path.dirname(space.url), partFileName)
dstSpaceToDeleteBeforeMove = await this.spacesManager.spaceEnv(user, dstUrl.split('/'))
} else if ((await isPathExists(dstDir)) && !(await isPathIsDir(dstDir))) {
// If the destination's parent exists but is a file, remove it so we can create the directory
const dstUrl = path.join(path.dirname(space.url), path.dirname(partFileName))
dstParentSpaceToDeleteBeforeMove = await this.spacesManager.spaceEnv(user, dstUrl.split('/'))
}
}
// Create the destination directory only when writing directly; user.tmpPath already exists.
if (!tmpFile && !(await isPathExists(dstDir))) {
await makeDir(dstDir, true)
}

// Create or refresh lock
const dbFile = { ...space.dbFile, path: path.join(dirName(space.dbFile.path), partFileName) }
// Use a short TTL for the PATCH method (which is also used for refreshing)
const ttl = patchMethod ? CACHE_LOCK_FILE_TTL : undefined
const [created, fileLock] = await this.filesLockManager.createOrRefresh(user, dbFile, SERVER_NAME, DEPTH.RESOURCE, ttl)

const dstDir = dirName(dstFile)

if (overwrite) {
// Prevent errors when an uploaded file would replace a directory with the same name
// Only applies in `overwrite` cases
if (dstExists && dstIsDir) {
// If a directory already exists at the destination path, delete it to allow overwriting with the uploaded file
const dstUrl = path.join(path.dirname(space.url), partFileName)
const dstSpace = await this.spacesManager.spaceEnv(user, dstUrl.split('/'))
await this.delete(user, dstSpace)
} else if ((await isPathExists(dstDir)) && !(await isPathIsDir(dstDir))) {
// If the destination's parent exists but is a file, remove it so we can create the directory
const dstUrl = path.join(path.dirname(space.url), path.dirname(partFileName))
const dstSpace = await this.spacesManager.spaceEnv(user, dstUrl.split('/'))
await this.delete(user, dstSpace)
let fileWritten = false
// Do
try {
await writeFromStream(writePath, part.file)
// With throwFileSizeLimit disabled, multipart marks the file stream as truncated instead of rejecting.
if (part.file.truncated) {
throw maxUploadSizeExceededError()
}
if (tmpFile) {
// If the following move fails after these deletes, the previous resources remain recoverable from the trash.
if (dstSpaceToDeleteBeforeMove) {
await this.delete(user, dstSpaceToDeleteBeforeMove)
}
if (dstParentSpaceToDeleteBeforeMove) {
await this.delete(user, dstParentSpaceToDeleteBeforeMove)
}
if (!(await isPathExists(dstDir))) {
await makeDir(dstDir, true)
}
await moveFiles(tmpFile, dstFile, true)
}
fileWritten = true
} catch (e) {
// Failed temporary uploads are discarded without touching the existing destination.
if (tmpFile) {
await removeFiles(tmpFile)
} else if (!dstExists) {
await removeFiles(dstFile)
}
if (isMultipartFileTooLargeError(e)) {
throw maxUploadSizeExceededError()
}
throw e
} finally {
if (fileWritten) {
// Emit only after the final destination has been written or moved into place.
const fileEventAction: ACTION = patchMethod || (dstExists && !dstIsDir) ? ACTION.UPDATE : ACTION.ADD
FileEvent.emit('event', { user, space, action: fileEventAction, rPath: dstFile })
}
if (!patchMethod && created) {
// Remove the file lock only if it has not been refreshed
await this.filesLockManager.removeLock(fileLock.key)
}
}
}
// Create the directory in the space
if (!(await isPathExists(dstDir))) {
await makeDir(dstDir, true)
}
// Create or refresh lock
const dbFile = { ...space.dbFile, path: path.join(dirName(space.dbFile.path), partFileName) }
// Use a short TTL for the PATCH method (which is also used for refreshing)
const ttl = patch ? CACHE_LOCK_FILE_TTL : undefined
const [created, fileLock] = await this.filesLockManager.createOrRefresh(user, dbFile, SERVER_NAME, DEPTH.RESOURCE, ttl)
// Do
try {
await writeFromStream(dstFile, part.file)
} finally {
// emit file event
const fileEventAction: ACTION = patch || (dstExists && !dstIsDir) ? ACTION.UPDATE : ACTION.ADD
FileEvent.emit('event', { user, space, action: fileEventAction, rPath: dstFile })
if (!patch && created) {
// Remove the file lock only if it has not been refreshed
await this.filesLockManager.removeLock(fileLock.key)
if (patchMethod) {
// Only one resource can be updated with the PATCH method.
break
}
}
if (patch) {
// Only one resource can be updated with the PATCH method.
break
} catch (e) {
if (isMultipartFileTooLargeError(e)) {
throw maxUploadSizeExceededError()
}
throw e
}
}

Expand Down
21 changes: 21 additions & 0 deletions backend/src/applications/files/utils/upload-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import path from 'node:path'
import { randomUUID } from 'node:crypto'
import { FileError } from '../models/file-error'
import { HttpStatus } from '@nestjs/common'
import { fileName } from './files'

const FASTIFY_MULTIPART_FILE_TOO_LARGE_CODE = 'FST_REQ_FILE_TOO_LARGE' as const
const MAX_UPLOAD_SIZE_EXCEEDED = 'File size limit exceeded' as const

export function isMultipartFileTooLargeError(e: any): boolean {
// Other multipart limits also return 413; only this code means the file-size limit was reached.
return e?.code === FASTIFY_MULTIPART_FILE_TOO_LARGE_CODE
}

export function maxUploadSizeExceededError(): FileError {
return new FileError(HttpStatus.PAYLOAD_TOO_LARGE, MAX_UPLOAD_SIZE_EXCEEDED)
}

export function uploadTmpFilePath(tmpPath: string, partFileName: string): string {
return path.join(tmpPath, `${randomUUID()}-upload-${fileName(partFileName) || 'file'}`)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export interface SpaceTrash {
id: number
name: string
alias: string
enabled: boolean
mtime: number
ctime: number
nb: number
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ export class SpacesManager {
async listTrashes(user: UserModel): Promise<SpaceTrash[]> {
const trashes: SpaceTrash[] = []
// todo: store 'Personal files' as const somewhere (used in frontend too)
const personalTrash: SpaceTrash = { id: 0, name: 'Personal files', alias: SPACE_ALIAS.PERSONAL, nb: 0, mtime: 0, ctime: 0 }
const personalTrash: SpaceTrash = { id: 0, name: 'Personal files', alias: SPACE_ALIAS.PERSONAL, nb: 0, mtime: 0, ctime: 0, enabled: true }
for (const space of [...(await this.listSpaces(user.id)), personalTrash] as SpaceTrash[]) {
const rPath = space.alias === SPACE_ALIAS.PERSONAL ? user.trashPath : SpaceModel.getTrashPath(space.alias)
try {
Expand Down Expand Up @@ -236,6 +236,7 @@ export class SpacesManager {
/* only managers of the space can update it */
const space: SpaceProps = await this.userCanAccessSpace(user, spaceId, true)
// check and update space info
let mustInvalidateCache = false
const spaceDiffProps: Partial<SpaceProps> = { modifiedAt: new Date() }
const props: (keyof CreateOrUpdateSpaceDto)[] = ['name', 'description', 'enabled', 'storageQuota', 'storageIndexing']
for (const prop of props) {
Expand All @@ -253,6 +254,7 @@ export class SpacesManager {
}
} else if (prop === 'enabled') {
spaceDiffProps.disabledAt = spaceDiffProps[prop] ? null : new Date()
mustInvalidateCache = true
}
}
}
Expand Down Expand Up @@ -284,6 +286,9 @@ export class SpacesManager {
const aliases: string[] = space.roots.map((r) => r.alias)
const names: string[] = [...(await dirListFileNames(SpaceModel.getFilesPath(space.alias))), ...space.roots.map((r) => r.name)]
await this.updateRoots(user, space, space.roots, createOrUpdateSpaceDto.roots, aliases, names)
if (mustInvalidateCache) {
void this.spacesQueries.clearCachePermissions(space.alias, undefined, [user.id])
}
if (rootOwnerIds.indexOf(user.id) > -1) {
// current manager was removed
return null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,7 @@ export class SpacesQueries {
rootAliases.forEach((rAlias: string) => patterns.push(this.cache.genSlugKey(...basePattern, rAlias)))
} else {
// clear cache on spaces list
patterns.push(this.cache.genSlugKey(...[this.constructor.name, this.spaces.name, uid]))
patterns.push(this.cache.genSlugKey(...[this.constructor.name, this.spaces.name, uid, '*']))
// clear cache on spaces and roots
patterns.push(this.cache.genSlugKey(...basePattern), this.cache.genSlugKey(...basePattern, '*'))
Expand Down
1 change: 1 addition & 0 deletions environment/environment.dist.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,7 @@ applications:
files:
# required
dataPath: /home/sync-in
# maxUploadSize: Maximum upload file size.
# default: 5368709120 (5 GB)
maxUploadSize: 5368709120
contentIndexing:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
<span></span>
</div>
@for (b of bins(); track b.id) {
<button type="button" class="bin-row" (click)="openBin(b)">
<button type="button" class="bin-row" [class.bin-row--disabled]="!b.enabled" (click)="openBin(b)">
<div class="bin-row__name">
<span class="bin-row__glyph">
<app-v2-icon name="trash" [size]="14" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,14 @@
opacity: 1;
}

&--disabled {
opacity: 0.45;

&:hover {
opacity: 0.55;
}
}

&__name {
display: flex;
align-items: center;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core'
import { Router } from '@angular/router'
import { L10N_LOCALE, L10nLocale, L10nTranslateDirective, L10nTranslatePipe } from 'angular-l10n'
import { L10N_LOCALE, L10nLocale, L10nTranslateDirective, L10nTranslatePipe, L10nTranslationService } from 'angular-l10n'
import { Subscription } from 'rxjs'
import { TimeAgoPipe } from '../../../../common/pipes/time-ago.pipe'
import { TrashModel } from '../../../spaces/models/trash.model'
import { SpacesService } from '../../../spaces/services/spaces.service'
import { IconButtonComponent } from '../../components/icon-button.component'
import { ToastService } from '../../components/toast.service'
import { IconV2Component } from '../../icons/icon-v2.component'
import { V2BreadcrumbService } from '../../layout/breadcrumb.service'
import { V2_PATH, V2_ROUTES } from '../../v2.constants'
Expand All @@ -21,6 +22,8 @@ export class TrashComponent implements OnInit {
private readonly spacesService = inject(SpacesService)
private readonly router = inject(Router)
private readonly breadcrumbs = inject(V2BreadcrumbService)
private readonly toast = inject(ToastService)
private readonly translation = inject(L10nTranslationService)
protected readonly locale = inject<L10nLocale>(L10N_LOCALE)
private subscription: Subscription | null = null

Expand Down Expand Up @@ -61,6 +64,10 @@ export class TrashComponent implements OnInit {
}

protected openBin(bin: TrashModel): void {
if (!bin.enabled) {
this.toast.info(`${bin.name}: ${this.translation.translate('Space is disabled')}`)
return
}
this.router.navigate(['/', V2_PATH, V2_ROUTES.TRASH, bin.alias]).catch(console.error)
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { KeyValuePipe } from '@angular/common'
import { Component, ElementRef, EventEmitter, HostListener, inject, Input, OnInit, Output, ViewChild } from '@angular/core'
import { AfterViewInit, Component, ElementRef, EventEmitter, HostListener, inject, Input, OnInit, Output, ViewChild } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { FaIconComponent } from '@fortawesome/angular-fontawesome'
import { faCaretDown, faFileAlt, faFolderClosed, faGlobe } from '@fortawesome/free-solid-svg-icons'
Expand All @@ -18,7 +18,7 @@ import { FilesService } from '../../services/files.service'
templateUrl: 'files-new-dialog.component.html',
imports: [FaIconComponent, L10nTranslateDirective, BsDropdownModule, FormsModule, L10nTranslatePipe, AutofocusDirective, KeyValuePipe]
})
export class FilesNewDialogComponent implements OnInit {
export class FilesNewDialogComponent implements OnInit, AfterViewInit {
@Input() files: FileModel[]
@Input() inputType: 'file' | 'directory' | 'download'
@Output() refreshFiles = new EventEmitter()
Expand All @@ -45,13 +45,18 @@ export class FilesNewDialogComponent implements OnInit {
this.fileProp.name = `${this.layout.translateString('New document')}${this.docTypeExtension(this.selectedDocType)}`
this.fileProp.title = 'New document'
this.fileProp.placeholder = 'Document name'
this.updateFileSelection()
} else {
this.fileProp.title = 'New folder'
this.fileProp.placeholder = 'Folder name'
}
}

ngAfterViewInit() {
if (this.inputType === 'file') {
this.updateFileSelection()
}
}

onSelectDocType(docType: string) {
this.selectedDocType = docType
const pos = this.fileNamePosition()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ <h4 class="modal-title me-auto">
@if (canToggleViewer) {
<button (click)="toggleViewer()" class="btn btn-sm btn-primary me-2" type="button">
<fa-icon [icon]="isReadonly() ? icons.faPen : icons.faEye"></fa-icon>
<span class="ms-2" l10nTranslate>{{ isReadonly() ? 'Edit in OnlyOffice' : 'View in PDF.js' }}</span>
<span class="d-none d-sm-inline ms-2" l10nTranslate>{{ isReadonly() ? 'Edit in OnlyOffice' : 'View in PDF.js' }}</span>
</button>
}
<button (click)="onMinimize()" aria-label="Minimize" class="btn-minimize btn-minimize-white" [class.ms-1]="canToggleViewer" type="button"></button>
Expand Down
Loading
Loading