Add collaborative editing to shared files with version conflict detection

This commit is contained in:
Anthony Stirling
2026-07-31 07:49:58 +01:00
parent 9d01866c83
commit 77f45806c9
29 changed files with 1845 additions and 125 deletions
+29 -2
View File
@@ -56,6 +56,32 @@ Default role when none is specified: `EDITOR`.
Owners always have full access regardless of role.
`EDITOR` write access is enforced on the update path: a non-owner with an
`EDITOR` user-share can `PUT /api/v1/storage/files/{fileId}`, and any
authenticated holder of an `EDITOR` share link can
`PUT /api/v1/storage/share-links/{token}`. Non-owner writes require
`sharing.enabled`. Non-owner updates replace only the main file content -
history bundle and audit log parts are accepted but only the owner's client
sends them, so the owner's audit trail is not clobbered by collaborators.
### Optimistic Concurrency (Collaboration)
Every stored file carries a `content_version` counter (`stored_files.content_version`,
null for pre-upgrade rows, read as 0). Every content replace bumps it atomically
via a guarded UPDATE, which also serializes concurrent writers at the database row.
- `StoredFileResponse.version` and `ShareLinkMetadataResponse.version` expose the counter
- Downloads return it as the `ETag` header
- Clients send `If-Match: "N"` on update; if the server version moved on, the
update fails with `409 Conflict` and nothing is written
- Requests without `If-Match` keep last-write-wins semantics (legacy clients)
The frontend tracks `remoteVersionBase` (what the local bytes derive from) and
`remoteVersionLatest` (newest seen during sync). When latest > base the UI shows
an "Update available" badge and offers "Get latest version". On a 409 the save
dialogs offer "Get latest version" (fetch as a separate copy to merge manually)
or "Overwrite anyway".
#### Role Semantics: COMMENTER vs VIEWER
In the file storage layer, `COMMENTER` and `VIEWER` are equivalent — both grant read-only access and neither can replace file content. The distinction is meaningful in the **signing workflow** context:
@@ -309,7 +335,7 @@ The `FileShare.workflow_participant_id` column and the `FileShare.isWorkflowShar
| Method | Endpoint | Description | Auth |
|--------|----------|-------------|------|
| POST | `/api/v1/storage/files` | Upload file | Required |
| PUT | `/api/v1/storage/files/{id}` | Update file | Required (owner) |
| PUT | `/api/v1/storage/files/{id}` | Update file content | Required (owner or EDITOR share) |
| GET | `/api/v1/storage/files` | List accessible files | Required |
| GET | `/api/v1/storage/files/{id}` | Get file metadata | Required |
| GET | `/api/v1/storage/files/{id}/download` | Download file | Required |
@@ -320,6 +346,7 @@ The `FileShare.workflow_participant_id` column and the `FileShare.isWorkflowShar
| POST | `/api/v1/storage/files/{id}/shares/links` | Create share link | Required (owner) |
| DELETE | `/api/v1/storage/files/{id}/shares/links/{token}` | Revoke share link | Required (owner) |
| GET | `/api/v1/storage/share-links/{token}` | Download via share link | Required |
| PUT | `/api/v1/storage/share-links/{token}` | Update content via EDITOR share link | Required |
| GET | `/api/v1/storage/share-links/{token}/metadata` | Get share link metadata | Required |
| GET | `/api/v1/storage/share-links/accessed` | List accessed share links | Required |
| GET | `/api/v1/storage/files/{id}/shares/links/{token}/accesses` | List share accesses | Required (owner) |
@@ -355,7 +382,7 @@ storage:
### Access Control
- All endpoints require authentication — there is no anonymous access
- Owner-only operations enforced in service layer (not just controller)
- `requireReadAccess` / `requireEditorAccess` checked on every download
- `requireReadAccess` checked on every download; `requireEditorAccess` checked on every non-owner content update
### Share Link Security
- Tokens are UUIDs (random, not guessable)
@@ -19,6 +19,7 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
@@ -77,9 +78,11 @@ public class FileStorageController {
@PathVariable Long fileId,
@RequestPart("file") MultipartFile file,
@RequestPart(name = "historyBundle", required = false) MultipartFile historyBundle,
@RequestPart(name = "auditLog", required = false) MultipartFile auditLog) {
@RequestPart(name = "auditLog", required = false) MultipartFile auditLog,
@RequestHeader(name = HttpHeaders.IF_MATCH, required = false) String ifMatch) {
User user = fileStorageService.requireAuthenticatedUser();
return fileStorageService.updateFileResponse(user, fileId, file, historyBundle, auditLog);
return fileStorageService.updateFileResponse(
user, fileId, file, historyBundle, auditLog, parseVersionHeader(ifMatch));
}
@GetMapping(value = "/files", produces = MediaType.APPLICATION_JSON_VALUE)
@@ -206,6 +209,34 @@ public class FileStorageController {
return redirect.orElseGet(() -> buildFileResponse(file, inline));
}
@PutMapping(
value = "/share-links/{token}",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ShareLinkMetadataResponse updateShareLink(
@PathVariable String token,
Authentication authentication,
@RequestPart("file") MultipartFile file,
@RequestPart(name = "historyBundle", required = false) MultipartFile historyBundle,
@RequestPart(name = "auditLog", required = false) MultipartFile auditLog,
@RequestHeader(name = HttpHeaders.IF_MATCH, required = false) String ifMatch) {
fileStorageService.ensureShareLinksEnabled();
FileShare share = fileStorageService.getShareByToken(token);
if (!fileStorageService.canAccessShareLink(share, authentication)) {
HttpStatus status =
isAuthenticated(authentication)
? HttpStatus.FORBIDDEN
: HttpStatus.UNAUTHORIZED;
String message =
status == HttpStatus.FORBIDDEN
? "Access denied for this share link"
: "Authentication required for this share link";
throw new ResponseStatusException(status, message);
}
return fileStorageService.updateShareLinkResponse(
share, authentication, file, historyBundle, auditLog, parseVersionHeader(ifMatch));
}
@GetMapping("/share-links/{token}/metadata")
public ShareLinkMetadataResponse getShareLinkMetadata(
@PathVariable String token, Authentication authentication) {
@@ -238,6 +269,7 @@ public class FileStorageController {
share.getAccessRole() != null
? share.getAccessRole().name().toLowerCase(Locale.ROOT)
: null)
.version(file.contentVersionOrZero())
.createdAt(share.getCreatedAt())
.expiresAt(share.getExpiresAt())
.build();
@@ -278,9 +310,28 @@ public class FileStorageController {
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
}
headers.setContentLength(file.getSizeBytes());
// Content revision as ETag so clients can track the version their bytes came from.
headers.setETag("\"" + file.contentVersionOrZero() + "\"");
return ResponseEntity.ok().headers(headers).body(resource);
}
// Accepts a plain number or a (weak) quoted ETag; null/invalid means no conflict check.
private Long parseVersionHeader(String ifMatch) {
if (ifMatch == null || ifMatch.isBlank()) {
return null;
}
String cleaned = ifMatch.trim();
if (cleaned.startsWith("W/")) {
cleaned = cleaned.substring(2);
}
cleaned = cleaned.replace("\"", "").trim();
try {
return Long.parseLong(cleaned);
} catch (NumberFormatException ex) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid If-Match header");
}
}
private boolean isAuthenticated(Authentication authentication) {
return authentication != null
&& authentication.isAuthenticated()
@@ -2,5 +2,6 @@ package stirling.software.proprietary.storage.model;
public enum FileShareAccessType {
VIEW,
DOWNLOAD
DOWNLOAD,
EDIT
}
@@ -123,6 +123,11 @@ public class StoredFile implements Serializable {
@OnDelete(action = OnDeleteAction.SET_NULL)
private Folder folder;
// Content revision, bumped atomically on every replace (StoredFileRepository).
// Nullable so pre-existing rows survive ddl-auto upgrade; readers treat null as 0.
@Column(name = "content_version")
private Long contentVersion;
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@@ -130,4 +135,9 @@ public class StoredFile implements Serializable {
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
/** Null-safe content version; legacy rows without the column value read as 0. */
public long contentVersionOrZero() {
return contentVersion != null ? contentVersion : 0L;
}
}
@@ -14,6 +14,9 @@ public class ShareLinkMetadataResponse {
private final String owner;
private final boolean ownedByCurrentUser;
private final String accessRole;
// Content revision for optimistic concurrency; clients echo it back on update.
private final Long version;
private final LocalDateTime createdAt;
private final LocalDateTime expiresAt;
private final LocalDateTime lastAccessedAt;
@@ -17,6 +17,9 @@ public class StoredFileResponse {
private final String owner;
private final boolean ownedByCurrentUser;
private final String accessRole;
// Content revision for optimistic concurrency; clients echo it back on update.
private final long version;
private final LocalDateTime createdAt;
private final LocalDateTime updatedAt;
private final List<String> sharedWithUsers;
@@ -73,4 +73,19 @@ public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
+ "WHERE sf.workflowSession IN "
+ "(SELECT ws FROM WorkflowSession ws WHERE ws.owner = :user)")
void clearWorkflowSessionReferencesByOwner(@Param("user") User user);
// Guarded compare-and-bump: returns 0 when another writer got there first (409 upstream).
// Row lock serializes concurrent editors; coalesce backfills legacy null versions.
@Modifying
@Query(
"UPDATE StoredFile f SET f.contentVersion = COALESCE(f.contentVersion, 0) + 1 "
+ "WHERE f.id = :id AND COALESCE(f.contentVersion, 0) = :expected")
int bumpContentVersionIfMatches(@Param("id") Long id, @Param("expected") long expected);
// Unconditional bump for legacy clients that don't send a base version.
@Modifying
@Query(
"UPDATE StoredFile f SET f.contentVersion = COALESCE(f.contentVersion, 0) + 1 "
+ "WHERE f.id = :id")
int bumpContentVersion(@Param("id") Long id);
}
@@ -173,20 +173,94 @@ public class FileStorageService {
}
public StoredFile replaceFile(
User owner,
User actor,
StoredFile existing,
MultipartFile file,
MultipartFile historyBundle,
MultipartFile auditLog) {
return replaceFile(actor, existing, file, historyBundle, auditLog, null);
}
public StoredFile replaceFile(
User actor,
StoredFile existing,
MultipartFile file,
MultipartFile historyBundle,
MultipartFile auditLog,
Long expectedVersion) {
ensureStorageEnabled();
if (!isOwner(existing, owner)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Only the owner can update");
if (!isOwner(existing, actor)) {
// Collaborative write-back: shared EDITORs may replace content when sharing is on.
ensureSharingEnabled();
ShareAccessRole role = resolveUserShareRole(existing, actor);
if (role != ShareAccessRole.EDITOR) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN, "Editor access is required to update this file");
}
}
return replaceFileContent(existing, file, historyBundle, auditLog, expectedVersion);
}
/** Content replace via an EDITOR share link; the actor is not the owner. */
public StoredFile replaceFileViaShareLink(
FileShare share,
MultipartFile file,
MultipartFile historyBundle,
MultipartFile auditLog,
Long expectedVersion) {
ensureStorageEnabled();
ensureShareLinksEnabled();
requireEditorAccess(share);
return replaceFileContent(share.getFile(), file, historyBundle, auditLog, expectedVersion);
}
// Runs inside the transaction so lazy owner/share fields are still attached
// when the response is built (OSIV is off - controllers must not touch proxies).
public ShareLinkMetadataResponse updateShareLinkResponse(
FileShare share,
Authentication authentication,
MultipartFile file,
MultipartFile historyBundle,
MultipartFile auditLog,
Long expectedVersion) {
StoredFile updated =
replaceFileViaShareLink(share, file, historyBundle, auditLog, expectedVersion);
recordShareAccess(share, authentication, FileShareAccessType.EDIT);
User currentUser = requireAuthenticatedUser();
boolean ownedByCurrentUser =
updated.getOwner() != null
&& updated.getOwner().getId().equals(currentUser.getId());
return ShareLinkMetadataResponse.builder()
.shareToken(share.getShareToken())
.fileId(updated.getId())
.fileName(updated.getOriginalFilename())
.owner(updated.getOwner() != null ? updated.getOwner().getUsername() : null)
.ownedByCurrentUser(ownedByCurrentUser)
.accessRole(
share.getAccessRole() != null
? share.getAccessRole().name().toLowerCase(Locale.ROOT)
: null)
.version(updated.contentVersionOrZero())
.createdAt(share.getCreatedAt())
.expiresAt(share.getExpiresAt())
.build();
}
private StoredFile replaceFileContent(
StoredFile existing,
MultipartFile file,
MultipartFile historyBundle,
MultipartFile auditLog,
Long expectedVersion) {
validateMainUpload(file);
// Quotas and blob attribution always follow the file owner, not the acting editor.
User owner = existing.getOwner();
long newTotalBytes = calculateUploadBytes(file, historyBundle, auditLog, existing);
enforceStorageQuotas(owner, newTotalBytes, totalStoredBytes(existing));
long newVersion = bumpContentVersion(existing, expectedVersion);
StoredObject mainObject = null;
StoredObject historyObject = null;
StoredObject auditObject = null;
@@ -246,6 +320,28 @@ public class FileStorageService {
}
}
// Serializes concurrent writers via a guarded UPDATE (row lock); loser gets 409.
// Syncs the in-memory entity so the later full-column flush doesn't clobber the bump.
private long bumpContentVersion(StoredFile existing, Long expectedVersion) {
long newVersion;
if (expectedVersion != null) {
int updated =
storedFileRepository.bumpContentVersionIfMatches(
existing.getId(), expectedVersion);
if (updated == 0) {
throw new ResponseStatusException(
HttpStatus.CONFLICT,
"File was modified by someone else. Refresh and try again.");
}
newVersion = expectedVersion + 1;
} else {
storedFileRepository.bumpContentVersion(existing.getId());
newVersion = existing.contentVersionOrZero() + 1;
}
existing.setContentVersion(newVersion);
return newVersion;
}
public StoredFile getAccessibleFile(User user, Long fileId) {
ensureStorageEnabled();
StoredFile file =
@@ -281,7 +377,7 @@ public class FileStorageService {
ShareAccessRole role = resolveUserShareRole(file, user);
if (role != ShareAccessRole.EDITOR) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN, "Insufficient permissions to download");
HttpStatus.FORBIDDEN, "Editor access is required to update this file");
}
}
@@ -289,7 +385,7 @@ public class FileStorageService {
ShareAccessRole role = resolveShareRole(share);
if (role != ShareAccessRole.EDITOR) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN, "Insufficient permissions to download");
HttpStatus.FORBIDDEN, "Editor access is required to update this file");
}
}
@@ -331,18 +427,30 @@ public class FileStorageService {
}
public StoredFileResponse updateFileResponse(User owner, Long fileId, MultipartFile file) {
return updateFileResponse(owner, fileId, file, null, null);
return updateFileResponse(owner, fileId, file, null, null, null);
}
public StoredFileResponse updateFileResponse(
User owner,
User actor,
Long fileId,
MultipartFile file,
MultipartFile historyBundle,
MultipartFile auditLog) {
StoredFile existing = getOwnedFile(owner, fileId);
StoredFile updated = replaceFile(owner, existing, file, historyBundle, auditLog);
return buildResponse(updated, owner);
return updateFileResponse(actor, fileId, file, historyBundle, auditLog, null);
}
public StoredFileResponse updateFileResponse(
User actor,
Long fileId,
MultipartFile file,
MultipartFile historyBundle,
MultipartFile auditLog,
Long expectedVersion) {
// Share-aware lookup so EDITOR recipients can write back; replaceFile enforces the role.
StoredFile existing = getAccessibleFile(actor, fileId);
StoredFile updated =
replaceFile(actor, existing, file, historyBundle, auditLog, expectedVersion);
return buildResponse(updated, actor);
}
public List<StoredFileResponse> listAccessibleFileResponses(User user) {
@@ -449,6 +557,7 @@ public class FileStorageService {
.owner(file.getOwner() != null ? file.getOwner().getUsername() : null)
.ownedByCurrentUser(ownedByCurrentUser)
.accessRole(accessRole)
.version(file.contentVersionOrZero())
.createdAt(file.getCreatedAt())
.updatedAt(file.getUpdatedAt())
.sharedWithUsers(sharedWithUsers)
@@ -709,6 +818,14 @@ public class FileStorageService {
}
public void recordShareAccess(FileShare share, Authentication authentication, boolean inline) {
recordShareAccess(
share,
authentication,
inline ? FileShareAccessType.VIEW : FileShareAccessType.DOWNLOAD);
}
public void recordShareAccess(
FileShare share, Authentication authentication, FileShareAccessType accessType) {
if (share == null) {
return;
}
@@ -725,7 +842,7 @@ public class FileStorageService {
FileShareAccess access = new FileShareAccess();
access.setFileShare(share);
access.setUser(user);
access.setAccessType(inline ? FileShareAccessType.VIEW : FileShareAccessType.DOWNLOAD);
access.setAccessType(accessType);
fileShareAccessRepository.save(access);
}
@@ -811,6 +928,7 @@ public class FileStorageService {
.name()
.toLowerCase(Locale.ROOT)
: null)
.version(file != null ? file.contentVersionOrZero() : null)
.createdAt(share != null ? share.getCreatedAt() : null)
.expiresAt(share != null ? share.getExpiresAt() : null)
.lastAccessedAt(access.getAccessedAt())
@@ -103,9 +103,34 @@ class FileStorageControllerMoreTest {
MultipartFile file = mock(MultipartFile.class);
StoredFileResponse resp = StoredFileResponse.builder().id(2L).build();
when(fileStorageService.requireAuthenticatedUser()).thenReturn(u);
when(fileStorageService.updateFileResponse(u, 5L, file, null, null)).thenReturn(resp);
when(fileStorageService.updateFileResponse(u, 5L, file, null, null, null))
.thenReturn(resp);
assertThat(controller.updateFile(5L, file, null, null)).isSameAs(resp);
assertThat(controller.updateFile(5L, file, null, null, null)).isSameAs(resp);
}
@Test
void updateFile_parsesIfMatchHeader() {
User u = user();
MultipartFile file = mock(MultipartFile.class);
StoredFileResponse resp = StoredFileResponse.builder().id(2L).build();
when(fileStorageService.requireAuthenticatedUser()).thenReturn(u);
when(fileStorageService.updateFileResponse(u, 5L, file, null, null, 7L))
.thenReturn(resp);
assertThat(controller.updateFile(5L, file, null, null, "\"7\"")).isSameAs(resp);
}
@Test
void updateFile_invalidIfMatchHeader_badRequest() {
User u = user();
MultipartFile file = mock(MultipartFile.class);
when(fileStorageService.requireAuthenticatedUser()).thenReturn(u);
assertThatThrownBy(() -> controller.updateFile(5L, file, null, null, "abc"))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
}
@Test
@@ -525,6 +525,186 @@ class FileStorageServiceTest {
verify(storedFileRepository, never()).sumStorageBytesTotal();
}
// replaceFile - collaborative editor write-back
private StoredObject storedObject(String key) {
return StoredObject.builder()
.storageKey(key)
.originalFilename("test.pdf")
.contentType("application/pdf")
.sizeBytes(1L)
.build();
}
@Test
void replaceFile_editorShare_nonOwnerCanWrite() throws IOException {
when(storageProperties.getQuotas()).thenReturn(null);
User owner = user(1L);
User editor = user(2L);
StoredFile existing = ownedFile(owner);
existing.setStorageKey("old-key");
FileShare share = shareFor(existing, editor, ShareAccessRole.EDITOR);
when(fileShareRepository.findByFileAndSharedWithUser(existing, editor))
.thenReturn(Optional.of(share));
when(storageProvider.store(any(), any())).thenReturn(storedObject("new-key"));
when(storedFileRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
MockMultipartFile file =
new MockMultipartFile("file", "test.pdf", "application/pdf", new byte[] {1});
service.replaceFile(editor, existing, file, null, null);
// Blob attribution stays with the file owner, not the acting editor.
verify(storageProvider).store(owner, file);
}
@Test
void replaceFile_viewerShare_nonOwnerForbidden() {
User owner = user(1L);
User viewer = user(2L);
StoredFile existing = ownedFile(owner);
FileShare share = shareFor(existing, viewer, ShareAccessRole.VIEWER);
when(fileShareRepository.findByFileAndSharedWithUser(existing, viewer))
.thenReturn(Optional.of(share));
MockMultipartFile file =
new MockMultipartFile("file", "test.pdf", "application/pdf", new byte[] {1});
assertThatThrownBy(() -> service.replaceFile(viewer, existing, file, null, null))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(403);
}
@Test
void replaceFile_nonOwner_sharingDisabled_forbidden() {
when(sharingProperties.isEnabled()).thenReturn(false);
User owner = user(1L);
User editor = user(2L);
StoredFile existing = ownedFile(owner);
MockMultipartFile file =
new MockMultipartFile("file", "test.pdf", "application/pdf", new byte[] {1});
assertThatThrownBy(() -> service.replaceFile(editor, existing, file, null, null))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(403);
}
@Test
void replaceFile_versionMismatch_throwsConflict() {
when(storageProperties.getQuotas()).thenReturn(null);
User owner = user(1L);
StoredFile existing = ownedFile(owner);
existing.setContentVersion(5L);
when(storedFileRepository.bumpContentVersionIfMatches(100L, 4L)).thenReturn(0);
MockMultipartFile file =
new MockMultipartFile("file", "test.pdf", "application/pdf", new byte[] {1});
assertThatThrownBy(() -> service.replaceFile(owner, existing, file, null, null, 4L))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(409);
verify(storedFileRepository, never()).save(any());
}
@Test
void replaceFile_versionMatch_bumpsAndSaves() throws IOException {
when(storageProperties.getQuotas()).thenReturn(null);
User owner = user(1L);
StoredFile existing = ownedFile(owner);
existing.setContentVersion(4L);
existing.setStorageKey("old-key");
when(storedFileRepository.bumpContentVersionIfMatches(100L, 4L)).thenReturn(1);
when(storageProvider.store(any(), any())).thenReturn(storedObject("new-key"));
when(storedFileRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
MockMultipartFile file =
new MockMultipartFile("file", "test.pdf", "application/pdf", new byte[] {1});
StoredFile updated = service.replaceFile(owner, existing, file, null, null, 4L);
assertThat(updated.getContentVersion()).isEqualTo(5L);
}
@Test
void replaceFile_noExpectedVersion_bumpsUnconditionally() throws IOException {
when(storageProperties.getQuotas()).thenReturn(null);
User owner = user(1L);
StoredFile existing = ownedFile(owner);
existing.setStorageKey("old-key");
when(storageProvider.store(any(), any())).thenReturn(storedObject("new-key"));
when(storedFileRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
MockMultipartFile file =
new MockMultipartFile("file", "test.pdf", "application/pdf", new byte[] {1});
StoredFile updated = service.replaceFile(owner, existing, file, null, null, null);
verify(storedFileRepository).bumpContentVersion(100L);
// Legacy null version reads as 0 and increments to 1.
assertThat(updated.getContentVersion()).isEqualTo(1L);
}
@Test
void replaceFileViaShareLink_editorRole_writes() throws IOException {
when(storageProperties.getQuotas()).thenReturn(null);
User owner = user(1L);
StoredFile existing = ownedFile(owner);
existing.setStorageKey("old-key");
FileShare linkShare = new FileShare();
linkShare.setFile(existing);
linkShare.setShareToken("token-1");
linkShare.setAccessRole(ShareAccessRole.EDITOR);
when(storageProvider.store(any(), any())).thenReturn(storedObject("new-key"));
when(storedFileRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
MockMultipartFile file =
new MockMultipartFile("file", "test.pdf", "application/pdf", new byte[] {1});
StoredFile updated = service.replaceFileViaShareLink(linkShare, file, null, null, null);
assertThat(updated.getStorageKey()).isEqualTo("new-key");
verify(storageProvider).store(owner, file);
}
@Test
void replaceFileViaShareLink_viewerRole_forbidden() {
User owner = user(1L);
StoredFile existing = ownedFile(owner);
FileShare linkShare = new FileShare();
linkShare.setFile(existing);
linkShare.setShareToken("token-1");
linkShare.setAccessRole(ShareAccessRole.VIEWER);
MockMultipartFile file =
new MockMultipartFile("file", "test.pdf", "application/pdf", new byte[] {1});
assertThatThrownBy(() -> service.replaceFileViaShareLink(linkShare, file, null, null, null))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(403);
}
@Test
void replaceFile_editorShare_quotaChargedToOwner() throws IOException {
when(storageProperties.getQuotas()).thenReturn(quotasProperties);
when(quotasProperties.getMaxFileMb()).thenReturn(-1L);
when(quotasProperties.getMaxStorageMbPerUser()).thenReturn(10L);
when(quotasProperties.getMaxStorageMbTotal()).thenReturn(-1L);
User owner = user(1L);
User editor = user(2L);
StoredFile existing = ownedFile(owner);
existing.setStorageKey("old-key");
FileShare share = shareFor(existing, editor, ShareAccessRole.EDITOR);
when(fileShareRepository.findByFileAndSharedWithUser(existing, editor))
.thenReturn(Optional.of(share));
when(storedFileRepository.sumStorageBytesByOwner(any())).thenReturn(0L);
when(storageProvider.store(any(), any())).thenReturn(storedObject("new-key"));
when(storedFileRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
MockMultipartFile file =
new MockMultipartFile("file", "test.pdf", "application/pdf", new byte[1024 * 1024]);
service.replaceFile(editor, existing, file, null, null);
verify(storedFileRepository).sumStorageBytesByOwner(owner);
verify(storedFileRepository, never()).sumStorageBytesByOwner(editor);
}
// -------------------------------------------------------------------------
// deleteFile — workflow guard
// -------------------------------------------------------------------------
@@ -9564,6 +9564,28 @@ title = "Split PDF by Chapters"
[splitPdfByChapters]
tags = "split,chapters,bookmarks,organise"
[storageCollab]
conflictBody = "Someone else saved a newer version since you last synced. You can fetch their version to merge manually, or overwrite it with yours."
conflictTitle = "This file changed on the server"
errorTitle = "Save failed"
fetchLatestFailed = "Unable to fetch the latest shared version."
getLatest = "Get latest version"
latestFetched = "Latest version added"
latestFetchedBody = "The newest shared version was added next to your copy so you can merge your changes."
newBadge = "New"
overwrite = "Overwrite anyway"
ownedBy = "Owned by {{owner}}"
saveButton = "Save changes"
saved = "Saved to shared file"
saveDescription = "Save your changes back to the shared file. Everyone with access will see this version."
saveFailed = "Unable to save your changes to the shared file."
saveTitle = "Save to Shared File"
saveToShared = "Save to shared file"
sharedEditorNoticeBody = "You have editor access. Use “Save to shared file” after editing to publish your changes for everyone with access."
sharedEditorNoticeTitle = "Shared file"
updateAvailable = "Update available"
updateAvailableHint = "A newer version of this shared file exists on the server."
[storageShare]
accessDenied = "You do not have access to this shared file. Ask the owner to share it with you."
accessDeniedBody = "You do not have access to this file. Ask the owner to share it with you."
@@ -9587,6 +9609,7 @@ download = "Download"
downloaded = "Downloaded"
downloadFailed = "Unable to download this file."
downloadsCount = "Downloads: {{count}}"
edited = "Edited"
emailWarningBody = "This looks like an email address. If this person is not already a Stirling PDF user, they will not be able to access the file."
emailWarningConfirm = "Share anyway"
emailWarningTitle = "Email address"
@@ -10442,6 +10442,28 @@ title = "Split PDF by Chapters"
[splitPdfByChapters]
tags = "split,chapters,bookmarks,organize"
[storageCollab]
conflictBody = "Someone else saved a newer version since you last synced. You can fetch their version to merge manually, or overwrite it with yours."
conflictTitle = "This file changed on the server"
errorTitle = "Save failed"
fetchLatestFailed = "Unable to fetch the latest shared version."
getLatest = "Get latest version"
latestFetched = "Latest version added"
latestFetchedBody = "The newest shared version was added next to your copy so you can merge your changes."
newBadge = "New"
overwrite = "Overwrite anyway"
ownedBy = "Owned by {{owner}}"
saveButton = "Save changes"
saved = "Saved to shared file"
saveDescription = "Save your changes back to the shared file. Everyone with access will see this version."
saveFailed = "Unable to save your changes to the shared file."
saveTitle = "Save to Shared File"
saveToShared = "Save to shared file"
sharedEditorNoticeBody = "You have editor access. Use “Save to shared file” after editing to publish your changes for everyone with access."
sharedEditorNoticeTitle = "Shared file"
updateAvailable = "Update available"
updateAvailableHint = "A newer version of this shared file exists on the server."
[storageShare]
accessDenied = "You do not have access to this shared file. Ask the owner to share it with you."
accessDeniedBody = "You do not have access to this file. Ask the owner to share it with you."
@@ -10465,6 +10487,7 @@ download = "Download"
downloaded = "Downloaded"
downloadFailed = "Unable to download this file."
downloadsCount = "Downloads: {{count}}"
edited = "Edited"
emailWarningBody = "This looks like an email address. If this person is not already a Stirling PDF user, they will not be able to access the file."
emailWarningConfirm = "Share anyway"
emailWarningTitle = "Email address"
@@ -11,6 +11,7 @@ import CloseIcon from "@mui/icons-material/Close";
import VisibilityIcon from "@mui/icons-material/Visibility";
import UnarchiveIcon from "@mui/icons-material/Unarchive";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import CloudSyncIcon from "@mui/icons-material/CloudSync";
import LinkIcon from "@mui/icons-material/Link";
import HistoryIcon from "@mui/icons-material/History";
import PushPinIcon from "@mui/icons-material/PushPin";
@@ -41,6 +42,8 @@ import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWith
import { PrivateContent } from "@app/components/shared/PrivateContent";
import UploadToServerModal from "@app/components/shared/UploadToServerModal";
import ShareFileModal from "@app/components/shared/ShareFileModal";
import SaveToSharedModal from "@app/components/shared/SaveToSharedModal";
import { canEditSharedFile } from "@app/hooks/useSharedFileActions";
import { VersionHistoryModal } from "@app/components/filesPage/VersionHistoryModal";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useFileThumbnail } from "@app/hooks/useFileThumbnail";
@@ -147,6 +150,7 @@ const FileEditorThumbnail = ({
const isOwnedOrLocal = file.remoteOwnedByCurrentUser !== false;
const isSharedFile =
file.remoteOwnedByCurrentUser === false || file.remoteSharedViaLink;
const isSharedEditor = sharingEnabled && canEditSharedFile(file);
const localUpdatedAt = file.createdAt ?? file.lastModified ?? 0;
const remoteUpdatedAt = file.remoteStorageUpdatedAt ?? 0;
const isUploaded = Boolean(file.remoteStorageId);
@@ -177,6 +181,7 @@ const FileEditorThumbnail = ({
const [isDragging, setIsDragging] = useState(false);
const [showCloseModal, setShowCloseModal] = useState(false);
const [showUploadModal, setShowUploadModal] = useState(false);
const [showSaveToSharedModal, setShowSaveToSharedModal] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
const [showSharedEditNotice, setShowSharedEditNotice] = useState(false);
const sharedEditNoticeShownRef = useRef(false);
@@ -388,6 +393,25 @@ const FileEditorThumbnail = ({
},
]
: []),
...(isSharedEditor && file.isLeaf
? [
{
id: "saveToShared",
icon: <CloudSyncIcon style={{ fontSize: 20 }} />,
label: t("storageCollab.saveToShared", "Save to shared file"),
disabled: policyEnforcing,
tooltip: policyEnforcing
? enforcingTooltip(
t("storageCollab.saveToShared", "Save to shared file"),
)
: undefined,
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
setShowSaveToSharedModal(true);
},
},
]
: []),
...(canShare
? [
{
@@ -463,6 +487,8 @@ const FileEditorThumbnail = ({
policyEnforcing,
canUpload,
canShare,
isSharedEditor,
file.isLeaf,
isUploaded,
pinFile,
unpinFile,
@@ -689,20 +715,29 @@ const FileEditorThumbnail = ({
</Stack>
</Modal>
{/* Shared edit notice modal */}
{/* Shared edit notice modal - copy depends on the recipient's role */}
<Modal
opened={showSharedEditNotice}
onClose={() => setShowSharedEditNotice(false)}
title={t("fileManager.sharedEditNoticeTitle", "Read-only server copy")}
title={
isSharedEditor
? t("storageCollab.sharedEditorNoticeTitle", "Shared file")
: t("fileManager.sharedEditNoticeTitle", "Read-only server copy")
}
centered
size="auto"
>
<Stack gap="md">
<Text size="sm">
{t(
"fileManager.sharedEditNoticeBody",
"You do not have edit rights to the server version of this file. Any edits you make will be saved as a local copy.",
)}
{isSharedEditor
? t(
"storageCollab.sharedEditorNoticeBody",
"You have editor access. Use “Save to shared file” after editing to publish your changes for everyone with access.",
)
: t(
"fileManager.sharedEditNoticeBody",
"You do not have edit rights to the server version of this file. Any edits you make will be saved as a local copy.",
)}
</Text>
<Group justify="flex-end" gap="sm">
<Button onClick={() => setShowSharedEditNotice(false)}>
@@ -719,6 +754,13 @@ const FileEditorThumbnail = ({
file={file}
/>
)}
{isSharedEditor && (
<SaveToSharedModal
opened={showSaveToSharedModal}
onClose={() => setShowSaveToSharedModal(false)}
file={file}
/>
)}
{canShare && (
<ShareFileModal
opened={showShareModal}
@@ -18,6 +18,8 @@ import UnarchiveIcon from "@mui/icons-material/Unarchive";
import CloseIcon from "@mui/icons-material/Close";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import CloudDoneIcon from "@mui/icons-material/CloudDone";
import CloudSyncIcon from "@mui/icons-material/CloudSync";
import FileDownloadIcon from "@mui/icons-material/FileDownload";
import LinkIcon from "@mui/icons-material/Link";
import { useTranslation } from "react-i18next";
import { getFileSize, getFileDate } from "@app/utils/fileUtils";
@@ -32,6 +34,12 @@ import UploadToServerModal from "@app/components/shared/UploadToServerModal";
import ShareFileModal from "@app/components/shared/ShareFileModal";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import ShareManagementModal from "@app/components/shared/ShareManagementModal";
import SaveToSharedModal from "@app/components/shared/SaveToSharedModal";
import {
canEditSharedFile,
hasNewerSharedVersion,
useSharedFileActions,
} from "@app/hooks/useSharedFileActions";
import apiClient from "@app/services/apiClient";
import { absoluteWithBasePath } from "@app/constants/app";
import { alert } from "@app/components/toast";
@@ -65,6 +73,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
const [showUploadModal, setShowUploadModal] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
const [showShareManageModal, setShowShareManageModal] = useState(false);
const [showSaveToSharedModal, setShowSaveToSharedModal] = useState(false);
const { t } = useTranslation();
const { config } = useAppConfig();
const {
@@ -131,6 +140,11 @@ const FileListItem: React.FC<FileListItemProps> = ({
Boolean(file.remoteHasShareLinks) &&
Boolean(file.remoteStorageId);
const canDownloadFile = Boolean(onDownload) && hasReadAccess;
const canSaveToShared =
sharingEnabled && isLatestVersion && canEditSharedFile(file);
const hasRemoteUpdate =
sharingEnabled && isSharedWithYou && hasNewerSharedVersion(file);
const { fetchLatestCopy } = useSharedFileActions();
const shareBaseUrl = useMemo(() => {
const frontendUrl = (config?.frontendUrl || "").trim();
@@ -251,6 +265,16 @@ const FileListItem: React.FC<FileListItemProps> = ({
{t("fileManager.sharedWithYou", "Shared with you")}
</Badge>
) : null}
{hasRemoteUpdate ? (
<Badge
size="xs"
variant="light"
color="orange"
leftSection={<CloudSyncIcon style={{ fontSize: 12 }} />}
>
{t("storageCollab.updateAvailable", "Update available")}
</Badge>
) : null}
{sharingEnabled &&
isSharedWithYou &&
accessRole &&
@@ -382,6 +406,37 @@ const FileListItem: React.FC<FileListItemProps> = ({
</Menu.Item>
)}
{canSaveToShared && (
<Menu.Item
leftSection={<CloudSyncIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
setShowSaveToSharedModal(true);
}}
>
{t("storageCollab.saveToShared", "Save to shared file")}
</Menu.Item>
)}
{sharingEnabled && isSharedWithYou && isLatestVersion && (
<Menu.Item
leftSection={<FileDownloadIcon style={{ fontSize: 16 }} />}
rightSection={
hasRemoteUpdate ? (
<Badge size="xs" color="orange" variant="filled">
{t("storageCollab.newBadge", "New")}
</Badge>
) : undefined
}
onClick={(e) => {
e.stopPropagation();
void fetchLatestCopy(file);
}}
>
{t("storageCollab.getLatest", "Get latest version")}
</Menu.Item>
)}
{canShare && (
<Menu.Item
leftSection={<LinkIcon style={{ fontSize: 16 }} />}
@@ -497,6 +552,14 @@ const FileListItem: React.FC<FileListItemProps> = ({
onUploaded={refreshRecentFiles}
/>
)}
{canSaveToShared && (
<SaveToSharedModal
opened={showSaveToSharedModal}
onClose={() => setShowSaveToSharedModal(false)}
file={file}
onSaved={refreshRecentFiles}
/>
)}
{canManageShare && (
<ShareManagementModal
opened={showShareManageModal}
@@ -1,6 +1,6 @@
import React, { useCallback, useMemo, useRef } from "react";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Checkbox, Menu, Tooltip } from "@mantine/core";
import { Badge, Checkbox, Menu, Tooltip } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import MoreVertIcon from "@mui/icons-material/MoreVert";
@@ -14,6 +14,8 @@ import HistoryIcon from "@mui/icons-material/History";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import DriveFileRenameOutlineIcon from "@mui/icons-material/DriveFileRenameOutline";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import CloudSyncIcon from "@mui/icons-material/CloudSync";
import FileDownloadIcon from "@mui/icons-material/FileDownload";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
@@ -37,6 +39,13 @@ import { FolderAppearancePicker } from "@app/components/filesPage/FolderAppearan
import { useLazyThumbnail } from "@app/hooks/useLazyThumbnail";
import type { FilesPageSortMode } from "@app/contexts/FilesPageContext";
import { OpenInNewWindowMenuItem } from "@app/components/filesPage/OpenInNewWindowMenuItem";
import SaveToSharedModal from "@app/components/shared/SaveToSharedModal";
import {
canEditSharedFile,
hasNewerSharedVersion,
useSharedFileActions,
} from "@app/hooks/useSharedFileActions";
import { useAppConfig } from "@app/contexts/AppConfigContext";
export type FilesPageViewMode = "grid" | "list";
@@ -627,6 +636,17 @@ function FileCard({
() => getFileDate({ lastModified: file.lastModified }),
[file.lastModified],
);
const [showSaveToSharedModal, setShowSaveToSharedModal] = useState(false);
const { fetchLatestCopy } = useSharedFileActions();
const { config } = useAppConfig();
const sharingEnabled =
config?.storageEnabled === true && config?.storageSharingEnabled === true;
const isSharedEditor = sharingEnabled && canEditSharedFile(file);
const isSharedWithYou =
sharingEnabled &&
(file.remoteOwnedByCurrentUser === false ||
Boolean(file.remoteSharedViaLink));
const hasRemoteUpdate = isSharedWithYou && hasNewerSharedVersion(file);
const handleDragStart = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
@@ -685,6 +705,17 @@ function FileCard({
{t("filesPage.inWorkspace", "Open")}
</span>
)}
{hasRemoteUpdate && (
<span
className="files-page-card-update-badge"
title={t(
"storageCollab.updateAvailableHint",
"A newer version of this shared file exists on the server.",
)}
>
{t("storageCollab.updateAvailable", "Update available")}
</span>
)}
{/* Checkbox only renders once the user is explicitly in multi-select
mode (2+ files chosen via Ctrl/Shift-click, or one file then
another). For single-select the highlight border on the card is
@@ -809,6 +840,37 @@ function FileCard({
</Menu.Item>
</Tooltip>
)}
{isSharedEditor && (
<Menu.Item
leftSection={<CloudSyncIcon fontSize="small" />}
data-testid="file-menu-save-to-shared"
onClick={(e) => {
e.stopPropagation();
setShowSaveToSharedModal(true);
}}
>
{t("storageCollab.saveToShared", "Save to shared file")}
</Menu.Item>
)}
{isSharedWithYou && (
<Menu.Item
leftSection={<FileDownloadIcon fontSize="small" />}
rightSection={
hasRemoteUpdate ? (
<Badge size="xs" color="orange" variant="filled">
{t("storageCollab.newBadge", "New")}
</Badge>
) : undefined
}
data-testid="file-menu-get-latest"
onClick={(e) => {
e.stopPropagation();
void fetchLatestCopy(file);
}}
>
{t("storageCollab.getLatest", "Get latest version")}
</Menu.Item>
)}
{onVersionHistory && (file.versionNumber ?? 1) > 1 && (
<Menu.Item
leftSection={<HistoryIcon fontSize="small" />}
@@ -834,6 +896,13 @@ function FileCard({
</Menu.Dropdown>
</Menu>
</div>
{isSharedEditor && (
<SaveToSharedModal
opened={showSaveToSharedModal}
onClose={() => setShowSaveToSharedModal(false)}
file={file}
/>
)}
</div>
);
}
@@ -576,6 +576,28 @@
pointer-events: none;
}
/* Newer shared revision exists on the server; sits under the Open badge slot. */
.files-page-card-update-badge {
position: absolute;
top: 0.5rem;
left: 0.5rem;
z-index: 3;
display: inline-flex;
align-items: center;
padding: 0.22rem 0.55rem;
border-radius: 999px;
background: var(--c-warning);
color: var(--c-text-on-primary);
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
box-shadow:
0 0 0 2px rgba(255, 255, 255, 0.85),
0 2px 6px rgba(0, 0, 0, 0.18);
pointer-events: none;
}
.files-page-card-open-dot {
width: 0.45rem;
height: 0.45rem;
@@ -121,6 +121,7 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({
const {
remoteId: storedId,
updatedAt,
version,
chain,
} = await uploadHistoryChains(rootIds, existingRemoteId);
@@ -128,20 +129,17 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({
setShareToken(shareResponse.token ?? null);
for (const stub of chain) {
actions.updateStirlingFileStub(stub.id, {
const updates = {
remoteStorageId: storedId,
remoteStorageUpdatedAt: updatedAt,
remoteOwnedByCurrentUser: true,
remoteSharedViaLink: false,
remoteHasShareLinks: true,
});
await fileStorage.updateFileMetadata(stub.id, {
remoteStorageId: storedId,
remoteStorageUpdatedAt: updatedAt,
remoteOwnedByCurrentUser: true,
remoteSharedViaLink: false,
remoteHasShareLinks: true,
});
remoteVersionBase: version,
remoteVersionLatest: version,
};
actions.updateStirlingFileStub(stub.id, updates);
await fileStorage.updateFileMetadata(stub.id, updates);
}
alert({
@@ -0,0 +1,207 @@
import React, { useCallback, useEffect, useState } from "react";
import { Modal, Stack, Text, Group, Alert } from "@mantine/core";
import { Button } from "@app/ui/Button";
import CloudSyncIcon from "@mui/icons-material/CloudSync";
import FileDownloadIcon from "@mui/icons-material/FileDownload";
import { useTranslation } from "react-i18next";
import { alert } from "@app/components/toast";
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
import type { StirlingFileStub } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
import {
saveSharedFile,
SharedFileConflictError,
} from "@app/services/sharedFileSave";
import { fileStorage } from "@app/services/fileStorage";
import { useFileActions } from "@app/contexts/FileContext";
import { useSharedFileActions } from "@app/hooks/useSharedFileActions";
interface SaveToSharedModalProps {
opened: boolean;
onClose: () => void;
file: StirlingFileStub;
onSaved?: () => Promise<void> | void;
}
// Saves a recipient's edits back to a shared file (editor role); version
// conflicts get a guided choice instead of a silent overwrite.
const SaveToSharedModal: React.FC<SaveToSharedModalProps> = ({
opened,
onClose,
file,
onSaved,
}) => {
const { t } = useTranslation();
const { actions } = useFileActions();
const [isWorking, setIsWorking] = useState(false);
const [hasConflict, setHasConflict] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
if (!opened) {
setIsWorking(false);
setHasConflict(false);
setErrorMessage(null);
}
}, [opened]);
const applySavedMetadata = useCallback(
async (version: number | undefined, updatedAt: number) => {
const originalFileId = (file.originalFileId || file.id) as FileId;
const chain = await fileStorage.getHistoryChainStubs(originalFileId);
const stubs = chain.length > 0 ? chain : [file];
const updates = {
remoteStorageUpdatedAt: updatedAt,
remoteVersionBase: version,
remoteVersionLatest: version,
};
for (const stub of stubs) {
actions.updateStirlingFileStub(stub.id, updates);
await fileStorage.updateFileMetadata(stub.id, updates);
}
},
[actions, file],
);
const handleSave = useCallback(
async (force = false) => {
setIsWorking(true);
setErrorMessage(null);
try {
const result = await saveSharedFile(file, { force });
await applySavedMetadata(result.version, result.updatedAt);
alert({
alertType: "success",
title: t("storageCollab.saved", "Saved to shared file"),
expandable: false,
durationMs: 3000,
});
if (onSaved) {
await onSaved();
}
onClose();
} catch (error) {
if (error instanceof SharedFileConflictError) {
setHasConflict(true);
} else {
console.error("Failed to save shared file:", error);
setErrorMessage(
t(
"storageCollab.saveFailed",
"Unable to save your changes to the shared file.",
),
);
}
} finally {
setIsWorking(false);
}
},
[applySavedMetadata, file, onClose, onSaved, t],
);
const { fetchLatestCopy } = useSharedFileActions();
const handleGetLatestCopy = useCallback(async () => {
setIsWorking(true);
setErrorMessage(null);
const ok = await fetchLatestCopy(file);
setIsWorking(false);
if (ok) {
onClose();
}
}, [fetchLatestCopy, file, onClose]);
return (
<Modal
opened={opened}
onClose={onClose}
centered
title={t("storageCollab.saveTitle", "Save to Shared File")}
zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL}
overlayProps={{ blur: 6 }}
size={hasConflict ? "lg" : "md"}
>
<Stack gap="sm">
{!hasConflict && (
<>
<Text size="sm">
{t(
"storageCollab.saveDescription",
"Save your changes back to the shared file. Everyone with access will see this version.",
)}
</Text>
<Text size="sm" c="dimmed">
{t("storageShare.fileLabel", "File")}: {file.name}
{file.remoteOwnerUsername
? `${t("storageCollab.ownedBy", "Owned by {{owner}}", {
owner: file.remoteOwnerUsername,
})}`
: ""}
</Text>
</>
)}
{hasConflict && (
<Alert
color="yellow"
title={t(
"storageCollab.conflictTitle",
"This file changed on the server",
)}
>
{t(
"storageCollab.conflictBody",
"Someone else saved a newer version since you last synced. You can fetch their version to merge manually, or overwrite it with yours.",
)}
</Alert>
)}
{errorMessage && (
<Alert
color="red"
title={t("storageCollab.errorTitle", "Save failed")}
>
{errorMessage}
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button variant="secondary" onClick={onClose} disabled={isWorking}>
{t("cancel", "Cancel")}
</Button>
{hasConflict ? (
<>
<Button
variant="secondary"
leftSection={<FileDownloadIcon style={{ fontSize: 18 }} />}
onClick={() => void handleGetLatestCopy()}
loading={isWorking}
>
{t("storageCollab.getLatest", "Get latest version")}
</Button>
<Button
accent="danger"
leftSection={<CloudSyncIcon style={{ fontSize: 18 }} />}
onClick={() => void handleSave(true)}
loading={isWorking}
>
{t("storageCollab.overwrite", "Overwrite anyway")}
</Button>
</>
) : (
<Button
leftSection={<CloudSyncIcon style={{ fontSize: 18 }} />}
onClick={() => void handleSave(false)}
loading={isWorking}
>
{t("storageCollab.saveButton", "Save changes")}
</Button>
)}
</Group>
</Stack>
</Modal>
);
};
export default SaveToSharedModal;
@@ -124,23 +124,22 @@ const ShareFileModal: React.FC<ShareFileModalProps> = ({
const {
remoteId: newStoredId,
updatedAt,
version,
chain,
} = await uploadHistoryChain(originalFileId, remoteId);
storedId = newStoredId;
for (const stub of chain) {
actions.updateStirlingFileStub(stub.id, {
const updates = {
remoteStorageId: newStoredId,
remoteStorageUpdatedAt: updatedAt,
remoteOwnedByCurrentUser: true,
remoteHasShareLinks: true,
});
await fileStorage.updateFileMetadata(stub.id, {
remoteStorageId: newStoredId,
remoteStorageUpdatedAt: updatedAt,
remoteOwnedByCurrentUser: true,
remoteHasShareLinks: true,
});
remoteVersionBase: version,
remoteVersionLatest: version,
};
actions.updateStirlingFileStub(stub.id, updates);
await fileStorage.updateFileMetadata(stub.id, updates);
}
}
@@ -991,12 +991,22 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({
)}
</Text>
</Stack>
<Badge size="sm" variant="light">
<Badge
size="sm"
variant="light"
color={
entry.accessType === "EDIT"
? "orange"
: undefined
}
>
{entry.accessType === "VIEW"
? t("storageShare.viewed", "Viewed")
: entry.accessType === "DOWNLOAD"
? t("storageShare.downloaded", "Downloaded")
: t("storageShare.accessed", "Accessed")}
: entry.accessType === "EDIT"
? t("storageShare.edited", "Edited")
: t("storageShare.accessed", "Accessed")}
</Badge>
</Group>
</Paper>
@@ -2,12 +2,15 @@ import React, { useCallback, useEffect, useState } from "react";
import { Modal, Stack, Text, Group, Alert } from "@mantine/core";
import { Button } from "@app/ui/Button";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import FileDownloadIcon from "@mui/icons-material/FileDownload";
import { useTranslation } from "react-i18next";
import { alert } from "@app/components/toast";
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
import type { StirlingFileStub } from "@app/types/fileContext";
import { uploadHistoryChain } from "@app/services/serverStorageUpload";
import { SharedFileConflictError } from "@app/services/sharedFileSave";
import { useSharedFileActions } from "@app/hooks/useSharedFileActions";
import { fileStorage } from "@app/services/fileStorage";
import { useFileActions } from "@app/contexts/FileContext";
import type { FileId } from "@app/types/file";
@@ -27,64 +30,87 @@ const UploadToServerModal: React.FC<UploadToServerModalProps> = ({
}) => {
const { t } = useTranslation();
const { actions } = useFileActions();
const { fetchLatestCopy } = useSharedFileActions();
const [isUploading, setIsUploading] = useState(false);
const [hasConflict, setHasConflict] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
if (!opened) {
setIsUploading(false);
setHasConflict(false);
setErrorMessage(null);
}
}, [opened]);
const handleUpload = useCallback(async () => {
const handleUpload = useCallback(
async (force = false) => {
setIsUploading(true);
setErrorMessage(null);
try {
const originalFileId = (file.originalFileId || file.id) as FileId;
const remoteId = file.remoteStorageId;
const {
remoteId: storedId,
updatedAt,
version,
chain,
} = await uploadHistoryChain(originalFileId, remoteId, {
baseVersion: file.remoteVersionBase,
force,
});
for (const stub of chain) {
const updates = {
remoteStorageId: storedId,
remoteStorageUpdatedAt: updatedAt,
remoteOwnedByCurrentUser: true,
remoteVersionBase: version,
remoteVersionLatest: version,
};
actions.updateStirlingFileStub(stub.id, updates);
await fileStorage.updateFileMetadata(stub.id, updates);
}
alert({
alertType: "success",
title: t("storageUpload.success", "Uploaded to server"),
expandable: false,
durationMs: 3000,
});
if (onUploaded) {
await onUploaded();
}
onClose();
} catch (error) {
if (error instanceof SharedFileConflictError) {
setHasConflict(true);
} else {
console.error("Failed to upload file to server:", error);
setErrorMessage(
t(
"storageUpload.failure",
"Upload failed. Please check your login and storage settings.",
),
);
}
} finally {
setIsUploading(false);
}
},
[actions, file, onClose, onUploaded, t],
);
const handleGetLatestCopy = useCallback(async () => {
setIsUploading(true);
setErrorMessage(null);
try {
const originalFileId = (file.originalFileId || file.id) as FileId;
const remoteId = file.remoteStorageId;
const {
remoteId: storedId,
updatedAt,
chain,
} = await uploadHistoryChain(originalFileId, remoteId);
for (const stub of chain) {
actions.updateStirlingFileStub(stub.id, {
remoteStorageId: storedId,
remoteStorageUpdatedAt: updatedAt,
remoteOwnedByCurrentUser: true,
});
await fileStorage.updateFileMetadata(stub.id, {
remoteStorageId: storedId,
remoteStorageUpdatedAt: updatedAt,
remoteOwnedByCurrentUser: true,
});
}
alert({
alertType: "success",
title: t("storageUpload.success", "Uploaded to server"),
expandable: false,
durationMs: 3000,
});
if (onUploaded) {
await onUploaded();
}
const ok = await fetchLatestCopy(file);
setIsUploading(false);
if (ok) {
onClose();
} catch (error) {
console.error("Failed to upload file to server:", error);
setErrorMessage(
t(
"storageUpload.failure",
"Upload failed. Please check your login and storage settings.",
),
);
} finally {
setIsUploading(false);
}
}, [actions, file, onClose, onUploaded, t]);
}, [fetchLatestCopy, file, onClose]);
return (
<Modal
@@ -93,23 +119,43 @@ const UploadToServerModal: React.FC<UploadToServerModalProps> = ({
centered
title={t("storageUpload.title", "Upload to Server")}
zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL}
size={hasConflict ? "lg" : "md"}
>
<Stack gap="sm">
<Text size="sm">
{t(
"storageUpload.description",
"This uploads the current file to server storage for your own access.",
)}
</Text>
<Text size="sm" c="dimmed">
{t("storageUpload.fileLabel", "File")}: {file.name}
</Text>
<Text size="xs" c="dimmed">
{t(
"storageUpload.hint",
"Public links and access modes are controlled by your server settings.",
)}
</Text>
{!hasConflict && (
<>
<Text size="sm">
{t(
"storageUpload.description",
"This uploads the current file to server storage for your own access.",
)}
</Text>
<Text size="sm" c="dimmed">
{t("storageUpload.fileLabel", "File")}: {file.name}
</Text>
<Text size="xs" c="dimmed">
{t(
"storageUpload.hint",
"Public links and access modes are controlled by your server settings.",
)}
</Text>
</>
)}
{hasConflict && (
<Alert
color="yellow"
title={t(
"storageCollab.conflictTitle",
"This file changed on the server",
)}
>
{t(
"storageCollab.conflictBody",
"Someone else saved a newer version since you last synced. You can fetch their version to merge manually, or overwrite it with yours.",
)}
</Alert>
)}
{errorMessage && (
<Alert
@@ -124,15 +170,36 @@ const UploadToServerModal: React.FC<UploadToServerModalProps> = ({
<Button variant="secondary" onClick={onClose} disabled={isUploading}>
{t("cancel", "Cancel")}
</Button>
<Button
leftSection={<CloudUploadIcon style={{ fontSize: 18 }} />}
onClick={handleUpload}
loading={isUploading}
>
{file.remoteStorageId
? t("storageUpload.updateButton", "Update on Server")
: t("storageUpload.uploadButton", "Upload to Server")}
</Button>
{hasConflict ? (
<>
<Button
variant="secondary"
leftSection={<FileDownloadIcon style={{ fontSize: 18 }} />}
onClick={() => void handleGetLatestCopy()}
loading={isUploading}
>
{t("storageCollab.getLatest", "Get latest version")}
</Button>
<Button
accent="danger"
leftSection={<CloudUploadIcon style={{ fontSize: 18 }} />}
onClick={() => void handleUpload(true)}
loading={isUploading}
>
{t("storageCollab.overwrite", "Overwrite anyway")}
</Button>
</>
) : (
<Button
leftSection={<CloudUploadIcon style={{ fontSize: 18 }} />}
onClick={() => void handleUpload(false)}
loading={isUploading}
>
{file.remoteStorageId
? t("storageUpload.updateButton", "Update on Server")
: t("storageUpload.uploadButton", "Upload to Server")}
</Button>
)}
</Group>
</Stack>
</Modal>
@@ -0,0 +1,109 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { alert } from "@app/components/toast";
import type { FileId } from "@app/types/file";
import type { StirlingFileStub } from "@app/types/fileContext";
import {
downloadSharedBytes,
fetchLatestSharedVersion,
} from "@app/services/sharedFileSave";
import { fileStorage } from "@app/services/fileStorage";
import { useFileActions } from "@app/contexts/FileContext";
/** True when the server holds a newer revision than the local bytes derive from. */
export function hasNewerSharedVersion(file: StirlingFileStub): boolean {
return (
typeof file.remoteVersionLatest === "number" &&
typeof file.remoteVersionBase === "number" &&
file.remoteVersionLatest > file.remoteVersionBase
);
}
/** True when this stub is a shared file the current user may write back to. */
export function canEditSharedFile(file: StirlingFileStub): boolean {
const isSharedWithUser =
file.remoteOwnedByCurrentUser === false ||
Boolean(file.remoteSharedViaLink);
const hasServerRef = Boolean(file.remoteStorageId || file.remoteShareToken);
const role = (file.remoteAccessRole ?? "viewer").toLowerCase();
return (
isSharedWithUser &&
hasServerRef &&
file.remoteOwnedByCurrentUser !== true &&
role === "editor"
);
}
// Pulls the latest server revision of a shared file in as a fresh local copy
// tagged with the same remote linkage.
export function useSharedFileActions() {
const { t } = useTranslation();
const { actions } = useFileActions();
const fetchLatestCopy = useCallback(
async (file: StirlingFileStub): Promise<boolean> => {
try {
const blob = await downloadSharedBytes(file);
const latestVersion = await fetchLatestSharedVersion(file);
const latest = new File([blob], file.name, {
type: blob.type || file.type,
});
const added = await actions.addFilesWithOptions([latest], {
selectFiles: true,
autoUnzip: false,
skipAutoUnzip: true,
allowDuplicates: true,
});
for (const entry of added) {
const updates = {
remoteStorageId: file.remoteStorageId,
remoteStorageUpdatedAt: Date.now(),
remoteOwnerUsername: file.remoteOwnerUsername,
remoteOwnedByCurrentUser: file.remoteOwnedByCurrentUser,
remoteAccessRole: file.remoteAccessRole,
remoteSharedViaLink: file.remoteSharedViaLink,
remoteShareToken: file.remoteShareToken,
remoteVersionBase: latestVersion ?? undefined,
remoteVersionLatest: latestVersion ?? undefined,
};
actions.updateStirlingFileStub(entry.fileId as FileId, updates);
await fileStorage.updateFileMetadata(entry.fileId as FileId, updates);
}
// The old copy no longer tracks the server head; keep it as a plain
// local fork so two entries don't both claim the same remote file.
const forkUpdates = {
remoteVersionLatest: file.remoteVersionBase,
};
actions.updateStirlingFileStub(file.id, forkUpdates);
await fileStorage.updateFileMetadata(file.id, forkUpdates);
alert({
alertType: "success",
title: t("storageCollab.latestFetched", "Latest version added"),
body: t(
"storageCollab.latestFetchedBody",
"The newest shared version was added next to your copy so you can merge your changes.",
),
expandable: false,
durationMs: 5000,
});
return true;
} catch (error) {
console.error("Failed to fetch latest shared version:", error);
alert({
alertType: "warning",
title: t(
"storageCollab.fetchLatestFailed",
"Unable to fetch the latest shared version.",
),
expandable: false,
durationMs: 4000,
});
return false;
}
},
[actions, t],
);
return { fetchLatestCopy };
}
@@ -141,6 +141,8 @@ class FileStorageService {
remoteSharedViaLink: stub.remoteSharedViaLink,
remoteHasShareLinks: stub.remoteHasShareLinks,
remoteShareToken: stub.remoteShareToken,
remoteVersionBase: stub.remoteVersionBase,
remoteVersionLatest: stub.remoteVersionLatest,
// History data from stub
versionNumber: stub.versionNumber ?? 1,
@@ -271,6 +273,8 @@ class FileStorageService {
remoteSharedViaLink: record.remoteSharedViaLink,
remoteHasShareLinks: record.remoteHasShareLinks,
remoteShareToken: record.remoteShareToken,
remoteVersionBase: record.remoteVersionBase,
remoteVersionLatest: record.remoteVersionLatest,
versionNumber: record.versionNumber,
originalFileId: record.originalFileId,
parentFileId: record.parentFileId,
@@ -331,6 +335,8 @@ class FileStorageService {
remoteSharedViaLink: record.remoteSharedViaLink,
remoteHasShareLinks: record.remoteHasShareLinks,
remoteShareToken: record.remoteShareToken,
remoteVersionBase: record.remoteVersionBase,
remoteVersionLatest: record.remoteVersionLatest,
versionNumber: record.versionNumber || 1,
originalFileId: record.originalFileId || record.id,
parentFileId: record.parentFileId,
@@ -423,6 +429,8 @@ class FileStorageService {
remoteSharedViaLink: record.remoteSharedViaLink,
remoteHasShareLinks: record.remoteHasShareLinks,
remoteShareToken: record.remoteShareToken,
remoteVersionBase: record.remoteVersionBase,
remoteVersionLatest: record.remoteVersionLatest,
versionNumber: record.versionNumber || 1,
originalFileId: record.originalFileId || record.id,
parentFileId: record.parentFileId,
@@ -43,6 +43,7 @@ interface StoredFileResponse {
owner?: string | null;
ownedByCurrentUser?: boolean;
accessRole?: string | null;
version?: number | null;
shareLinks?: Array<{ token?: string | null }>;
sharedUsers?: Array<{ username?: string | null }>;
sharedWithUsers?: string[];
@@ -56,6 +57,8 @@ interface AccessedShareLinkResponse {
fileName?: string | null;
owner?: string | null;
ownedByCurrentUser?: boolean;
accessRole?: string | null;
version?: number | null;
createdAt?: string | null;
lastAccessedAt?: string | null;
}
@@ -199,6 +202,10 @@ export async function reconcileServerFiles(
typeof updatedAtMs === "number" && Number.isFinite(updatedAtMs)
? updatedAtMs
: stub.remoteStorageUpdatedAt,
remoteVersionLatest:
typeof serverFile.version === "number"
? serverFile.version
: stub.remoteVersionLatest,
// Server is authoritative for cloud-stored files. Don't fall back to
// stub.folderId on null - that would resurrect a stale folder pointer
// after the server SET_NULL'd it (e.g. owner deleted the folder).
@@ -243,6 +250,8 @@ export async function reconcileServerFiles(
? file.ownedByCurrentUser
: undefined,
remoteAccessRole: file.accessRole ?? undefined,
remoteVersionLatest:
typeof file.version === "number" ? file.version : undefined,
remoteSharedViaLink: false,
remoteHasShareLinks: Boolean(file.shareLinks?.length),
remoteHasUserShares: Boolean(
@@ -361,6 +370,9 @@ export async function reconcileServerFiles(
remoteStorageUpdatedAt: lastModified,
remoteOwnerUsername: link.owner ?? undefined,
remoteOwnedByCurrentUser: false,
remoteAccessRole: link.accessRole ?? undefined,
remoteVersionLatest:
typeof link.version === "number" ? link.version : undefined,
remoteSharedViaLink: true,
remoteHasShareLinks: false,
remoteShareToken: link.shareToken,
@@ -475,6 +487,9 @@ export async function materializeServerStubs(
remoteOwnerUsername: stub.remoteOwnerUsername,
remoteOwnedByCurrentUser: stub.remoteOwnedByCurrentUser,
remoteAccessRole: stub.remoteAccessRole,
// Bytes just came from the server head, so base == latest here.
remoteVersionBase: stub.remoteVersionLatest,
remoteVersionLatest: stub.remoteVersionLatest,
remoteSharedViaLink: isSharedStub ? true : false,
remoteHasShareLinks: stub.remoteHasShareLinks,
remoteShareToken: isSharedStub ? stub.remoteShareToken : undefined,
@@ -4,9 +4,16 @@ import {
buildHistoryBundle,
buildSharePackage,
} from "@app/services/serverStorageBundle";
import { SharedFileConflictError } from "@app/services/sharedFileSave";
import type { FileId } from "@app/types/file";
import type { StirlingFileStub } from "@app/types/fileContext";
export interface UploadChainOptions {
// Optimistic-concurrency baseline; when set the server 409s if it moved on.
baseVersion?: number;
force?: boolean;
}
function resolveUpdatedAt(value: unknown): number {
if (!value) {
return Date.now();
@@ -18,10 +25,56 @@ function resolveUpdatedAt(value: unknown): number {
return Number.isFinite(parsed) ? parsed : Date.now();
}
function resolveVersion(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value)
? value
: undefined;
}
function buildUpdateHeaders(
options: UploadChainOptions | undefined,
): Record<string, string> {
if (options?.force || typeof options?.baseVersion !== "number") {
return {};
}
return { "If-Match": `"${options.baseVersion}"` };
}
async function putExistingFile(
existingRemoteId: number,
formData: FormData,
options: UploadChainOptions | undefined,
): Promise<{ updatedAt: number; version?: number }> {
try {
const response = await apiClient.put(
`/api/v1/storage/files/${existingRemoteId}`,
formData,
{ headers: buildUpdateHeaders(options), suppressErrorToast: true } as any,
);
return {
updatedAt: resolveUpdatedAt(response.data?.updatedAt),
version: resolveVersion(response.data?.version),
};
} catch (error) {
const status = (error as { response?: { status?: number } })?.response
?.status;
if (status === 409) {
throw new SharedFileConflictError();
}
throw error;
}
}
export async function uploadHistoryChain(
originalFileId: FileId,
existingRemoteId?: number,
): Promise<{ remoteId: number; updatedAt: number; chain: StirlingFileStub[] }> {
options?: UploadChainOptions,
): Promise<{
remoteId: number;
updatedAt: number;
version?: number;
chain: StirlingFileStub[];
}> {
const chain = await fileStorage.getHistoryChainStubs(originalFileId);
if (chain.length === 0) {
throw new Error("No history chain found.");
@@ -52,12 +105,12 @@ export async function uploadHistoryChain(
formData.append("auditLog", auditLog, auditLog.name);
if (existingRemoteId) {
const response = await apiClient.put(
`/api/v1/storage/files/${existingRemoteId}`,
const { updatedAt, version } = await putExistingFile(
existingRemoteId,
formData,
options,
);
const updatedAt = resolveUpdatedAt(response.data?.updatedAt);
return { remoteId: existingRemoteId, updatedAt, chain };
return { remoteId: existingRemoteId, updatedAt, version, chain };
}
const response = await apiClient.post("/api/v1/storage/files", formData);
@@ -67,13 +120,24 @@ export async function uploadHistoryChain(
}
const updatedAt = resolveUpdatedAt(response.data?.updatedAt);
return { remoteId, updatedAt, chain };
return {
remoteId,
updatedAt,
version: resolveVersion(response.data?.version),
chain,
};
}
export async function uploadHistoryChains(
originalFileIds: FileId[],
existingRemoteId?: number,
): Promise<{ remoteId: number; updatedAt: number; chain: StirlingFileStub[] }> {
options?: UploadChainOptions,
): Promise<{
remoteId: number;
updatedAt: number;
version?: number;
chain: StirlingFileStub[];
}> {
const uniqueRoots = Array.from(new Set(originalFileIds));
const chainMap = new Map<FileId, StirlingFileStub[]>();
const combinedChain: StirlingFileStub[] = [];
@@ -129,12 +193,17 @@ export async function uploadHistoryChains(
formData.append("auditLog", auditLog, auditLog.name);
if (existingRemoteId) {
const response = await apiClient.put(
`/api/v1/storage/files/${existingRemoteId}`,
const { updatedAt, version } = await putExistingFile(
existingRemoteId,
formData,
options,
);
const updatedAt = resolveUpdatedAt(response.data?.updatedAt);
return { remoteId: existingRemoteId, updatedAt, chain: combinedChain };
return {
remoteId: existingRemoteId,
updatedAt,
version,
chain: combinedChain,
};
}
const response = await apiClient.post("/api/v1/storage/files", formData);
@@ -144,5 +213,10 @@ export async function uploadHistoryChains(
}
const updatedAt = resolveUpdatedAt(response.data?.updatedAt);
return { remoteId, updatedAt, chain: combinedChain };
return {
remoteId,
updatedAt,
version: resolveVersion(response.data?.version),
chain: combinedChain,
};
}
@@ -0,0 +1,136 @@
// Write-back for files shared WITH the current user (editor role). Sends only
// main bytes (owner's history/audit stay untouched); If-Match guards concurrency.
import apiClient from "@app/services/apiClient";
import { fileStorage } from "@app/services/fileStorage";
import type { FileId } from "@app/types/file";
import type { StirlingFileStub } from "@app/types/fileContext";
/** Thrown when the server copy moved on since our bytes were fetched (HTTP 409). */
export class SharedFileConflictError extends Error {
constructor() {
super("Shared file was modified by someone else");
this.name = "SharedFileConflictError";
}
}
export interface SharedSaveResult {
version?: number;
updatedAt: number;
}
function resolveUpdatedAt(value: unknown): number {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (value) {
const parsed = new Date(String(value)).getTime();
if (Number.isFinite(parsed)) return parsed;
}
return Date.now();
}
async function resolveLeafFile(stub: StirlingFileStub): Promise<File> {
const originalFileId = (stub.originalFileId || stub.id) as FileId;
const chain = await fileStorage.getHistoryChainStubs(originalFileId);
const finalStub =
chain
.slice()
.reverse()
.find((entry) => entry.isLeaf !== false) ||
chain[chain.length - 1] ||
stub;
const finalFile = await fileStorage.getStirlingFile(finalStub.id);
if (!finalFile) {
throw new Error("Missing local file data for shared save.");
}
return finalFile;
}
function isConflict(error: unknown): boolean {
return (
(error as { response?: { status?: number } })?.response?.status === 409
);
}
// Save the local latest bytes back to the shared server file; force=true skips
// the version check (deliberate overwrite after a conflict).
export async function saveSharedFile(
stub: StirlingFileStub,
options?: { force?: boolean },
): Promise<SharedSaveResult> {
const file = await resolveLeafFile(stub);
const formData = new FormData();
formData.append("file", file, file.name);
const baseVersion = stub.remoteVersionBase;
const headers: Record<string, string> = {};
if (!options?.force && typeof baseVersion === "number") {
headers["If-Match"] = `"${baseVersion}"`;
}
const useToken = Boolean(stub.remoteSharedViaLink && stub.remoteShareToken);
const url = useToken
? `/api/v1/storage/share-links/${stub.remoteShareToken}`
: `/api/v1/storage/files/${stub.remoteStorageId}`;
if (!useToken && !stub.remoteStorageId) {
throw new Error("Shared file has no server reference.");
}
try {
const response = await apiClient.put(url, formData, {
headers,
suppressErrorToast: true,
} as any);
const version =
typeof response.data?.version === "number"
? response.data.version
: undefined;
return { version, updatedAt: resolveUpdatedAt(response.data?.updatedAt) };
} catch (error) {
if (isConflict(error)) {
throw new SharedFileConflictError();
}
throw error;
}
}
/** Download the current server bytes for a shared stub (token or file id path). */
export async function downloadSharedBytes(
stub: StirlingFileStub,
): Promise<Blob> {
const url =
stub.remoteSharedViaLink && stub.remoteShareToken
? `/api/v1/storage/share-links/${stub.remoteShareToken}`
: `/api/v1/storage/files/${stub.remoteStorageId}/download`;
const response = await apiClient.get(url, {
responseType: "blob",
suppressErrorToast: true,
} as any);
return response.data as Blob;
}
/** Latest server version for a shared stub; null when it can't be determined. */
export async function fetchLatestSharedVersion(
stub: StirlingFileStub,
): Promise<number | null> {
try {
if (stub.remoteSharedViaLink && stub.remoteShareToken) {
const response = await apiClient.get(
`/api/v1/storage/share-links/${stub.remoteShareToken}/metadata`,
{ suppressErrorToast: true, skipAuthRedirect: true } as any,
);
const version = response.data?.version;
return typeof version === "number" ? version : null;
}
if (stub.remoteStorageId) {
const response = await apiClient.get(
`/api/v1/storage/files/${stub.remoteStorageId}`,
{ suppressErrorToast: true, skipAuthRedirect: true } as any,
);
const version = response.data?.version;
return typeof version === "number" ? version : null;
}
} catch {
// Metadata refresh is best-effort; the save itself still conflict-checks.
}
return null;
}
@@ -0,0 +1,403 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page, Route } from "@playwright/test";
import path from "node:path";
import { DATABASE_CONFIGS } from "@app/services/indexedDBManager";
/** Walkthrough shots for the sharing-collaboration feature (save-back, conflicts, badges). */
interface SeedFile {
id: string;
name: string;
remoteStorageId: number | null;
ownedByCurrentUser?: boolean;
accessRole?: string;
versionBase?: number;
versionLatest?: number;
sharedViaLink?: boolean;
shareToken?: string | null;
hasShareLinks?: boolean;
}
function serverEntry(f: SeedFile) {
return {
id: f.remoteStorageId,
fileName: f.name,
contentType: "application/pdf",
sizeBytes: 1024,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
owner: f.ownedByCurrentUser === false ? "alice" : "testuser",
ownedByCurrentUser: f.ownedByCurrentUser !== false,
accessRole: f.accessRole ?? "editor",
version: f.versionLatest ?? f.versionBase ?? 0,
shareLinks: f.hasShareLinks ? [{ token: "tok-1" }] : [],
sharedUsers: [],
filePurpose: "generic",
folderId: null,
};
}
async function seedFiles(page: Page, files: SeedFile[]): Promise<void> {
const serverFiles = files
.filter((f) => f.remoteStorageId != null)
.map(serverEntry);
await page.route("**/api/v1/storage/files", (route: Route) =>
route.fulfill({ json: serverFiles }),
);
await page.addInitScript(
({ records, dbVersion }) => {
const open = window.indexedDB.open("stirling-pdf-files", dbVersion);
open.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains("files")) {
const store = db.createObjectStore("files", { keyPath: "id" });
store.createIndex("name", "name", { unique: false });
store.createIndex("folderId", "folderId", { unique: false });
store.createIndex("originalFileId", "originalFileId", {
unique: false,
});
}
if (!db.objectStoreNames.contains("folders")) {
const fStore = db.createObjectStore("folders", { keyPath: "id" });
fStore.createIndex("parentFolderId", "parentFolderId", {
unique: false,
});
fStore.createIndex("name", "name", { unique: false });
}
};
open.onsuccess = () => {
const db = open.result;
db.onversionchange = () => db.close();
const tx = db.transaction("files", "readwrite");
const store = tx.objectStore("files");
const now = Date.now();
for (const f of records) {
store.put({
id: f.id,
fileId: f.id,
quickKey: f.id,
name: f.name,
type: "application/pdf",
size: 1024,
lastModified: now,
createdAt: now,
data: new ArrayBuffer(8),
thumbnail: null,
isLeaf: true,
versionNumber: 1,
originalFileId: f.id,
parentFileId: null,
toolHistory: [],
folderId: null,
remoteStorageId: f.remoteStorageId,
remoteStorageUpdatedAt: f.remoteStorageId ? now : null,
remoteOwnerUsername: f.remoteStorageId
? f.ownedByCurrentUser === false
? "alice"
: "testuser"
: null,
remoteOwnedByCurrentUser: f.remoteStorageId
? (f.ownedByCurrentUser ?? true)
: null,
remoteAccessRole: f.remoteStorageId
? (f.accessRole ?? "editor")
: null,
remoteVersionBase: f.versionBase ?? null,
remoteVersionLatest: f.versionLatest ?? f.versionBase ?? null,
remoteSharedViaLink: f.sharedViaLink ?? false,
remoteHasShareLinks: f.hasShareLinks ?? false,
remoteShareToken: f.shareToken ?? null,
});
}
tx.oncomplete = () => db.close();
};
},
{ records: files, dbVersion: DATABASE_CONFIGS.FILES.version },
);
}
async function stubStorageApis(page: Page): Promise<void> {
const configPayload = {
appVersion: "test",
storageEnabled: true,
storageSharingEnabled: true,
storageShareLinksEnabled: true,
frontendUrl: "http://localhost:5173",
};
await page.route("**/api/v1/config/app-config", (route: Route) =>
route.fulfill({ json: configPayload }),
);
await page.route("**/api/v1/config", (route: Route) =>
route.fulfill({ json: configPayload }),
);
await page.route("**/api/v1/storage/**", (route: Route) =>
route.fulfill({ json: [] }),
);
}
const SCREENSHOTS_DIR = path.resolve(
process.cwd(),
"screenshots",
"sharing-collab",
);
function shotPath(name: string): string {
return path.join(SCREENSHOTS_DIR, `${name}.png`);
}
async function settle(page: Page, ms = 350): Promise<void> {
await page.waitForTimeout(ms);
}
async function enableDarkMode(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem("mantine-color-scheme", "dark");
localStorage.setItem("mantine-color-scheme-value", "dark");
});
await page.emulateMedia({ colorScheme: "dark" });
}
async function enableRtl(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem("i18nextLng", "ar-AR");
localStorage.setItem("stirling-language", "ar-AR");
localStorage.setItem("stirling-language-source", "user");
const applyDir = () => {
document.documentElement.setAttribute("dir", "rtl");
document.documentElement.setAttribute("lang", "ar-AR");
};
if (document.documentElement) applyDir();
else document.addEventListener("DOMContentLoaded", applyDir);
});
}
const GRID_FILES: SeedFile[] = [
// Owned cloud file with an active share link.
{
id: "mine",
name: "my-report.pdf",
remoteStorageId: 1001,
ownedByCurrentUser: true,
accessRole: "editor",
versionBase: 2,
versionLatest: 2,
hasShareLinks: true,
},
// Shared with me as editor, in sync.
{
id: "shared-editor",
name: "team-budget.pdf",
remoteStorageId: 2001,
ownedByCurrentUser: false,
accessRole: "editor",
versionBase: 3,
versionLatest: 3,
},
// Shared with me as editor, server moved on (update available).
{
id: "shared-stale",
name: "contract-draft.pdf",
remoteStorageId: 2002,
ownedByCurrentUser: false,
accessRole: "editor",
versionBase: 1,
versionLatest: 4,
},
// Shared with me read-only.
{
id: "shared-viewer",
name: "signed-nda.pdf",
remoteStorageId: 2003,
ownedByCurrentUser: false,
accessRole: "viewer",
versionBase: 1,
versionLatest: 1,
},
];
async function gotoGrid(page: Page): Promise<void> {
await page.goto("/files", { waitUntil: "domcontentloaded" });
await expect(
page.locator(".files-page-card:not(.files-page-skeleton-card)").first(),
).toBeVisible({ timeout: 10_000 });
await settle(page);
}
function card(page: Page, name: string) {
return page
.locator(".files-page-card:not(.is-folder)")
.filter({ hasText: name });
}
async function openSaveToSharedModal(page: Page): Promise<void> {
await card(page, "team-budget.pdf").getByTestId("file-card-actions").click();
await page.getByTestId("file-menu-save-to-shared").click();
await expect(
page.getByRole("dialog", { name: /Save to Shared File/i }),
).toBeVisible();
await settle(page);
}
test.describe("Sharing collaboration walkthrough", () => {
test.use({
autoGoto: false,
viewport: { width: 1600, height: 900 },
seedJwt: true,
});
for (const theme of ["light", "dark"] as const) {
const dark = theme === "dark";
const prep = async (page: Page) => {
if (dark) await enableDarkMode(page);
await stubStorageApis(page);
await seedFiles(page, GRID_FILES);
};
test(`01_grid_badges_${theme}`, async ({ page }) => {
await prep(page);
await gotoGrid(page);
await expect(page.locator(".files-page-card-update-badge")).toBeVisible();
await page.screenshot({ path: shotPath(`01_grid_badges_${theme}`) });
});
test(`02_kebab_shared_editor_${theme}`, async ({ page }) => {
await prep(page);
await gotoGrid(page);
await card(page, "team-budget.pdf")
.getByTestId("file-card-actions")
.click();
await expect(page.getByTestId("file-menu-save-to-shared")).toBeVisible();
await settle(page);
await page.screenshot({
path: shotPath(`02_kebab_shared_editor_${theme}`),
});
});
test(`03_kebab_update_available_${theme}`, async ({ page }) => {
await prep(page);
await gotoGrid(page);
await card(page, "contract-draft.pdf")
.getByTestId("file-card-actions")
.click();
await expect(page.getByTestId("file-menu-get-latest")).toContainText(
/new/i,
);
await settle(page);
await page.screenshot({
path: shotPath(`03_kebab_update_available_${theme}`),
});
});
test(`04_kebab_shared_viewer_${theme}`, async ({ page }) => {
await prep(page);
await gotoGrid(page);
await card(page, "signed-nda.pdf")
.getByTestId("file-card-actions")
.click();
await expect(page.getByTestId("file-menu-get-latest")).toBeVisible();
await expect(
page.getByTestId("file-menu-save-to-shared"),
).not.toBeVisible();
await settle(page);
await page.screenshot({
path: shotPath(`04_kebab_shared_viewer_${theme}`),
});
});
test(`05_save_to_shared_modal_${theme}`, async ({ page }) => {
await prep(page);
await gotoGrid(page);
await openSaveToSharedModal(page);
await page.screenshot({
path: shotPath(`05_save_to_shared_modal_${theme}`),
});
});
test(`06_save_conflict_${theme}`, async ({ page }) => {
await prep(page);
// The write is rejected: someone else already bumped the version.
await page.route("**/api/v1/storage/files/2001", (route: Route) => {
if (route.request().method() === "PUT") {
return route.fulfill({
status: 409,
json: { status: 409, detail: "File was modified by someone else" },
});
}
return route.fulfill({ json: serverEntry(GRID_FILES[1]) });
});
await gotoGrid(page);
await openSaveToSharedModal(page);
await page.getByRole("button", { name: /Save changes/i }).click();
await expect(
page.getByText(/This file changed on the server/i),
).toBeVisible({ timeout: 5_000 });
await settle(page);
await page.screenshot({ path: shotPath(`06_save_conflict_${theme}`) });
});
test(`07_share_activity_edited_${theme}`, async ({ page }) => {
await prep(page);
await page.route("**/api/v1/storage/files/1001", (route: Route) =>
route.fulfill({ json: serverEntry(GRID_FILES[0]) }),
);
await page.route(
"**/api/v1/storage/files/1001/shares/links/tok-1/accesses",
(route: Route) =>
route.fulfill({
json: [
{
username: "bob",
accessType: "EDIT",
accessedAt: new Date().toISOString(),
},
{
username: "bob",
accessType: "VIEW",
accessedAt: new Date(Date.now() - 3600_000).toISOString(),
},
],
}),
);
await gotoGrid(page);
await card(page, "my-report.pdf").click();
await expect(page.locator(".files-page-details")).toBeVisible();
await page.getByRole("button", { name: /Manage sharing/i }).click();
await expect(
page.getByRole("dialog", { name: /Manage Sharing/i }),
).toBeVisible();
await expect(page.getByText(/^Edited$/).first()).toBeVisible({
timeout: 5_000,
});
await settle(page);
await page.screenshot({
path: shotPath(`07_share_activity_edited_${theme}`),
});
});
}
// ─── RTL spot checks ──────────────────────────────────────────────────────
test("08_rtl_grid_badges", async ({ page }) => {
await enableRtl(page);
await stubStorageApis(page);
await seedFiles(page, GRID_FILES);
await gotoGrid(page);
await expect(page.locator(".files-page-card-update-badge")).toBeVisible();
await page.screenshot({ path: shotPath("08_rtl_grid_badges_light") });
});
test("09_rtl_save_to_shared_modal", async ({ page }) => {
await enableRtl(page);
await stubStorageApis(page);
await seedFiles(page, GRID_FILES);
await gotoGrid(page);
await card(page, "team-budget.pdf")
.getByTestId("file-card-actions")
.click();
await page.getByTestId("file-menu-save-to-shared").click();
await expect(page.getByRole("dialog")).toBeVisible();
await settle(page);
await page.screenshot({
path: shotPath("09_rtl_save_to_shared_modal_light"),
});
});
});
+2
View File
@@ -75,6 +75,8 @@ export interface BaseFileMetadata {
// Remote storage tracking
remoteStorageId?: number; // Server-side storage ID for this file chain
remoteStorageUpdatedAt?: number; // Timestamp when chain was last uploaded
remoteVersionBase?: number; // Server content version our local bytes derive from
remoteVersionLatest?: number; // Newest server content version seen during sync
remoteOwnerUsername?: string; // Server-side owner username (if known)
remoteOwnedByCurrentUser?: boolean; // Ownership flag for server files
remoteAccessRole?: string; // Access role for shared server files
@@ -18,6 +18,7 @@ export interface ShareLinkMetadata {
owner?: string | null;
ownedByCurrentUser?: boolean;
accessRole?: string | null;
version?: number | null;
createdAt?: string;
expiresAt?: string;
}
@@ -97,6 +98,14 @@ export async function importShareLinkToWorkbench(
remoteSharedViaLink: true,
remoteHasShareLinks: false,
remoteShareToken: shareMetadata?.shareToken || token,
remoteVersionBase:
typeof shareMetadata?.version === "number"
? shareMetadata.version
: undefined,
remoteVersionLatest:
typeof shareMetadata?.version === "number"
? shareMetadata.version
: undefined,
};
for (const entry of sortedEntries) {
@@ -160,6 +169,14 @@ export async function importShareLinkToWorkbench(
remoteSharedViaLink: true,
remoteHasShareLinks: false,
remoteShareToken: shareMetadata?.shareToken || token,
remoteVersionBase:
typeof shareMetadata?.version === "number"
? shareMetadata.version
: undefined,
remoteVersionLatest:
typeof shareMetadata?.version === "number"
? shareMetadata.version
: undefined,
};
for (const fileId of ids) {
actions.updateStirlingFileStub(fileId, sharedUpdates);