#101: DataTransferPublicServicesRest.kt

plugins/org.projectforge.plugins.datatransfer/src/main/kotlin/org/projectforge/plugins/datatransfer/restPublic/DataTransferPublicServicesRest.kt

Path: plugins/.../datatransfer/restPublic/DataTransferPublicServicesRest.kt · Lines: 496 · Language: Kotlin · Base URL: /rsPublic/datatransfer

Purpose: PUBLIC REST API for the DataTransfer plugin — allows EXTERNAL users (without ProjectForge accounts) to download, upload, delete, and modify files via token/password authentication. 7 endpoints with full audit logging. The bridge between ProjectForge's internal file storage (JCR) and the outside world.

Source: GitHub

496 lines · 434 code · 35 comments · 27 blank
CommitMessage
9ebb885222016-07-18 Initial commit
Not Spring Security. Authentication uses a custom token-based session system (DataTransferPublicSession), NOT Spring Security's filter chain. External users get an access token (typically a UUID in a URL parameter) that maps to a ProjectForge data transfer area. No ProjectForge user account needed — this is for sharing files with customers, suppliers, or the public.

7 endpoints — the full CRUD + bulk operations

EndpointMethodLinesWhat it doesAccess check
/download/{category}/{id}GET83-119Download a single file by fileIdhasDownloadAccess() per-file
/downloadAll/{category}/{id}GET121-151Download ALL files as ZIPexternalDownloadEnabled on area
/multiDownload/{category}/{id}GET157-198Download selected files as ZIP (CSV of fileIds)externalDownloadEnabled on area
/multiDeletePOST200-284Delete multiple files in one requestexternalUploadEnabled + per-file ownership
/upload/{category}/{id}/{listId}POST286-341Upload a file via multipart formexternalUploadEnabled
/deletePOST343-395Delete a single filehasDeleteAccess() per-file
/modifyPOST397-440Rename or change description of a fileImplicit via accessChecker on JCR operations

Every endpoint starts with the same 3 checks: (1) validate category = "datatransfer", (2) validate listId = DEFAULT_NODE, (3) authenticate via dataTransferPublicSession.checkLogin(). The check() function throws IllegalStateException if the condition fails — uncaught, it becomes a 500 error. Arguably, these should return 400 responses instead.

Token-based public authentication — no user account needed

val data = dataTransferPublicSession.checkLogin(request, id)
    ?: return RestUtils.badRequest("No valid login.")
val area: DataTransferAreaDO = data.first        // The DataTransfer area
val sessionData = data.second                      // Session info (userInfo, token)

DataTransferPublicSession.checkLogin() is the custom auth layer. It extracts an access token from the HTTP request (likely from query params: ?accessToken=abc123) and validates it against the database. On success, it returns a Pair<DataTransferAreaDO, TransferAreaData> — the area being accessed and session metadata. On failure, returns null and the endpoint responds with 400 "No valid login."

Not a JWT or OAuth system — this is a simple token lookup. The token is generated when an admin creates a data transfer area with external access enabled. The token is embedded in the shareable URL: https://projectforge.acme.com/rsPublic/datatransfer/download/datatransfer/42?accessToken=uuid.

multiDownload — the file ID prefix trick (lines 157-198)

@RequestParam("fileIds") fileIds: String,     // CSV: "abc1,def2,ghi3"
val fileIdList = fileIds.split(",")
val attachments = attachmentsService.getAttachments(...)
    ?.filter { attachment ->
        fileIdList.any { attachment.fileId?.startsWith(it) == true }
        // Match by PREFIX, not exact ID! "abc" matches "abc123def456"
    }

The Javadoc on line 154 explains the trick: "For preserving url length, fileIds may also be shortened (e.g. first 4 chars)." JCR file IDs are long UUIDs like "a1b2c3d4-e5f6-7890-abcd-ef1234567890". Putting multiple full UUIDs in a URL query string bloats the URL past browser limits (~2000 chars). The solution: users can send just the first 4 characters of each file ID.

How the matching works: fileIdList.any { attachment.fileId?.startsWith(it) == true } — for each attachment, check if its full fileId starts with ANY of the provided prefixes. If the user sends "a1b2,d4e5", the server matches "a1b2c3d4-..." and "d4e5f6a7-...". This is a trade-off: shorter URLs vs. risk of prefix collisions. With UUIDs, the first 4 hex chars have 16^4 = 65536 possibilities — collision risk is extremely low for typical usage (10-20 files per area).

ResponseAction pattern — server-driven UI updates

// After upload, tell the React frontend to update the attachment list
return ResponseEntity.ok()
    .body(
        ResponseAction(targetType = TargetType.UPDATE, merge = true)
            .addVariable("data", AttachmentsServicesRest.ResponseData(list))
    )

// After delete/modify, tell React to close the modal
return ResponseEntity.ok()
    .body(
        ResponseAction(targetType = TargetType.CLOSE_MODAL, merge = true)
            .addVariable("data", AttachmentsServicesRest.ResponseData(list))
    )

Instead of returning just the updated data, the endpoints return a ResponseAction — a command to the React frontend about what to do next. This is the server-driven UI pattern (SDUI) that ProjectForge uses for its dynamic layout system:

The addVariable("data", ...) attaches the new attachment list as a named variable. The frontend's dynamic layout engine reads the ResponseAction and decides which components to re-render. The server doesn't know about React components — it sends abstract actions; the frontend interprets them.

Audit trail — every operation logged with external user info

attachmentsService.addAttachment(
    jcrPath, fileInfo, inputStream,
    baseDao = dataTransferAreaDao,
    obj = area,
    accessChecker = attachmentsAccessChecker,
    userString = getExternalUserString(request, sessionData.userInfo)  // Audit!
)

Every JCR operation passes a userString — a string identifying the external user. getExternalUserString() (from the companion object of DataTransferAreaDao) extracts the client's IP address and optional identity info from the HTTP request: "192.168.1.42 (John from Acme Corp)". This string is stored in the JCR's audit log alongside the file modification. Even though the user has no ProjectForge account, their actions are traceable — critical for GDPR compliance and security forensics.

registerFileAsOwner() (line 323): After upload, the session records which files the external user uploaded. This enables per-file ownership checks — a user can only delete/modify files they themselves uploaded, not files uploaded by other external users in the same shared area.

convert() — building the filtered DTO (lines 442-464)

The convert() helper builds a DataTransferPublicArea DTO for the frontend. Two key transformations:

  1. Access filtering: attachmentsAccessChecker.filterAttachments() — removes files the current external user shouldn't see. This is defense-in-depth: even if the JCR query returns all files, the access checker strips unauthorized ones before they reach the client.
  2. Expiry info: DataTransferUtils.expiryTimeLeft() — data transfer areas can have expiration. Each attachment gets an expiry countdown added to its DTO. The frontend can display "Expires in 3 days" or a red warning for expired files.