Skip to content

Commit 5102e9a

Browse files
committed
feat(links): allow direct access to spaces via public links; add file preview/edit/download; improve password validation
1 parent bda58d6 commit 5102e9a

27 files changed

+228
-99
lines changed

backend/src/applications/links/constants/routes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ export const PUBLIC_LINKS_ROUTE = {
1111
LINK: 'link',
1212
VALIDATION: 'validation',
1313
ACCESS: 'access',
14+
DOWNLOAD: 'download',
1415
AUTH: 'auth'
1516
}
1617

1718
export const API_PUBLIC_LINK_VALIDATION = `${PUBLIC_LINKS_ROUTE.BASE}/${PUBLIC_LINKS_ROUTE.VALIDATION}`
1819
export const API_PUBLIC_LINK_ACCESS = `${PUBLIC_LINKS_ROUTE.BASE}/${PUBLIC_LINKS_ROUTE.ACCESS}`
20+
export const API_PUBLIC_LINK_DOWNLOAD = `${PUBLIC_LINKS_ROUTE.BASE}/${PUBLIC_LINKS_ROUTE.DOWNLOAD}`
1921
export const API_PUBLIC_LINK_AUTH = `${PUBLIC_LINKS_ROUTE.BASE}/${PUBLIC_LINKS_ROUTE.AUTH}`

backend/src/applications/links/interfaces/link-space.interface.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,20 @@
44
* See the LICENSE file for licensing details
55
*/
66

7+
import type { FileEditorProvider } from '../../../configuration/config.interfaces'
8+
79
export interface SpaceLink {
8-
share?: { name: string; alias: string; hasParent: boolean; isDir: boolean; mime: string }
9-
space?: { name: string; alias: string }
10-
owner?: { login?: string; fullName: string; avatar?: string }
10+
share?: {
11+
name: string
12+
alias: string
13+
hasParent: boolean
14+
isDir: boolean
15+
mtime: number
16+
mime: string
17+
size: number
18+
permissions: string
19+
} | null
20+
space?: { name: string; alias: string } | null
21+
owner?: { login?: string; fullName: string; avatar?: string } | null
22+
fileEditors?: FileEditorProvider
1123
}

backend/src/applications/links/links.controller.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { LinksManager } from './services/links-manager.service'
1818
@Controller(PUBLIC_LINKS_ROUTE.BASE)
1919
@AuthTokenOptional()
2020
export class LinksController {
21-
constructor(private readonly linksPublicManager: LinksManager) {}
21+
constructor(private readonly linksManager: LinksManager) {}
2222

2323
@Get(`${PUBLIC_LINKS_ROUTE.VALIDATION}/:uuid`)
2424
linkValidation(
@@ -29,7 +29,7 @@ export class LinksController {
2929
ok: boolean
3030
link: SpaceLink
3131
}> {
32-
return this.linksPublicManager.linkValidation(user, uuid)
32+
return this.linksManager.linkValidation(user, uuid)
3333
}
3434

3535
@Get(`${PUBLIC_LINKS_ROUTE.ACCESS}/:uuid`)
@@ -38,8 +38,18 @@ export class LinksController {
3838
@Param('uuid') uuid: string,
3939
@Req() req: FastifyRequest,
4040
@Res({ passthrough: true }) res: FastifyReply
41-
): Promise<StreamableFile | LoginResponseDto> {
42-
return this.linksPublicManager.linkAccess(user, uuid, req, res)
41+
): Promise<LoginResponseDto | Omit<LoginResponseDto, 'token'>> {
42+
return this.linksManager.linkAccess(user, uuid, req, res)
43+
}
44+
45+
@Get(`${PUBLIC_LINKS_ROUTE.DOWNLOAD}/:uuid`)
46+
linkDownload(
47+
@GetUser() user: UserModel,
48+
@Param('uuid') uuid: string,
49+
@Req() req: FastifyRequest,
50+
@Res({ passthrough: true }) res: FastifyReply
51+
): Promise<StreamableFile> {
52+
return this.linksManager.linkDownload(user, uuid, req, res)
4353
}
4454

4555
@Post(`${PUBLIC_LINKS_ROUTE.AUTH}/:uuid`)
@@ -50,6 +60,6 @@ export class LinksController {
5060
@Req() req: FastifyRequest,
5161
@Res({ passthrough: true }) res: FastifyReply
5262
): Promise<LoginResponseDto> {
53-
return this.linksPublicManager.linkAuthentication(user, uuid, linkPasswordDto, req, res)
63+
return this.linksManager.linkAuthentication(user, uuid, linkPasswordDto, req, res)
5464
}
5565
}

backend/src/applications/links/services/links-manager.service.spec.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,6 @@ describe(LinksManager.name, () => {
196196
share: { isDir: false, name: 'file.txt', alias: 'share-alias' }
197197
} as any
198198
linksQueriesMock.spaceLink.mockResolvedValueOnce(spaceLink)
199-
200199
spacesManagerMock.spaceEnv.mockResolvedValueOnce({} as any)
201200
const streamable: any = { some: 'stream' }
202201
filesManagerMock.sendFileFromSpace.mockReturnValueOnce({
@@ -208,7 +207,7 @@ describe(LinksManager.name, () => {
208207
const logErrorSpy = jest.spyOn((service as any).logger, 'error').mockImplementation(() => undefined as any)
209208
linksQueriesMock.incrementLinkNbAccess.mockRejectedValueOnce(new Error('increment boom'))
210209

211-
const result = await service.linkAccess(identity, link.uuid, req, res)
210+
const result = await service.linkDownload(identity, link.uuid, req, res)
212211

213212
expect(result).toBe(streamable)
214213
expect(filesManagerMock.sendFileFromSpace).toHaveBeenCalled()
@@ -270,7 +269,7 @@ describe(LinksManager.name, () => {
270269

271270
const result = await service.linkAccess(sameUserIdentity as any, link.uuid, req, res)
272271

273-
expect(result).toBeUndefined()
272+
expect(result.user.id).toEqual(baseLink.user.id)
274273
expect(authManagerMock.setCookies).not.toHaveBeenCalled()
275274
expect(linksQueriesMock.incrementLinkNbAccess).not.toHaveBeenCalled()
276275
})
@@ -282,6 +281,7 @@ describe(LinksManager.name, () => {
282281
space: null,
283282
share: { isDir: false, name: 'bad.txt', alias: 'share' }
284283
} as any
284+
linksQueriesMock.spaceLink.mockReset()
285285
linksQueriesMock.spaceLink.mockResolvedValueOnce(spaceLink)
286286

287287
spacesManagerMock.spaceEnv.mockResolvedValueOnce({} as any)
@@ -290,7 +290,7 @@ describe(LinksManager.name, () => {
290290
stream: jest.fn()
291291
} as any)
292292

293-
await expect(service.linkAccess(identity, link.uuid, req, res)).rejects.toMatchObject({
293+
await expect(service.linkDownload(identity, link.uuid, req, res)).rejects.toMatchObject({
294294
status: 500
295295
})
296296
expect(linksQueriesMock.incrementLinkNbAccess).toHaveBeenCalledWith(link.uuid)

backend/src/applications/links/services/links-manager.service.ts

Lines changed: 47 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { FastifyReply, FastifyRequest } from 'fastify'
99
import { LoginResponseDto } from '../../../authentication/dto/login-response.dto'
1010
import { JwtIdentityPayload } from '../../../authentication/interfaces/jwt-payload.interface'
1111
import { AuthManager } from '../../../authentication/services/auth-manager.service'
12+
import { serverConfig } from '../../../configuration/config.environment'
1213
import { FilesManager } from '../../files/services/files-manager.service'
1314
import { SendFile } from '../../files/utils/send-file'
1415
import { SPACE_REPOSITORY } from '../../spaces/constants/spaces'
@@ -41,43 +42,67 @@ export class LinksManager {
4142
this.logger.warn(`${this.linkValidation.name} - ${uuid} : ${check}`)
4243
}
4344
const spaceLink: SpaceLink = ok ? await this.linksQueries.spaceLink(uuid) : null
44-
if (spaceLink?.owner?.login) {
45-
spaceLink.owner.avatar = await getAvatarBase64(spaceLink.owner.login)
46-
// for security reasons
47-
delete spaceLink.owner.login
45+
if (ok) {
46+
if (spaceLink?.owner?.login) {
47+
spaceLink.owner.avatar = await getAvatarBase64(spaceLink.owner.login)
48+
// For security reasons
49+
delete spaceLink.owner.login
50+
}
51+
if (spaceLink?.share) {
52+
// Only used when the link is a file or a directory
53+
spaceLink.fileEditors = serverConfig.fileEditors
54+
}
4855
}
4956
return { ok: ok, error: ok ? null : check, link: spaceLink }
5057
}
5158

52-
async linkAccess(identity: JwtIdentityPayload, uuid: string, req: FastifyRequest, res: FastifyReply): Promise<StreamableFile | LoginResponseDto> {
59+
async linkAccess(
60+
identity: JwtIdentityPayload,
61+
uuid: string,
62+
req: FastifyRequest,
63+
res: FastifyReply
64+
): Promise<LoginResponseDto | Omit<LoginResponseDto, 'token'>> {
5365
const [link, check, ok] = await this.linkEnv(identity, uuid)
5466
if (!ok) {
5567
this.logger.warn(`${this.linkAccess.name} - *${link.user.login}* (${link.user.id}) : ${check}`)
5668
throw new HttpException(check as string, HttpStatus.BAD_REQUEST)
5769
}
5870
const user = new UserModel(link.user)
59-
const spaceLink: SpaceLink = await this.linksQueries.spaceLink(uuid)
60-
if (!spaceLink.space && !spaceLink.share.isDir) {
61-
// download the file (authentication has been verified before)
62-
this.logger.log(`${this.linkAccess.name} - *${user.login}* (${user.id}) downloading ${spaceLink.share.name}`)
63-
this.incrementLinkNbAccess(link)
64-
const spaceEnv: SpaceEnv = await this.spaceEnvFromLink(user, spaceLink)
65-
const sendFile: SendFile = this.filesManager.sendFileFromSpace(spaceEnv, spaceLink.share.name)
66-
try {
67-
await sendFile.checks()
68-
return await sendFile.stream(req, res)
69-
} catch (e) {
70-
this.logger.error(`${this.linkAccess.name} - unable to send file : ${e}`)
71-
throw new HttpException('Unable to download file', HttpStatus.INTERNAL_SERVER_ERROR)
72-
}
73-
} else if (link.user.id !== identity.id) {
74-
// authenticate user to allow access to the directory
71+
if (link.user.id !== identity.id) {
72+
// Authenticate user to allow access to the directory
7573
this.logger.log(`${this.linkAccess.name} - *${user.login}* (${user.id}) is logged`)
7674
this.incrementLinkNbAccess(link)
7775
this.usersManager.updateAccesses(user, req.ip, true).catch((e: Error) => this.logger.error(`${this.linkAccess.name} - ${e}`))
7876
return this.authManager.setCookies(user, res)
7977
}
80-
// already authenticated
78+
// Already authenticated
79+
return { user: user, server: serverConfig }
80+
}
81+
82+
async linkDownload(identity: JwtIdentityPayload, uuid: string, req: FastifyRequest, res: FastifyReply): Promise<StreamableFile> {
83+
const [link, check, ok] = await this.linkEnv(identity, uuid)
84+
if (!ok) {
85+
this.logger.warn(`${this.linkDownload.name} - *${link.user.login}* (${link.user.id}) : ${check}`)
86+
throw new HttpException(check as string, HttpStatus.BAD_REQUEST)
87+
}
88+
const spaceLink: SpaceLink = await this.linksQueries.spaceLink(uuid)
89+
if (spaceLink.space || spaceLink.share?.isDir) {
90+
this.logger.error(`${this.linkDownload.name} - the provided link does not reference a downloadable file`)
91+
throw new HttpException('This link does not allow file download', HttpStatus.BAD_REQUEST)
92+
}
93+
const user = new UserModel(link.user)
94+
// Download the file (authentication has been verified before)
95+
this.logger.log(`${this.linkDownload.name} - *${user.login}* (${user.id}) downloading ${spaceLink.share.name}`)
96+
this.incrementLinkNbAccess(link)
97+
const spaceEnv: SpaceEnv = await this.spaceEnvFromLink(user, spaceLink)
98+
const sendFile: SendFile = this.filesManager.sendFileFromSpace(spaceEnv, spaceLink.share.name)
99+
try {
100+
await sendFile.checks()
101+
return await sendFile.stream(req, res)
102+
} catch (e) {
103+
this.logger.error(`${this.linkDownload.name} - unable to send file : ${e}`)
104+
throw new HttpException('Unable to download file', HttpStatus.INTERNAL_SERVER_ERROR)
105+
}
81106
}
82107

83108
async linkAuthentication(identity: JwtIdentityPayload, uuid: string, linkPasswordDto: UserPasswordDto, req: FastifyRequest, res: FastifyReply) {

backend/src/applications/links/services/links-queries.service.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,10 @@ export class LinksQueries {
155155
alias: shares.alias,
156156
hasParent: isNotNull(shares.parentId).mapWith(Boolean),
157157
isDir: sql`IF (${isNotNull(shares.externalPath)} OR ${isNotNull(shareSpaceRoot.externalPath)}, 1 ,${files.isDir})`.mapWith(Boolean),
158-
mime: files.mime
158+
mime: files.mime,
159+
mtime: files.mtime,
160+
size: files.size,
161+
permissions: sharesMembers.permissions
159162
},
160163
space: { name: spaces.name, alias: spaces.alias },
161164
owner: { login: shareOwner.login, fullName: userFullNameSQL(shareOwner) }
@@ -176,7 +179,11 @@ export class LinksQueries {
176179
)
177180
.where(eq(links.uuid, uuid))
178181
.limit(1)
179-
return r
182+
return {
183+
...r,
184+
space: r.space?.name ? r.space : null,
185+
share: r.share?.name ? r.share : null
186+
}
180187
}
181188

182189
async incrementLinkNbAccess(uuid: string) {

frontend/src/app/applications/links/components/public/public-link-auth.component.html

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,20 @@
1010
<img alt="" height="40" [src]="logoUrl">
1111
</a>
1212
</div>
13-
<img (click)="validPassword()" alt="" class="cursor-pointer" [src]="linkProtected" height="96">
14-
<div class="d-flex mt-2 ms-auto me-auto" style="width: 250px">
15-
<div class="input-group">
16-
<span class="input-group-text bg-gray-light">
17-
<fa-icon [icon]="icons.faKey"></fa-icon>
18-
</span>
13+
<div class="d-flex flex-column align-items-center" style="margin: 1.2rem 1rem;">
14+
<img (click)="validPassword()" alt="" class="cursor-pointer" [src]="linkProtected" height="96">
15+
</div>
16+
<div class="d-flex flex-column align-items-center mt-2 ms-auto me-auto">
17+
<div class="input-group" style="max-width: 200px">
1918
<input [(ngModel)]="password"
2019
(keyup.enter)="validPassword()"
20+
[class.is-invalid]="password?.length < passwordMinLength"
2121
autocomplete="off"
2222
class="form-control border-0"
2323
[placeholder]="'Password' | translate:locale.language"
24-
type="password">
25-
<button [disabled]="!password?.length"
24+
type="password"
25+
required>
26+
<button [disabled]="password?.length < passwordMinLength"
2627
(click)="validPassword()"
2728
class="btn btn-default btn-custom border-0"
2829
type="button">
@@ -33,7 +34,7 @@
3334
<div class="error-text no-select">
3435
<span class="hr"></span>
3536
<div>
36-
<span class="solve" l10nTranslate>This share is protected by a password</span>
37+
<span class="solve" l10nTranslate>This access is protected by a password</span>
3738
</div>
3839
</div>
3940
</div>

frontend/src/app/applications/links/components/public/public-link-auth.component.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { FormsModule } from '@angular/forms'
99
import { ActivatedRoute, Params, RouterLink } from '@angular/router'
1010
import { FaIconComponent } from '@fortawesome/angular-fontawesome'
1111
import { faKey, faSignInAlt } from '@fortawesome/free-solid-svg-icons'
12+
import { USER_PASSWORD_MIN_LENGTH } from '@sync-in-server/backend/src/applications/users/constants/user'
1213
import { L10N_LOCALE, L10nLocale, L10nTranslateDirective, L10nTranslatePipe } from 'angular-l10n'
1314
import { linkProtected, logoUrl } from '../../../files/files.constants'
1415
import { LinksService } from '../../services/links.service'
@@ -23,6 +24,7 @@ export class PublicLinkAuthComponent {
2324
protected readonly logoUrl = logoUrl
2425
protected readonly linkProtected = linkProtected
2526
protected readonly icons = { faKey, faSignInAlt }
27+
protected readonly passwordMinLength = USER_PASSWORD_MIN_LENGTH
2628
protected password = ''
2729
private readonly activatedRoute = inject(ActivatedRoute)
2830
private readonly linksService = inject(LinksService)
@@ -33,7 +35,7 @@ export class PublicLinkAuthComponent {
3335
}
3436

3537
validPassword() {
36-
if (this.password) {
38+
if (this.password && this.password.length >= this.passwordMinLength) {
3739
this.linksService.linkAuthentication(this.uuid, this.password).subscribe(() => (this.password = ''))
3840
}
3941
}

frontend/src/app/applications/links/components/public/public-link-error.component.html

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,14 @@
1010
<img alt="" height="40" [src]="logoUrl">
1111
</a>
1212
</div>
13-
<fa-icon class="text-white" [icon]="icons.faExclamationCircle" size="4x"></fa-icon>
14-
<div class="error-code mt-2" style="font-size: 24px" l10nTranslate>Link error</div>
13+
<div class="d-flex flex-column align-items-center" style="margin-top: 1.2rem; margin-bottom: .6rem">
14+
<fa-icon class="text-white" [icon]="icons.faExclamationCircle" size="5x"></fa-icon>
15+
</div>
16+
<div class="error-code" l10nTranslate>Link error</div>
1517
<div class="error-text">
1618
<span class="hr"></span>
17-
<br>
18-
<span l10nTranslate>{{ error }}</span>
19-
<br>
20-
<br>
19+
<div>
20+
<span class="solve" l10nTranslate>{{ error }}</span>
21+
</div>
2122
</div>
2223
</div>

frontend/src/app/applications/links/components/public/public-link.component.html

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,29 @@
1111
</a>
1212
</div>
1313
<div class="d-flex flex-column align-content-center">
14-
<div (click)="followLink()" class="resource d-flex flex-column align-items-center mt-3 mx-auto cursor-pointer">
15-
@if (mimeUrl) {
16-
<img [src]="mimeUrl" alt="" height="96" class="no-select-pointer" (error)="fallBackMimeUrl()">
17-
} @else {
18-
<fa-icon [icon]="icons.SPACES" class="circle-primary-icon" [fixedWidth]="false" style="min-width: 128px; min-height: 128px; font-size: 64px"></fa-icon>
14+
<div class="d-flex flex-column align-items-center mt-3 mb-1 mx-auto cursor-pointer">
15+
<div (click)="followLink()" class="resource">
16+
<div class="mime">
17+
@if (file?.mimeUrl) {
18+
<img [src]="file.mimeUrl" alt="" height="96" class="no-select-pointer" (error)="fallBackMimeUrl()">
19+
} @else {
20+
<fa-icon [icon]="icons.SPACES" class="circle-primary-icon" [fixedWidth]="false" style="width: 96px; height: 96px; font-size: 48px"></fa-icon>
21+
}
22+
</div>
23+
<span class="error-code">{{ link.share?.name || link.space?.name }}</span>
24+
</div>
25+
@if (file?.isDir === false) {
26+
<div class="mt-3 mb-2 fs-lg">
27+
@if (file?.isViewable) {
28+
<button (click)="openLink()" class="btn btn-lg btn-outline-light me-2" l10nTranslate>
29+
<fa-icon [icon]="fileCanBeModified && file.isEditable ? icons.faPen : icons.faEye"></fa-icon>
30+
</button>
31+
}
32+
<button (click)="downloadLink()" class="btn btn-lg btn-outline-light">
33+
<fa-icon [icon]="icons.faDownload"></fa-icon>
34+
</button>
35+
</div>
1936
}
20-
<span class="error-code">{{ link.share?.name || link.space?.name }}</span>
2137
</div>
2238
</div>
2339
<div class="error-text no-select">

0 commit comments

Comments
 (0)