mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Add collaborative editing to shared files with version conflict detection
This commit is contained in:
+29
-2
@@ -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)
|
||||
|
||||
+53
-2
@@ -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
-1
@@ -2,5 +2,6 @@ package stirling.software.proprietary.storage.model;
|
||||
|
||||
public enum FileShareAccessType {
|
||||
VIEW,
|
||||
DOWNLOAD
|
||||
DOWNLOAD,
|
||||
EDIT
|
||||
}
|
||||
|
||||
+10
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -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;
|
||||
|
||||
+3
@@ -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;
|
||||
|
||||
+15
@@ -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);
|
||||
}
|
||||
|
||||
+129
-11
@@ -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())
|
||||
|
||||
+27
-2
@@ -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
|
||||
|
||||
+180
@@ -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"),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user