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
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,30 @@ GET /api/folders/search?folderPath=/&query=report&type=file&minSize=1024&sortBy=

---

## Inline file viewing

`GET /api/files/view?path=<user-relative-path>` serves a file for display in the browser. The
response keeps the file's detected MIME type, uses `Content-Disposition: inline`, and supports
HTTP byte-range requests for seeking in video, audio, and PDF files. Unknown file types use
`application/octet-stream`, allowing the client to show a download fallback.

The endpoint uses the same authenticated user root and path validation as the other file APIs.
Viewer actions can reuse the existing endpoints:

| Action | Endpoint |
|-------|----------|
| Rename or move | `PATCH /api/files/move?filePath=<path>&newPath=<path>` |
| Delete (move to trash) | `DELETE /api/files?filePath=<path>` |
| Download | `GET /api/files/download?path=<path>` |
| Next/previous source list | `GET /api/folders/content?path=<folder>&page=<page>&size=<size>` |

All requests require the existing bearer-token authentication. A frontend using native
`<video>`, `<audio>`, `<img>`, or embedded PDF elements should expose the endpoint through its
authenticated same-origin backend/proxy, because those elements cannot attach an arbitrary
`Authorization` request header.

---

## Project Structure

- Backend implemented in **Spring Boot**
Expand Down Expand Up @@ -101,4 +125,4 @@ Then visit:
## 📫 Questions?

For any issues, feel free to open an issue in this repository.
Integration or usage questions related to Vault Web should reference the main Vault Web documentation.
Integration or usage questions related to Vault Web should reference the main Vault Web documentation.
22 changes: 15 additions & 7 deletions backend/src/main/java/cloudpage/controller/FileController.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@
import cloudpage.service.TrashService;
import cloudpage.service.UserService;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
Expand Down Expand Up @@ -91,17 +93,23 @@ public ResponseEntity<Resource> viewFile(@RequestParam String path) throws IOExc
Path fullPath = Paths.get(user.getRootFolderPath(), path).normalize();
folderService.validatePath(user.getRootFolderPath(), fullPath);

if (!fullPath.toFile().exists() || !fullPath.toFile().isFile()) {
throw new FileNotFoundException("File Not Found with path : " + path);
}

Resource resource = new UrlResource(fullPath.toUri());
FileResource result = fileService.loadAsResource(fullPath);
String mimeType = Files.probeContentType(fullPath);
Comment thread
DenizAltunkapan marked this conversation as resolved.
if (mimeType == null) {
mimeType = "application/octet-stream";
}

return ResponseEntity.ok().header(HttpHeaders.CONTENT_TYPE, mimeType).body(resource);
return ResponseEntity.ok()
.eTag(result.getETag())
.lastModified(result.getLastModified())
.contentType(MediaType.parseMediaType(mimeType))
.header(
HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.inline()
.filename(fullPath.getFileName().toString(), StandardCharsets.UTF_8)
.build()
.toString())
.body(result.getResource());
}

@GetMapping("/checksum")
Expand Down
137 changes: 135 additions & 2 deletions backend/src/test/java/cloudpage/controller/FileControllerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -151,16 +151,149 @@ void downloadFile_nonExistentFile_returns404() throws Exception {

@Test
void viewFile_existingFile_returnsResourceWithContentType() throws Exception {
Files.writeString(tempDir.resolve("view.txt"), "viewme");
Path file = Files.writeString(tempDir.resolve("view.txt"), "viewme");
when(fileService.loadAsResource(file)).thenCallRealMethod();

mockMvc
.perform(get("/api/files/view").param("path", "view.txt"))
.andExpect(status().isOk())
.andExpect(header().exists("Content-Type"));
.andExpect(header().string("Content-Type", "text/plain"))
.andExpect(
header()
.string(
"Content-Disposition",
org.hamcrest.Matchers.containsString("filename*=UTF-8''view.txt")))
.andExpect(header().string("ETag", org.hamcrest.Matchers.startsWith("\"6-")))
.andExpect(header().exists("Last-Modified"))
.andExpect(content().string("viewme"));
}

@Test
void viewFile_rangeRequest_returnsPartialContent() throws Exception {
Path file = Files.writeString(tempDir.resolve("video.mp4"), "0123456789");
when(fileService.loadAsResource(file)).thenCallRealMethod();

mockMvc
.perform(get("/api/files/view").param("path", "video.mp4").header("Range", "bytes=2-5"))
Comment thread
DenizAltunkapan marked this conversation as resolved.
.andExpect(status().isPartialContent())
.andExpect(header().string("Content-Range", "bytes 2-5/10"))
.andExpect(content().bytes("2345".getBytes()));
}

@Test
void viewFile_openRange_returnsRemainingContent() throws Exception {
Path file = Files.writeString(tempDir.resolve("audio.mp3"), "0123456789");
when(fileService.loadAsResource(file)).thenCallRealMethod();

mockMvc
.perform(get("/api/files/view").param("path", "audio.mp3").header("Range", "bytes=6-"))
.andExpect(status().isPartialContent())
.andExpect(header().string("Content-Range", "bytes 6-9/10"))
.andExpect(content().bytes("6789".getBytes()));
}

@Test
void viewFile_suffixRange_returnsLastBytes() throws Exception {
Path file = Files.writeString(tempDir.resolve("document.pdf"), "0123456789");
when(fileService.loadAsResource(file)).thenCallRealMethod();

mockMvc
.perform(get("/api/files/view").param("path", "document.pdf").header("Range", "bytes=-4"))
.andExpect(status().isPartialContent())
.andExpect(header().string("Content-Range", "bytes 6-9/10"))
.andExpect(content().bytes("6789".getBytes()));
}

@Test
void viewFile_withoutRange_returnsFullContent() throws Exception {
Path file = Files.writeString(tempDir.resolve("image.png"), "0123456789");
when(fileService.loadAsResource(file)).thenCallRealMethod();

mockMvc
.perform(get("/api/files/view").param("path", "image.png"))
.andExpect(status().isOk())
.andExpect(content().bytes("0123456789".getBytes()));
}

@Test
void viewFile_unsatisfiableRange_returns416() throws Exception {
Path file = Files.writeString(tempDir.resolve("video.mp4"), "0123456789");
when(fileService.loadAsResource(file)).thenCallRealMethod();

mockMvc
.perform(get("/api/files/view").param("path", "video.mp4").header("Range", "bytes=20-30"))
.andExpect(status().isRequestedRangeNotSatisfiable())
.andExpect(header().string("Content-Range", "bytes */10"));
}

@Test
void viewFile_unknownMimeType_returnsOctetStream() throws Exception {
Path file = Files.writeString(tempDir.resolve("archive.cloudpage-unknown"), "data");
when(fileService.loadAsResource(file)).thenCallRealMethod();

mockMvc
.perform(get("/api/files/view").param("path", "archive.cloudpage-unknown"))
.andExpect(status().isOk())
.andExpect(header().string("Content-Type", "application/octet-stream"));
}

@Test
void viewFile_nonAsciiFilename_usesSafeInlineContentDisposition() throws Exception {
String filename = "Urlaub Zürich 2026.pdf";
Path file = Files.writeString(tempDir.resolve(filename), "data");
when(fileService.loadAsResource(file)).thenCallRealMethod();

mockMvc
.perform(get("/api/files/view").param("path", filename))
.andExpect(status().isOk())
.andExpect(
header()
.string(
"Content-Disposition",
org.hamcrest.Matchers.containsString(
"filename*=UTF-8''Urlaub%20Z%C3%BCrich%202026.pdf")));
}

@Test
void viewFile_pathTraversal_returns400() throws Exception {
Path root = Files.createDirectories(tempDir.resolve("root/nested"));
Files.writeString(tempDir.resolve("outside.pdf"), "secret");
testUser.setRootFolderPath(root.toString());
doCallRealMethod().when(folderService).validatePath(eq(root.toString()), any(Path.class));

mockMvc
.perform(get("/api/files/view").param("path", "../../outside.pdf"))
.andExpect(status().isBadRequest());

verify(fileService, never()).loadAsResource(any(Path.class));
}

@Test
void viewFile_symlinkOutsideUserRoot_returns400() throws Exception {
Path root = Files.createDirectory(tempDir.resolve("root"));
Path outside = Files.writeString(tempDir.resolve("outside.pdf"), "secret");
Path link = root.resolve("linked.pdf");
try {
Files.createSymbolicLink(link, outside);
} catch (UnsupportedOperationException | SecurityException e) {
org.junit.jupiter.api.Assumptions.abort("Symbolic links are not supported");
} catch (java.nio.file.FileSystemException e) {
org.junit.jupiter.api.Assumptions.abort("Symbolic links are not permitted");
}
testUser.setRootFolderPath(root.toString());
doCallRealMethod().when(folderService).validatePath(eq(root.toString()), any(Path.class));

mockMvc
.perform(get("/api/files/view").param("path", "linked.pdf"))
.andExpect(status().isBadRequest());

verify(fileService, never()).loadAsResource(any(Path.class));
}

@Test
void viewFile_nonExistentFile_returns404() throws Exception {
Comment thread
DenizAltunkapan marked this conversation as resolved.
when(fileService.loadAsResource(any(Path.class))).thenCallRealMethod();

mockMvc
.perform(get("/api/files/view").param("path", "nofile.txt"))
.andExpect(status().isNotFound());
Expand Down