Compare commits

...
Author SHA1 Message Date
Anthony Stirling 9608f4654e chore(tests): drop decorative divider comment flagged by comment-lint 2026-08-30 11:32:02 +01:00
Anthony Stirling d0e1e2289b fix(storage): guard owner re-uploads with If-Match and read the bumped version back 2026-08-30 10:46:26 +01:00
Anthony Stirling ac2c8f71ab Merge remote-tracking branch 'origin/main' into pdf-sharing-collaboration 2026-08-30 10:07:55 +01:00
Anthony Stirling 65a6da3212 Merge remote-tracking branch 'origin/main' into sweep/pr7218 2026-08-26 08:11:38 +01:00
Anthony Stirling 488984ac2f Merge remote-tracking branch 'origin/main' into sweep/pr7218 2026-08-26 07:12:59 +01:00
Anthony Stirling 8e220dd7b3 Merge branch 'main' into pdf-sharing-collaboration 2026-08-20 13:55:36 +01:00
Anthony Stirling a825b515b5 Persist blob cleanup retries in their own transaction 2026-08-13 12:24:53 +01:00
Anthony Stirling 4fd504e8ea Use a hyphen in the share upgrade note 2026-08-13 11:46:37 +01:00
Anthony Stirling 6854b46568 Make shared-file collaborative writes upgrade-safe and opt-in 2026-08-13 10:33:14 +01:00
Anthony Stirling de65fda91b Merge remote-tracking branch 'origin/main' into pdf-sharing-collaboration 2026-08-13 09:52:13 +01:00
Anthony Stirling 8d5d3972e2 Merge remote-tracking branch 'origin/main' into pdf-sharing-collaboration
# Conflicts:
#	app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/StoredFileRepository.java
2026-08-12 11:25:49 +01:00
Frooodle 247e43f4a0 Merge branch 'main' into pdf-sharing-collaboration 2026-08-05 10:05:22 +01:00
Frooodle 089e699263 Merge branch 'main' into pdf-sharing-collaboration 2026-08-02 01:41:01 +01:00
Anthony Stirling 77f45806c9 Add collaborative editing to shared files with version conflict detection 2026-07-31 07:49:58 +01:00
40 changed files with 2990 additions and 270 deletions
+46 -3
View File
@@ -48,7 +48,7 @@ The File Sharing feature enables users to store files server-side and share them
| Role | Can Read | Can Write |
|------|----------|-----------|
| `EDITOR` | ✅ | ✅ |
| `EDITOR` | ✅ | ✅ (when write was granted, see below) |
| `COMMENTER` | ✅ | ❌ |
| `VIEWER` | ✅ | ❌ |
@@ -56,6 +56,47 @@ 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`.
#### Write Is Opt-In Per Share
The `EDITOR` role alone does not grant write. `file_shares.write_enabled` records
the grant, and it is stamped `true` only when the owner creates or updates a share
with the `EDITOR` role. Shares that predate collaborative editing have the column
null and stay read-only after an upgrade - the owner must re-grant editor access to
make one writable. `StoredFileResponse.canEdit` and `ShareLinkMetadataResponse.canEdit`
expose the server's decision so the UI does not offer a save that would be rejected.
Non-owner updates replace only the main file content. The version history bundle and
the audit log belong to the owner, so a request from a non-owner that carries a
`historyBundle` or `auditLog` part is rejected with `403` rather than overwriting
(and deleting) the owner's archive.
A superseded blob is deleted only after the transaction commits, so a failed or
rolled-back update can never leave the file without its bytes.
### 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 +350,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 +361,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 +397,8 @@ 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; every non-owner content update requires an `EDITOR` share with `write_enabled` set
- Replacing the version history or audit log is owner-only
### Share Link Security
- Tokens are UUIDs (random, not guessable)
@@ -20,6 +20,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;
@@ -81,9 +82,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)
@@ -210,6 +213,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) {
@@ -242,6 +273,8 @@ public class FileStorageController {
share.getAccessRole() != null
? share.getAccessRole().name().toLowerCase(Locale.ROOT)
: null)
.canEdit(ownedByCurrentUser || fileStorageService.allowsWrite(share))
.version(file.contentVersionOrZero())
.createdAt(share.getCreatedAt())
.expiresAt(share.getExpiresAt())
.build();
@@ -297,9 +330,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()
@@ -0,0 +1,40 @@
package stirling.software.proprietary.storage.converter;
import java.util.Locale;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.storage.model.FileShareAccessType;
/**
* Persists the access type as plain text. {@code @Enumerated(STRING)} makes Hibernate emit a native
* enum column on H2/MySQL, which {@code ddl-auto=update} never widens, so every new constant would
* be unwritable on existing installs.
*/
@Converter
@Slf4j
public class FileShareAccessTypeConverter
implements AttributeConverter<FileShareAccessType, String> {
@Override
public String convertToDatabaseColumn(FileShareAccessType attribute) {
return attribute != null ? attribute.name() : null;
}
@Override
public FileShareAccessType convertToEntityAttribute(String dbData) {
if (dbData == null || dbData.isBlank()) {
return null;
}
try {
return FileShareAccessType.valueOf(dbData.trim().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException ex) {
// A row written by a newer version must not break the reader.
log.warn("Unknown file share access type {} in database", dbData);
return null;
}
}
}
@@ -69,6 +69,11 @@ public class FileShare implements Serializable {
@Column(name = "access_role")
private ShareAccessRole accessRole;
// Opt-in collaborative write. Null on shares that predate it, so upgrades stay read-only
// until the owner re-grants editor access.
@Column(name = "write_enabled")
private Boolean writeEnabled;
@Column(name = "expires_at")
private LocalDateTime expiresAt;
@@ -7,9 +7,8 @@ import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import jakarta.persistence.Column;
import jakarta.persistence.Convert;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
@@ -24,6 +23,7 @@ import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.storage.converter.FileShareAccessTypeConverter;
@Entity
@Table(
@@ -55,8 +55,9 @@ public class FileShareAccess implements Serializable {
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Enumerated(EnumType.STRING)
@Column(name = "access_type", nullable = false)
// Plain text, not @Enumerated: a native enum column cannot gain values on ddl-auto=update.
@Convert(converter = FileShareAccessTypeConverter.class)
@Column(name = "access_type", nullable = false, length = 32)
private FileShareAccessType accessType;
@CreationTimestamp
@@ -2,5 +2,6 @@ package stirling.software.proprietary.storage.model;
public enum FileShareAccessType {
VIEW,
DOWNLOAD
DOWNLOAD,
EDIT
}
@@ -132,6 +132,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;
@@ -139,4 +144,9 @@ public class StoredFile implements Serializable {
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
/** Null-safe content version; legacy rows without the column value read as 0. */
public long contentVersionOrZero() {
return contentVersion != null ? contentVersion : 0L;
}
}
@@ -14,6 +14,12 @@ public class ShareLinkMetadataResponse {
private final String owner;
private final boolean ownedByCurrentUser;
private final String accessRole;
// Whether the viewer may write back. Editor role alone is not enough on legacy shares.
private final boolean canEdit;
// Content revision for optimistic concurrency; clients echo it back on update.
private final Long version;
private final LocalDateTime createdAt;
private final LocalDateTime expiresAt;
private final LocalDateTime lastAccessedAt;
@@ -17,6 +17,12 @@ public class StoredFileResponse {
private final String owner;
private final boolean ownedByCurrentUser;
private final String accessRole;
// Whether this user may write back. Editor role alone is not enough on legacy shares.
private final boolean canEdit;
// 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;
@@ -75,6 +75,25 @@ public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
+ "(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);
// Scalar projection: reads the DB, not the stale first-level cache entity.
@Query("SELECT COALESCE(f.contentVersion, 0) FROM StoredFile f WHERE f.id = :id")
Long findContentVersionById(@Param("id") Long id);
// ---- storage encryption at rest ----------------------------------------------------
long countByEncryptionKeyIdIsNull();
@@ -20,6 +20,8 @@ import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
@@ -38,7 +40,6 @@ import stirling.software.proprietary.storage.model.FileShare;
import stirling.software.proprietary.storage.model.FileShareAccess;
import stirling.software.proprietary.storage.model.FileShareAccessType;
import stirling.software.proprietary.storage.model.ShareAccessRole;
import stirling.software.proprietary.storage.model.StorageCleanupEntry;
import stirling.software.proprietary.storage.model.StoredFile;
import stirling.software.proprietary.storage.model.api.ShareLinkAccessResponse;
import stirling.software.proprietary.storage.model.api.ShareLinkMetadataResponse;
@@ -49,7 +50,6 @@ import stirling.software.proprietary.storage.provider.StorageProvider;
import stirling.software.proprietary.storage.provider.StoredObject;
import stirling.software.proprietary.storage.repository.FileShareAccessRepository;
import stirling.software.proprietary.storage.repository.FileShareRepository;
import stirling.software.proprietary.storage.repository.StorageCleanupEntryRepository;
import stirling.software.proprietary.storage.repository.StoredFileRepository;
@Service
@@ -69,7 +69,7 @@ public class FileStorageService {
private final ApplicationProperties applicationProperties;
private final StorageProvider storageProvider;
private final Optional<EmailService> emailService;
private final StorageCleanupEntryRepository storageCleanupEntryRepository;
private final StorageCleanupQueue storageCleanupQueue;
public void ensureStorageEnabled() {
if (!applicationProperties.getSecurity().isEnableLogin()) {
@@ -176,20 +176,130 @@ 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");
boolean owner = isOwner(existing, actor);
if (!owner) {
// Collaborative write-back: shared EDITORs may replace content when sharing is on.
ensureSharingEnabled();
requireCollaborativeWriteAccess(
fileShareRepository.findByFileAndSharedWithUser(existing, actor).orElse(null));
}
return replaceFileContent(existing, file, historyBundle, auditLog, expectedVersion, owner);
}
/** 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();
requireCollaborativeWriteAccess(share);
return replaceFileContent(
share.getFile(), file, historyBundle, auditLog, expectedVersion, false);
}
// Write is opt-in per share: the editor role alone is not enough, because every share that
// predates collaborative editing would otherwise become writable on upgrade.
private void requireCollaborativeWriteAccess(FileShare share) {
if (share == null || resolveShareRole(share) != ShareAccessRole.EDITOR) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN, "Editor access is required to update this file");
}
if (!Boolean.TRUE.equals(share.getWriteEnabled())) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN,
"This share is read-only. The owner must re-grant editor access to enable"
+ " editing.");
}
}
public boolean allowsWrite(FileShare share) {
return share != null
&& Boolean.TRUE.equals(share.getWriteEnabled())
&& resolveShareRole(share) == ShareAccessRole.EDITOR;
}
private FileShare userShare(StoredFile file, User user) {
if (file == null || user == null) {
return null;
}
return fileShareRepository.findByFileAndSharedWithUser(file, user).orElse(null);
}
// 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)
.canEdit(ownedByCurrentUser || allowsWrite(share))
.version(updated.contentVersionOrZero())
.createdAt(share.getCreatedAt())
.expiresAt(share.getExpiresAt())
.build();
}
private StoredFile replaceFileContent(
StoredFile existing,
MultipartFile file,
MultipartFile historyBundle,
MultipartFile auditLog,
Long expectedVersion,
boolean ownerWrite) {
validateMainUpload(file);
// The version history and audit bundle are the owner's record of the file; a collaborator
// replacing them would destroy evidence they never owned.
if (!ownerWrite && (isValidUpload(historyBundle) || isValidUpload(auditLog))) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Only the owner can replace the version history or audit log");
}
// 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;
@@ -227,12 +337,12 @@ public class FileStorageService {
cleanupStoredObject(auditObject);
throw saveError;
}
cleanupStoredKey(oldStorageKey);
deleteAfterCommit(oldStorageKey);
if (historyObject != null) {
cleanupStoredKey(oldHistoryKey);
deleteAfterCommit(oldHistoryKey);
}
if (auditObject != null) {
cleanupStoredKey(oldAuditKey);
deleteAfterCommit(oldAuditKey);
}
return updated;
@@ -250,6 +360,31 @@ 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());
// Another writer may have bumped past us; the in-memory value would
// otherwise be flushed back over the SQL-computed one.
Long persisted = storedFileRepository.findContentVersionById(existing.getId());
newVersion = persisted != null ? persisted : existing.contentVersionOrZero() + 1;
}
existing.setContentVersion(newVersion);
return newVersion;
}
public StoredFile getAccessibleFile(User user, Long fileId) {
ensureStorageEnabled();
StoredFile file =
@@ -285,7 +420,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");
}
}
@@ -293,7 +428,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");
}
}
@@ -335,35 +470,55 @@ 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);
boolean nonOwnerWrite = !isOwner(existing, actor);
StoredFile updated =
replaceFile(actor, existing, file, historyBundle, auditLog, expectedVersion);
if (nonOwnerWrite) {
// Mirrors updateShareLinkResponse so both write paths are attributable.
recordShareAccess(
userShare(existing, actor),
SecurityContextHolder.getContext().getAuthentication(),
FileShareAccessType.EDIT);
}
return buildResponse(updated, actor);
}
public List<StoredFileResponse> listAccessibleFileResponses(User user) {
List<StoredFile> files = listAccessibleFiles(user);
Map<Long, ShareAccessRole> roleByFileId = new HashMap<>();
Map<Long, FileShare> shareByFileId = new HashMap<>();
if (!files.isEmpty()) {
List<FileShare> shares = fileShareRepository.findBySharedWithUserAndFileIn(user, files);
for (FileShare share : shares) {
StoredFile sharedFile = share.getFile();
if (sharedFile != null && sharedFile.getId() != null) {
roleByFileId.put(sharedFile.getId(), resolveShareRole(share));
shareByFileId.put(sharedFile.getId(), share);
}
}
}
return files.stream()
.sorted(Comparator.comparing(StoredFile::getCreatedAt).reversed())
.map(file -> buildResponse(file, user, roleByFileId.get(file.getId())))
.map(file -> buildResponse(file, user, shareByFileId.get(file.getId())))
.toList();
}
@@ -384,16 +539,24 @@ public class FileStorageService {
return buildResponse(file, currentUser, null);
}
// knownShare is the caller's already-loaded share for this user, so the list path stays a
// single bulk query instead of one lookup per file.
private StoredFileResponse buildResponse(
StoredFile file, User currentUser, ShareAccessRole accessRoleOverride) {
StoredFile file, User currentUser, FileShare knownShare) {
boolean ownedByCurrentUser =
file.getOwner() != null
&& Objects.equals(file.getOwner().getId(), currentUser.getId());
FileShare currentUserShare =
ownedByCurrentUser
? null
: Optional.ofNullable(knownShare)
.orElseGet(() -> userShare(file, currentUser));
String accessRole =
ownedByCurrentUser
? ShareAccessRole.EDITOR.name().toLowerCase(Locale.ROOT)
: Optional.ofNullable(accessRoleOverride)
.orElseGet(() -> resolveUserShareRole(file, currentUser))
: (currentUserShare != null
? resolveShareRole(currentUserShare)
: ShareAccessRole.VIEWER)
.name()
.toLowerCase(Locale.ROOT);
List<String> sharedWithUsers =
@@ -453,6 +616,8 @@ public class FileStorageService {
.owner(file.getOwner() != null ? file.getOwner().getUsername() : null)
.ownedByCurrentUser(ownedByCurrentUser)
.accessRole(accessRole)
.canEdit(ownedByCurrentUser || allowsWrite(currentUserShare))
.version(file.contentVersionOrZero())
.createdAt(file.getCreatedAt())
.updatedAt(file.getUpdatedAt())
.sharedWithUsers(sharedWithUsers)
@@ -526,7 +691,7 @@ public class FileStorageService {
}
storedFileRepository.delete(file);
for (String storageKey : storageKeys) {
cleanupStoredKey(storageKey);
deleteAfterCommit(storageKey);
}
}
@@ -555,6 +720,7 @@ public class FileStorageService {
.map(
existingShare -> {
existingShare.setAccessRole(role);
existingShare.setWriteEnabled(grantsWrite(role));
return fileShareRepository.save(existingShare);
})
.orElseGet(
@@ -563,6 +729,7 @@ public class FileStorageService {
newShare.setFile(file);
newShare.setSharedWithUser(targetUser);
newShare.setAccessRole(role);
newShare.setWriteEnabled(grantsWrite(role));
return fileShareRepository.save(newShare);
});
@@ -645,10 +812,16 @@ public class FileStorageService {
share.setFile(file);
share.setShareToken(UUID.randomUUID().toString());
share.setAccessRole(role);
share.setWriteEnabled(grantsWrite(role));
share.setExpiresAt(resolveShareLinkExpiration());
return fileShareRepository.save(share);
}
// Stamped at grant time so the decision is recorded on the share, not inferred later.
private boolean grantsWrite(ShareAccessRole role) {
return role == ShareAccessRole.EDITOR;
}
public void revokeShareLink(User owner, StoredFile file, String token) {
ensureStorageEnabled();
if (!isOwner(file, owner)) {
@@ -719,6 +892,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;
}
@@ -735,7 +916,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);
}
@@ -768,7 +949,10 @@ public class FileStorageService {
access.getUser() != null
? access.getUser().getUsername()
: null)
.accessType(access.getAccessType().name())
.accessType(
access.getAccessType() != null
? access.getAccessType().name()
: null)
.accessedAt(access.getAccessedAt())
.build())
.toList();
@@ -821,6 +1005,8 @@ public class FileStorageService {
.name()
.toLowerCase(Locale.ROOT)
: null)
.canEdit(allowsWrite(share))
.version(file != null ? file.contentVersionOrZero() : null)
.createdAt(share != null ? share.getCreatedAt() : null)
.expiresAt(share != null ? share.getExpiresAt() : null)
.lastAccessedAt(access.getAccessedAt())
@@ -1026,6 +1212,25 @@ public class FileStorageService {
cleanupStoredKey(storedObject.getStorageKey());
}
// Superseded blobs must outlive the transaction: a rollback restores the row that still
// points at them, so deleting inline would destroy the only copy of the file.
private void deleteAfterCommit(String storageKey) {
if (storageKey == null || storageKey.isBlank()) {
return;
}
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
cleanupStoredKey(storageKey);
return;
}
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
cleanupStoredKey(storageKey);
}
});
}
private void cleanupStoredKey(String storageKey) {
if (storageKey == null || storageKey.isBlank()) {
return;
@@ -1034,9 +1239,17 @@ public class FileStorageService {
storageProvider.delete(storageKey);
} catch (IOException e) {
log.warn("Failed to delete storage key {}. Scheduling cleanup.", storageKey, e);
StorageCleanupEntry entry = new StorageCleanupEntry();
entry.setStorageKey(storageKey);
storageCleanupEntryRepository.save(entry);
scheduleCleanup(storageKey);
}
}
// Reached from afterCommit, so a failure here must not turn an already-committed request into
// an error; the blob is then orphaned with only this log to find it by.
private void scheduleCleanup(String storageKey) {
try {
storageCleanupQueue.enqueue(storageKey);
} catch (RuntimeException e) {
log.error("Could not schedule cleanup for storage key {}", storageKey, e);
}
}
@@ -0,0 +1,29 @@
package stirling.software.proprietary.storage.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.storage.model.StorageCleanupEntry;
import stirling.software.proprietary.storage.repository.StorageCleanupEntryRepository;
// Separate bean on purpose: callers enqueue from afterCommit and from rollback compensation, where
// the caller's transaction can no longer flush, and REQUIRES_NEW only applies through the proxy.
@Service
@RequiredArgsConstructor
public class StorageCleanupQueue {
private final StorageCleanupEntryRepository cleanupEntryRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void enqueue(String storageKey) {
if (storageKey == null || storageKey.isBlank()) {
return;
}
StorageCleanupEntry entry = new StorageCleanupEntry();
entry.setStorageKey(storageKey);
cleanupEntryRepository.save(entry);
}
}
@@ -105,9 +105,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
@@ -0,0 +1,116 @@
package stirling.software.proprietary.storage.model;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.AutoConfigurationPackage;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import jakarta.persistence.EntityManager;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.model.User;
/**
* Runs against a real v2.10.0 user database, upgraded in place by ddl-auto=update, because the
* mocked service tests never touch a column definition. The v2.10.0 schema pins access_type to the
* enum values that existed then, so a new constant is unwritable on every upgraded install unless
* the mapping is plain text.
*/
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@DirtiesContext
class FileShareAccessTypeLegacySchemaDbTest {
private static Path databaseDir;
@Autowired private EntityManager entityManager;
@DynamicPropertySource
static void legacyDatabase(DynamicPropertyRegistry registry) throws Exception {
databaseDir = Files.createTempDirectory("legacy-share-schema");
Path database = databaseDir.resolve("legacy.mv.db");
try (InputStream fixture =
FileShareAccessTypeLegacySchemaDbTest.class.getResourceAsStream(
"/db-migration-fixtures/stirling-pdf-v2.10.0.mv.db")) {
Files.copy(fixture, database);
}
String path = database.toString().replace('\\', '/');
String base = path.substring(0, path.length() - ".mv.db".length());
registry.add(
"spring.datasource.url",
() -> "jdbc:h2:file:" + base + ";DB_CLOSE_DELAY=-1;MODE=PostgreSQL");
registry.add("spring.datasource.username", () -> "sa");
registry.add("spring.datasource.password", () -> "");
registry.add("spring.datasource.driver-class-name", () -> "org.h2.Driver");
// Production upgrade strategy; it adds columns but never rewrites an existing one.
registry.add("spring.jpa.hibernate.ddl-auto", () -> "update");
}
@Test
void editAccessTypeIsWritableAgainstAPreEditSchema() {
Team team = new Team();
team.setName("team-" + UUID.randomUUID());
entityManager.persist(team);
User owner = new User();
owner.setUsername("owner-" + UUID.randomUUID());
owner.setPassword("x");
owner.setTeam(team);
entityManager.persist(owner);
StoredFile file = new StoredFile();
file.setOwner(owner);
file.setOriginalFilename("doc.pdf");
file.setContentType("application/pdf");
file.setSizeBytes(1);
file.setStorageKey("k-" + UUID.randomUUID());
entityManager.persist(file);
FileShare share = new FileShare();
share.setFile(file);
share.setSharedWithUser(owner);
share.setShareToken(UUID.randomUUID().toString());
share.setAccessRole(ShareAccessRole.EDITOR);
entityManager.persist(share);
FileShareAccess access = new FileShareAccess();
access.setFileShare(share);
access.setUser(owner);
access.setAccessType(FileShareAccessType.EDIT);
entityManager.persist(access);
entityManager.flush();
entityManager.clear();
FileShareAccess reloaded = entityManager.find(FileShareAccess.class, access.getId());
assertThat(reloaded.getAccessType()).isEqualTo(FileShareAccessType.EDIT);
}
@Test
void accessTypeColumnIsNoLongerANativeEnum() {
Object dataType =
entityManager
.createNativeQuery(
"SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS"
+ " WHERE TABLE_NAME = 'FILE_SHARE_ACCESSES'"
+ " AND COLUMN_NAME = 'ACCESS_TYPE'")
.getSingleResult();
assertThat(String.valueOf(dataType)).isNotEqualTo("ENUM");
}
@SpringBootConfiguration
@AutoConfigurationPackage(basePackages = "stirling.software.proprietary")
static class TestApp {}
}
@@ -142,6 +142,64 @@ class StoredFileMigrationQueriesDbTest {
.containsExactly(file.getId());
}
@Test
void bumpContentVersionIfMatches_rejectsStaleExpectationAndIncrementsOnMatch() {
StoredFile file = persistFile("k-version", null);
entityManager.clear();
// Legacy rows have no version at all; 0 is the expectation that matches them.
assertThat(repository.bumpContentVersionIfMatches(file.getId(), 3L)).isZero();
assertThat(repository.bumpContentVersionIfMatches(file.getId(), 0L)).isEqualTo(1);
entityManager.clear();
assertThat(repository.findById(file.getId()).orElseThrow().getContentVersion())
.isEqualTo(1L);
// The expectation that just won is now stale, so a replayed save must lose.
assertThat(repository.bumpContentVersionIfMatches(file.getId(), 0L)).isZero();
assertThat(repository.bumpContentVersionIfMatches(file.getId(), 1L)).isEqualTo(1);
entityManager.clear();
assertThat(repository.findById(file.getId()).orElseThrow().getContentVersion())
.isEqualTo(2L);
}
@Test
void bumpContentVersion_incrementsLegacyNullVersion() {
StoredFile file = persistFile("k-unconditional", null);
entityManager.clear();
assertThat(repository.bumpContentVersion(file.getId())).isEqualTo(1);
entityManager.clear();
assertThat(repository.findById(file.getId()).orElseThrow().getContentVersion())
.isEqualTo(1L);
}
@Test
void findContentVersionById_readsTheBumpedValueNotTheCachedEntity() {
StoredFile file = persistFile("k-projection", null);
file.setContentVersion(5L);
entityManager.merge(file);
entityManager.flush();
entityManager.clear();
StoredFile loaded = repository.findById(file.getId()).orElseThrow();
repository.bumpContentVersion(loaded.getId());
repository.bumpContentVersion(loaded.getId());
// The entity loaded before the bulk update stays stale; the projection reads the DB.
assertThat(loaded.getContentVersion()).isEqualTo(5L);
assertThat(repository.findContentVersionById(loaded.getId())).isEqualTo(7L);
}
@Test
void findContentVersionById_legacyNullVersionReadsAsZero() {
StoredFile file = persistFile("k-projection-legacy", null);
entityManager.clear();
assertThat(repository.findContentVersionById(file.getId())).isEqualTo(0L);
}
@SpringBootConfiguration
@AutoConfigurationPackage(basePackages = "stirling.software.proprietary")
static class TestApp {}
@@ -0,0 +1,189 @@
package stirling.software.proprietary.storage.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.AutoConfigurationPackage;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import jakarta.persistence.EntityManager;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.storage.model.StoredFile;
import stirling.software.proprietary.storage.provider.StorageProvider;
import stirling.software.proprietary.storage.repository.FileShareAccessRepository;
import stirling.software.proprietary.storage.repository.FileShareRepository;
import stirling.software.proprietary.storage.repository.StorageCleanupEntryRepository;
import stirling.software.proprietary.storage.repository.StoredFileRepository;
// The retry queue is written from afterCommit, where the caller's transaction has already
// committed; every other test mocks the repository, so only a real datasource proves the row lands.
@DataJpaTest
@Import(FileStorageCleanupQueueDbTest.Beans.class)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
@DirtiesContext
class FileStorageCleanupQueueDbTest {
@Autowired private FileStorageService fileStorageService;
@Autowired private StorageCleanupQueue storageCleanupQueue;
@Autowired private StorageCleanupEntryRepository cleanupEntryRepository;
@Autowired private StoredFileRepository storedFileRepository;
@Autowired private PlatformTransactionManager transactionManager;
@Autowired private EntityManager entityManager;
@Test
void failedBlobDeleteAfterCommitLeavesACleanupEntryBehind() {
String storageKey = "key-" + UUID.randomUUID();
StoredFile file = persistFile(storageKey);
fileStorageService.deleteFile(file.getOwner(), file);
assertThat(storedFileRepository.findById(file.getId())).isEmpty();
assertThat(cleanupEntryRepository.findAll())
.extracting(entry -> entry.getStorageKey())
.contains(storageKey);
}
@Test
void everyFailedKeyOfADeletedFileIsQueued() {
String mainKey = "main-" + UUID.randomUUID();
String historyKey = "history-" + UUID.randomUUID();
String auditKey = "audit-" + UUID.randomUUID();
StoredFile file =
inTransaction(
() -> {
StoredFile stored = newFile(mainKey);
stored.setHistoryStorageKey(historyKey);
stored.setAuditLogStorageKey(auditKey);
entityManager.persist(stored);
return stored;
});
fileStorageService.deleteFile(file.getOwner(), file);
assertThat(cleanupEntryRepository.findAll())
.extracting(entry -> entry.getStorageKey())
.contains(mainKey, historyKey, auditKey);
}
@Test
void enqueuedKeySurvivesARollbackOfTheCallingTransaction() {
String storageKey = "rollback-" + UUID.randomUUID();
new TransactionTemplate(transactionManager)
.execute(
status -> {
storageCleanupQueue.enqueue(storageKey);
status.setRollbackOnly();
return null;
});
assertThat(cleanupEntryRepository.findAll())
.extracting(entry -> entry.getStorageKey())
.contains(storageKey);
}
private StoredFile persistFile(String storageKey) {
return inTransaction(
() -> {
StoredFile stored = newFile(storageKey);
entityManager.persist(stored);
return stored;
});
}
private StoredFile newFile(String storageKey) {
Team team = new Team();
team.setName("team-" + UUID.randomUUID());
entityManager.persist(team);
User owner = new User();
owner.setUsername("owner-" + UUID.randomUUID());
owner.setPassword("x");
owner.setTeam(team);
entityManager.persist(owner);
StoredFile stored = new StoredFile();
stored.setOwner(owner);
stored.setOriginalFilename("doc.pdf");
stored.setContentType("application/pdf");
stored.setSizeBytes(1);
stored.setStorageKey(storageKey);
return stored;
}
private <T> T inTransaction(java.util.function.Supplier<T> work) {
return new TransactionTemplate(transactionManager).execute(status -> work.get());
}
@TestConfiguration(proxyBeanMethods = false)
static class Beans {
@Bean
StorageProvider failingStorageProvider() throws IOException {
StorageProvider provider = mock(StorageProvider.class);
doThrow(new IOException("delete failed")).when(provider).delete(anyString());
return provider;
}
@Bean
ApplicationProperties applicationProperties() {
ApplicationProperties properties =
mock(ApplicationProperties.class, RETURNS_DEEP_STUBS);
when(properties.getSecurity().isEnableLogin()).thenReturn(true);
when(properties.getStorage().isEnabled()).thenReturn(true);
return properties;
}
@Bean
StorageCleanupQueue storageCleanupQueue(StorageCleanupEntryRepository repository) {
return new StorageCleanupQueue(repository);
}
@Bean
FileStorageService fileStorageService(
StoredFileRepository storedFileRepository,
FileShareRepository fileShareRepository,
FileShareAccessRepository fileShareAccessRepository,
UserRepository userRepository,
ApplicationProperties applicationProperties,
StorageProvider storageProvider,
StorageCleanupQueue storageCleanupQueue) {
return new FileStorageService(
storedFileRepository,
fileShareRepository,
fileShareAccessRepository,
userRepository,
applicationProperties,
storageProvider,
Optional.empty(),
storageCleanupQueue);
}
}
@SpringBootConfiguration
@AutoConfigurationPackage(basePackages = "stirling.software.proprietary")
static class TestApp {}
}
@@ -41,7 +41,6 @@ import stirling.software.proprietary.storage.model.StoredFile;
import stirling.software.proprietary.storage.provider.StorageProvider;
import stirling.software.proprietary.storage.repository.FileShareAccessRepository;
import stirling.software.proprietary.storage.repository.FileShareRepository;
import stirling.software.proprietary.storage.repository.StorageCleanupEntryRepository;
import stirling.software.proprietary.storage.repository.StoredFileRepository;
import stirling.software.proprietary.workflow.model.WorkflowSession;
@@ -57,7 +56,7 @@ class FileStorageServiceMoreTest {
@Mock private UserRepository userRepository;
@Mock private ApplicationProperties applicationProperties;
@Mock private StorageProvider storageProvider;
@Mock private StorageCleanupEntryRepository storageCleanupEntryRepository;
@Mock private StorageCleanupQueue storageCleanupQueue;
@Mock private ApplicationProperties.Security securityProperties;
@Mock private ApplicationProperties.System systemProperties;
@@ -77,7 +76,7 @@ class FileStorageServiceMoreTest {
applicationProperties,
storageProvider,
Optional.empty(),
storageCleanupEntryRepository);
storageCleanupQueue);
when(applicationProperties.getSecurity()).thenReturn(securityProperties);
when(securityProperties.isEnableLogin()).thenReturn(true);
@@ -1,11 +1,13 @@
package stirling.software.proprietary.storage.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.io.IOException;
@@ -15,12 +17,18 @@ import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionSynchronizationUtils;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.common.model.ApplicationProperties;
@@ -29,13 +37,14 @@ import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.storage.crypto.StorageEncryptionException;
import stirling.software.proprietary.storage.crypto.StorageKeyRevokedException;
import stirling.software.proprietary.storage.model.FileShare;
import stirling.software.proprietary.storage.model.FileShareAccess;
import stirling.software.proprietary.storage.model.FileShareAccessType;
import stirling.software.proprietary.storage.model.ShareAccessRole;
import stirling.software.proprietary.storage.model.StoredFile;
import stirling.software.proprietary.storage.provider.StorageProvider;
import stirling.software.proprietary.storage.provider.StoredObject;
import stirling.software.proprietary.storage.repository.FileShareAccessRepository;
import stirling.software.proprietary.storage.repository.FileShareRepository;
import stirling.software.proprietary.storage.repository.StorageCleanupEntryRepository;
import stirling.software.proprietary.storage.repository.StoredFileRepository;
import stirling.software.proprietary.workflow.model.WorkflowSession;
@@ -49,7 +58,7 @@ class FileStorageServiceTest {
@Mock private UserRepository userRepository;
@Mock private ApplicationProperties applicationProperties;
@Mock private StorageProvider storageProvider;
@Mock private StorageCleanupEntryRepository storageCleanupEntryRepository;
@Mock private StorageCleanupQueue storageCleanupQueue;
@Mock private ApplicationProperties.Security securityProperties;
@Mock private ApplicationProperties.System systemProperties;
@@ -70,7 +79,7 @@ class FileStorageServiceTest {
applicationProperties,
storageProvider,
Optional.empty(),
storageCleanupEntryRepository);
storageCleanupQueue);
// Default: storage and sharing fully enabled, share links enabled, no expiry
when(applicationProperties.getSecurity()).thenReturn(securityProperties);
@@ -105,6 +114,13 @@ class FileStorageServiceTest {
}
private FileShare shareFor(StoredFile file, User user, ShareAccessRole role) {
FileShare s = legacyShareFor(file, user, role);
s.setWriteEnabled(role == ShareAccessRole.EDITOR);
return s;
}
/** A share as it exists after upgrading: role set, write never explicitly granted. */
private FileShare legacyShareFor(StoredFile file, User user, ShareAccessRole role) {
FileShare s = new FileShare();
s.setFile(file);
s.setSharedWithUser(user);
@@ -112,6 +128,17 @@ class FileStorageServiceTest {
return s;
}
/** The service reads the acting principal off the security context to attribute writes. */
private void withAuthenticatedUser(User user, Runnable action) {
SecurityContextHolder.getContext()
.setAuthentication(new UsernamePasswordAuthenticationToken(user, "n/a", List.of()));
try {
action.run();
} finally {
SecurityContextHolder.clearContext();
}
}
// -------------------------------------------------------------------------
// getAccessibleFile
// -------------------------------------------------------------------------
@@ -528,6 +555,480 @@ 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 updateFileResponse_editorShare_recordsEditAccess() 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);
existing.getShares().add(share);
when(storedFileRepository.findByIdWithShares(100L)).thenReturn(Optional.of(existing));
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});
withAuthenticatedUser(editor, () -> service.updateFileResponse(editor, 100L, file));
ArgumentCaptor<FileShareAccess> access = ArgumentCaptor.forClass(FileShareAccess.class);
verify(fileShareAccessRepository).save(access.capture());
assertThat(access.getValue().getAccessType()).isEqualTo(FileShareAccessType.EDIT);
assertThat(access.getValue().getFileShare()).isSameAs(share);
assertThat(access.getValue().getUser()).isSameAs(editor);
}
@Test
void updateFileResponse_owner_recordsNoShareAccess() throws IOException {
when(storageProperties.getQuotas()).thenReturn(null);
User owner = user(1L);
StoredFile existing = ownedFile(owner);
existing.setStorageKey("old-key");
when(storedFileRepository.findByIdWithShares(100L)).thenReturn(Optional.of(existing));
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});
withAuthenticatedUser(owner, () -> service.updateFileResponse(owner, 100L, file));
verify(fileShareAccessRepository, never()).save(any());
}
@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));
// Another writer bumped past us, so the SQL-computed value is not loaded + 1.
when(storedFileRepository.findContentVersionById(100L)).thenReturn(9L);
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);
assertThat(updated.getContentVersion()).isEqualTo(9L);
}
@Test
void replaceFile_noExpectedVersion_versionProjectionMissing_fallsBackToLoadedPlusOne()
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));
when(storedFileRepository.findContentVersionById(100L)).thenReturn(null);
MockMultipartFile file =
new MockMultipartFile("file", "test.pdf", "application/pdf", new byte[] {1});
StoredFile updated = service.replaceFile(owner, existing, file, null, null, null);
// 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);
linkShare.setWriteEnabled(true);
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);
}
// replaceFile - write is opt-in per share
@Test
void replaceFile_legacyEditorShare_withoutExplicitWriteGrant_forbidden() {
User owner = user(1L);
User editor = user(2L);
StoredFile existing = ownedFile(owner);
FileShare share = legacyShareFor(existing, editor, ShareAccessRole.EDITOR);
when(fileShareRepository.findByFileAndSharedWithUser(existing, editor))
.thenReturn(Optional.of(share));
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);
verify(storedFileRepository, never()).save(any());
}
@Test
void replaceFileViaShareLink_legacyEditorLink_withoutExplicitWriteGrant_forbidden() {
User owner = user(1L);
StoredFile existing = ownedFile(owner);
FileShare linkShare = new FileShare();
linkShare.setFile(existing);
linkShare.setShareToken("token-1");
linkShare.setAccessRole(ShareAccessRole.EDITOR);
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);
verify(storedFileRepository, never()).save(any());
}
@Test
void shareWithUser_editorRole_grantsWriteExplicitly() {
User owner = user(1L);
User target = user(2L);
StoredFile file = ownedFile(owner);
when(storedFileRepository.findByIdAndOwnerWithShares(100L, owner))
.thenReturn(Optional.of(file));
when(userRepository.findByUsernameIgnoreCase("user2")).thenReturn(Optional.of(target));
when(fileShareRepository.findByFileAndSharedWithUser(file, target))
.thenReturn(Optional.empty());
when(fileShareRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
FileShare created = service.shareWithUser(owner, file, "user2", ShareAccessRole.EDITOR);
assertThat(created.getWriteEnabled()).isTrue();
}
@Test
void shareWithUser_viewerRole_doesNotGrantWrite() {
User owner = user(1L);
User target = user(2L);
StoredFile file = ownedFile(owner);
when(storedFileRepository.findByIdAndOwnerWithShares(100L, owner))
.thenReturn(Optional.of(file));
when(userRepository.findByUsernameIgnoreCase("user2")).thenReturn(Optional.of(target));
when(fileShareRepository.findByFileAndSharedWithUser(file, target))
.thenReturn(Optional.empty());
when(fileShareRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
FileShare created = service.shareWithUser(owner, file, "user2", ShareAccessRole.VIEWER);
assertThat(created.getWriteEnabled()).isFalse();
}
// replaceFile - only the owner may replace history / audit artifacts
@Test
void replaceFile_editorShare_cannotReplaceHistoryBundle() {
User owner = user(1L);
User editor = user(2L);
StoredFile existing = ownedFile(owner);
existing.setHistoryStorageKey("owner-history");
FileShare share = shareFor(existing, editor, ShareAccessRole.EDITOR);
when(fileShareRepository.findByFileAndSharedWithUser(existing, editor))
.thenReturn(Optional.of(share));
MockMultipartFile file =
new MockMultipartFile("file", "test.pdf", "application/pdf", new byte[] {1});
MockMultipartFile history =
new MockMultipartFile("historyBundle", "h.zip", "application/zip", new byte[] {1});
assertThatThrownBy(() -> service.replaceFile(editor, existing, file, history, null))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(403);
// The owner's archive must survive an editor attempting to overwrite it.
assertThat(existing.getHistoryStorageKey()).isEqualTo("owner-history");
verifyNoInteractions(storageProvider);
}
@Test
void replaceFile_editorShare_cannotReplaceAuditLog() {
User owner = user(1L);
User editor = user(2L);
StoredFile existing = ownedFile(owner);
existing.setAuditLogStorageKey("owner-audit");
FileShare share = shareFor(existing, editor, ShareAccessRole.EDITOR);
when(fileShareRepository.findByFileAndSharedWithUser(existing, editor))
.thenReturn(Optional.of(share));
MockMultipartFile file =
new MockMultipartFile("file", "test.pdf", "application/pdf", new byte[] {1});
MockMultipartFile audit =
new MockMultipartFile("auditLog", "a.json", "application/json", new byte[] {1});
assertThatThrownBy(() -> service.replaceFile(editor, existing, file, null, audit))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(403);
assertThat(existing.getAuditLogStorageKey()).isEqualTo("owner-audit");
verifyNoInteractions(storageProvider);
}
@Test
void replaceFileViaShareLink_cannotReplaceHistoryBundle() {
User owner = user(1L);
StoredFile existing = ownedFile(owner);
existing.setHistoryStorageKey("owner-history");
FileShare linkShare = new FileShare();
linkShare.setFile(existing);
linkShare.setShareToken("token-1");
linkShare.setAccessRole(ShareAccessRole.EDITOR);
linkShare.setWriteEnabled(true);
MockMultipartFile file =
new MockMultipartFile("file", "test.pdf", "application/pdf", new byte[] {1});
MockMultipartFile history =
new MockMultipartFile("historyBundle", "h.zip", "application/zip", new byte[] {1});
assertThatThrownBy(
() -> service.replaceFileViaShareLink(linkShare, file, history, null, null))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(403);
assertThat(existing.getHistoryStorageKey()).isEqualTo("owner-history");
}
@Test
void replaceFile_owner_mayStillReplaceHistoryBundle() throws IOException {
when(storageProperties.getQuotas()).thenReturn(null);
User owner = user(1L);
StoredFile existing = ownedFile(owner);
existing.setStorageKey("old-key");
existing.setHistoryStorageKey("old-history");
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});
MockMultipartFile history =
new MockMultipartFile("historyBundle", "h.zip", "application/zip", new byte[] {1});
assertThatCode(() -> service.replaceFile(owner, existing, file, history, null))
.doesNotThrowAnyException();
}
// replaceFile - the superseded blob outlives the transaction
@Test
void replaceFile_previousBlobIsDeletedOnlyAfterCommit() 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});
TransactionSynchronizationManager.initSynchronization();
try {
service.replaceFile(owner, existing, file, null, null);
// Still uncommitted: a rollback here would restore the row pointing at old-key.
verify(storageProvider, never()).delete("old-key");
TransactionSynchronizationUtils.triggerAfterCommit();
verify(storageProvider).delete("old-key");
} finally {
TransactionSynchronizationManager.clearSynchronization();
}
}
@Test
void replaceFile_previousBlobSurvivesWhenTheTransactionRollsBack() 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});
TransactionSynchronizationManager.initSynchronization();
try {
service.replaceFile(owner, existing, file, null, null);
TransactionSynchronizationUtils.triggerAfterCompletion(
TransactionSynchronization.STATUS_ROLLED_BACK);
verify(storageProvider, never()).delete("old-key");
} finally {
TransactionSynchronizationManager.clearSynchronization();
}
}
// replaceFile - a rejected write must not destroy the previous blob
@Test
void replaceFile_versionMismatch_leavesPreviousBlobIntact() {
when(storageProperties.getQuotas()).thenReturn(null);
User owner = user(1L);
StoredFile existing = ownedFile(owner);
existing.setContentVersion(5L);
existing.setStorageKey("old-key");
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);
assertThat(existing.getStorageKey()).isEqualTo("old-key");
verifyNoInteractions(storageProvider);
}
@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
// -------------------------------------------------------------------------
@@ -10644,6 +10644,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."
@@ -10667,6 +10689,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"
@@ -11006,6 +11006,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."
@@ -11029,6 +11051,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";
@@ -45,6 +46,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";
@@ -152,6 +155,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);
@@ -182,6 +186,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);
@@ -400,6 +405,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
? [
{
@@ -475,6 +499,8 @@ const FileEditorThumbnail = ({
policyEnforcing,
canUpload,
canShare,
isSharedEditor,
file.isLeaf,
isUploaded,
pinFile,
unpinFile,
@@ -727,20 +753,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)}>
@@ -757,6 +792,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";
@@ -15,6 +15,8 @@ import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import DriveFileRenameOutlineIcon from "@mui/icons-material/DriveFileRenameOutline";
import ContentCopyOutlinedIcon from "@mui/icons-material/ContentCopyOutlined";
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";
import SearchIcon from "@mui/icons-material/Search";
@@ -41,6 +43,13 @@ import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
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";
@@ -617,6 +626,23 @@ function PolicyBadges({ fileId }: { fileId: string }) {
return <PolicyBadgeRow policies={badges} />;
}
/** Sharing state for one file: may I write back, is it shared with me, and is
* the server copy newer than the one these local bytes came from. */
function useSharedFileFlags(file: StirlingFileStub) {
const { config } = useAppConfig();
const sharingEnabled =
config?.storageEnabled === true && config?.storageSharingEnabled === true;
const isSharedWithYou =
sharingEnabled &&
(file.remoteOwnedByCurrentUser === false ||
Boolean(file.remoteSharedViaLink));
return {
isSharedEditor: sharingEnabled && canEditSharedFile(file),
isSharedWithYou,
hasRemoteUpdate: isSharedWithYou && hasNewerSharedVersion(file),
};
}
/** Per-file actions. Shared verbatim by the grid card and the list row, and
* kept in step with the file sidebar's kebab so both surfaces offer the same. */
interface FileActionsMenuProps {
@@ -649,138 +675,185 @@ function FileActionsMenu({
const { t } = useTranslation();
const terminology = useFileActionTerminology();
const DownloadIcon = useFileActionIcons().download;
const [showSaveToSharedModal, setShowSaveToSharedModal] = useState(false);
const { fetchLatestCopy } = useSharedFileActions();
const { isSharedEditor, isSharedWithYou, hasRemoteUpdate } =
useSharedFileFlags(file);
const showSaveToServer =
Boolean(onSaveToServer) && file.remoteStorageId == null;
const showVersionHistory =
Boolean(onVersionHistory) && (file.versionNumber ?? 1) > 1;
return (
<Menu shadow="md" position="bottom-end" withinPortal width={220}>
<Menu.Target>
<ActionIcon
ref={triggerRef}
variant="tertiary"
size="sm"
onClick={(e) => e.stopPropagation()}
aria-label={t("filesPage.fileMenu", "File actions")}
data-testid="file-card-actions"
>
<MoreVertIcon fontSize="small" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<OpenInNewIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onOpen();
}}
>
{t("filesPage.addToWorkspace", "Add to workspace")}
</Menu.Item>
<OpenInNewWindowMenuItem file={file} />
<Menu.Item
leftSection={<DriveFileMoveIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onMove();
}}
data-testid="file-menu-move-to"
>
{t("filesPage.moveTo", "Move to…")}
</Menu.Item>
<>
<Menu shadow="md" position="bottom-end" withinPortal width={220}>
<Menu.Target>
<ActionIcon
ref={triggerRef}
variant="tertiary"
size="sm"
onClick={(e) => e.stopPropagation()}
aria-label={t("filesPage.fileMenu", "File actions")}
data-testid="file-card-actions"
>
<MoreVertIcon fontSize="small" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<OpenInNewIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onOpen();
}}
>
{t("filesPage.addToWorkspace", "Add to workspace")}
</Menu.Item>
<OpenInNewWindowMenuItem file={file} />
<Menu.Item
leftSection={<DriveFileMoveIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onMove();
}}
data-testid="file-menu-move-to"
>
{t("filesPage.moveTo", "Move to…")}
</Menu.Item>
{(onDownload || onRename || onDuplicate) && <Menu.Divider />}
{onDownload && (
<Menu.Item
leftSection={<DownloadIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onDownload();
}}
data-testid="file-menu-download"
>
{terminology.download}
</Menu.Item>
)}
{onRename && (
<Menu.Item
leftSection={<DriveFileRenameOutlineIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onRename();
}}
data-testid="file-menu-rename"
>
{t("filesPage.rename", "Rename")}
</Menu.Item>
)}
{onDuplicate && (
<Menu.Item
leftSection={<ContentCopyOutlinedIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onDuplicate();
}}
data-testid="file-menu-duplicate"
>
{t("filesPage.duplicate", "Duplicate")}
</Menu.Item>
)}
{(showSaveToServer || showVersionHistory) && <Menu.Divider />}
{/* Per-file Save to server; shown for local-only files. When
storage is off it stays visible but disabled with a tooltip. */}
{showSaveToServer && onSaveToServer && (
<Tooltip
label={saveToServerDisabledReason}
disabled={!saveToServerDisabledReason}
withinPortal
position="left"
multiline
w={240}
>
{(onDownload || onRename || onDuplicate) && <Menu.Divider />}
{onDownload && (
<Menu.Item
leftSection={<CloudUploadIcon fontSize="small" />}
disabled={Boolean(saveToServerDisabledReason)}
leftSection={<DownloadIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onSaveToServer();
onDownload();
}}
style={
saveToServerDisabledReason
? { pointerEvents: "auto" }
: undefined
}
data-testid="file-menu-download"
>
{t("filesPage.saveToServer", "Save to server")}
{terminology.download}
</Menu.Item>
</Tooltip>
)}
{showVersionHistory && onVersionHistory && (
)}
{onRename && (
<Menu.Item
leftSection={<DriveFileRenameOutlineIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onRename();
}}
data-testid="file-menu-rename"
>
{t("filesPage.rename", "Rename")}
</Menu.Item>
)}
{onDuplicate && (
<Menu.Item
leftSection={<ContentCopyOutlinedIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onDuplicate();
}}
data-testid="file-menu-duplicate"
>
{t("filesPage.duplicate", "Duplicate")}
</Menu.Item>
)}
{(showSaveToServer ||
showVersionHistory ||
isSharedEditor ||
isSharedWithYou) && <Menu.Divider />}
{/* Per-file Save to server; shown for local-only files. When
storage is off it stays visible but disabled with a tooltip. */}
{showSaveToServer && onSaveToServer && (
<Tooltip
label={saveToServerDisabledReason}
disabled={!saveToServerDisabledReason}
withinPortal
position="left"
multiline
w={240}
>
<Menu.Item
leftSection={<CloudUploadIcon fontSize="small" />}
disabled={Boolean(saveToServerDisabledReason)}
onClick={(e) => {
e.stopPropagation();
onSaveToServer();
}}
style={
saveToServerDisabledReason
? { pointerEvents: "auto" }
: undefined
}
>
{t("filesPage.saveToServer", "Save to server")}
</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>
)}
{showVersionHistory && onVersionHistory && (
<Menu.Item
leftSection={<HistoryIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onVersionHistory();
}}
>
{t("filesPage.versionHistory", "Version history")}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item
leftSection={<HistoryIcon fontSize="small" />}
color="red"
leftSection={<DeleteIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onVersionHistory();
onRemove();
}}
>
{t("filesPage.versionHistory", "Version history")}
{t("filesPage.remove", "Delete")}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<DeleteIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
{t("filesPage.remove", "Delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Menu.Dropdown>
</Menu>
{isSharedEditor && (
<SaveToSharedModal
opened={showSaveToSharedModal}
onClose={() => setShowSaveToSharedModal(false)}
file={file}
/>
)}
</>
);
}
@@ -863,6 +936,7 @@ function FileCard({
() => getFileDate({ lastModified: file.lastModified }),
[file.lastModified],
);
const { hasRemoteUpdate } = useSharedFileFlags(file);
const handleDragStart = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
@@ -921,6 +995,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
@@ -610,6 +610,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;
@@ -21,6 +21,7 @@ import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import type { StirlingFileStub } from "@app/types/fileContext";
import { uploadHistoryChains } from "@app/services/serverStorageUpload";
import { SharedFileConflictError } from "@app/services/sharedFileSave";
import { fileStorage } from "@app/services/fileStorage";
import { useFileActions } from "@app/contexts/FileContext";
import type { FileId } from "@app/types/file";
@@ -117,31 +118,40 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({
rootIds.length === 1 && remoteIds.length === 1
? remoteIds[0]
: undefined;
// Only guard when the selected files agree on one base version.
const baseVersions = Array.from(
new Set(
files
.filter((file) => file.remoteStorageId === existingRemoteId)
.map((file) => file.remoteVersionBase)
.filter((v): v is number => typeof v === "number"),
),
);
const {
remoteId: storedId,
updatedAt,
version,
chain,
} = await uploadHistoryChains(rootIds, existingRemoteId);
} = await uploadHistoryChains(rootIds, existingRemoteId, {
baseVersion: baseVersions.length === 1 ? baseVersions[0] : undefined,
});
const shareResponse = await createShareLink(storedId);
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({
@@ -154,6 +164,15 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({
await onShared();
}
} catch (error: unknown) {
if (error instanceof SharedFileConflictError) {
setErrorMessage(
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.",
),
);
return;
}
console.error("Failed to generate share link:", error);
setErrorMessage(
t(
@@ -8,6 +8,7 @@ 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 { uploadHistoryChains } from "@app/services/serverStorageUpload";
import { SharedFileConflictError } from "@app/services/sharedFileSave";
import { fileStorage } from "@app/services/fileStorage";
import { useFileActions } from "@app/contexts/FileContext";
import type { FileId } from "@app/types/file";
@@ -57,10 +58,22 @@ const BulkUploadToServerModal: React.FC<BulkUploadToServerModalProps> = ({
);
const existingRemoteId =
remoteIds.length === 1 ? remoteIds[0] : undefined;
// Only guard when the selected files agree on one base version.
const baseVersions = Array.from(
new Set(
files
.filter((file) => file.remoteStorageId === existingRemoteId)
.map((file) => file.remoteVersionBase)
.filter((v): v is number => typeof v === "number"),
),
);
const { remoteId, updatedAt, chain } = await uploadHistoryChains(
const { remoteId, updatedAt, version, chain } = await uploadHistoryChains(
rootIds,
existingRemoteId,
{
baseVersion: baseVersions.length === 1 ? baseVersions[0] : undefined,
},
);
for (const stub of chain) {
@@ -69,12 +82,16 @@ const BulkUploadToServerModal: React.FC<BulkUploadToServerModalProps> = ({
remoteStorageUpdatedAt: updatedAt,
remoteOwnedByCurrentUser: true,
remoteSharedViaLink: false,
remoteVersionBase: version,
remoteVersionLatest: version,
});
await fileStorage.updateFileMetadata(stub.id, {
remoteStorageId: remoteId,
remoteStorageUpdatedAt: updatedAt,
remoteOwnedByCurrentUser: true,
remoteSharedViaLink: false,
remoteVersionBase: version,
remoteVersionLatest: version,
});
}
@@ -89,6 +106,15 @@ const BulkUploadToServerModal: React.FC<BulkUploadToServerModalProps> = ({
}
onClose();
} catch (error) {
if (error instanceof SharedFileConflictError) {
setErrorMessage(
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.",
),
);
return;
}
console.error("Failed to upload files to server:", error);
// A 403 means the server has storage turned off (or login disabled,
// which gates storage). Say so plainly instead of the generic
@@ -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;
@@ -21,6 +21,7 @@ import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import type { StirlingFileStub } from "@app/types/fileContext";
import { uploadHistoryChain } from "@app/services/serverStorageUpload";
import { SharedFileConflictError } from "@app/services/sharedFileSave";
import { fileStorage } from "@app/services/fileStorage";
import { useFileActions } from "@app/contexts/FileContext";
import type { FileId } from "@app/types/file";
@@ -124,23 +125,24 @@ const ShareFileModal: React.FC<ShareFileModalProps> = ({
const {
remoteId: newStoredId,
updatedAt,
version,
chain,
} = await uploadHistoryChain(originalFileId, remoteId);
} = await uploadHistoryChain(originalFileId, remoteId, {
baseVersion: file.remoteVersionBase,
});
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);
}
}
@@ -166,6 +168,15 @@ const ShareFileModal: React.FC<ShareFileModalProps> = ({
await onUploaded();
}
} catch (error: unknown) {
if (error instanceof SharedFileConflictError) {
setErrorMessage(
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.",
),
);
return;
}
console.error("Failed to generate share link:", error);
setErrorMessage(
t(
@@ -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>
@@ -16,6 +16,7 @@ interface StoredFileResponse {
owner?: string | null;
ownedByCurrentUser?: boolean;
accessRole?: string | null;
canEdit?: boolean;
shareLinks?: Array<{ token?: string | null }>;
sharedWithUsers?: string[];
filePurpose?: string | null;
@@ -185,6 +186,10 @@ export const useFileManager = () => {
? serverFile.ownedByCurrentUser
: stub.remoteOwnedByCurrentUser,
remoteAccessRole: serverFile.accessRole ?? stub.remoteAccessRole,
remoteCanEdit:
typeof serverFile.canEdit === "boolean"
? serverFile.canEdit
: stub.remoteCanEdit,
remoteSharedViaLink: stub.remoteSharedViaLink,
remoteHasShareLinks: Boolean(serverFile.shareLinks?.length),
remoteStorageUpdatedAt:
@@ -235,6 +240,8 @@ export const useFileManager = () => {
? file.ownedByCurrentUser
: undefined,
remoteAccessRole: file.accessRole ?? undefined,
remoteCanEdit:
typeof file.canEdit === "boolean" ? file.canEdit : undefined,
remoteSharedViaLink: false,
remoteHasShareLinks: Boolean(file.shareLinks?.length),
});
@@ -0,0 +1,115 @@
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();
// Server decides; role is the fallback for stubs cached before canEdit existed.
const writable = file.remoteCanEdit ?? role === "editor";
return (
isSharedWithUser &&
hasServerRef &&
file.remoteOwnedByCurrentUser !== true &&
role === "editor" &&
writable
);
}
// 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 {
// Version first: if a writer commits between the two calls the bytes are
// newer than the base, which costs a spurious 409, never a lost update.
const latestVersion = await fetchLatestSharedVersion(file);
const blob = await downloadSharedBytes(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,
remoteCanEdit: file.remoteCanEdit,
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 };
}
@@ -310,9 +310,12 @@ class FileStorageService {
remoteOwnerUsername: stub.remoteOwnerUsername,
remoteOwnedByCurrentUser: stub.remoteOwnedByCurrentUser,
remoteAccessRole: stub.remoteAccessRole,
remoteCanEdit: stub.remoteCanEdit,
remoteSharedViaLink: stub.remoteSharedViaLink,
remoteHasShareLinks: stub.remoteHasShareLinks,
remoteShareToken: stub.remoteShareToken,
remoteVersionBase: stub.remoteVersionBase,
remoteVersionLatest: stub.remoteVersionLatest,
// History data from stub
versionNumber: stub.versionNumber ?? 1,
@@ -685,9 +688,12 @@ class FileStorageService {
remoteOwnerUsername: record.remoteOwnerUsername,
remoteOwnedByCurrentUser: record.remoteOwnedByCurrentUser,
remoteAccessRole: record.remoteAccessRole,
remoteCanEdit: record.remoteCanEdit,
remoteSharedViaLink: record.remoteSharedViaLink,
remoteHasShareLinks: record.remoteHasShareLinks,
remoteShareToken: record.remoteShareToken,
remoteVersionBase: record.remoteVersionBase,
remoteVersionLatest: record.remoteVersionLatest,
versionNumber: record.versionNumber,
originalFileId: record.originalFileId,
parentFileId: record.parentFileId,
@@ -753,9 +759,12 @@ class FileStorageService {
remoteOwnerUsername: record.remoteOwnerUsername,
remoteOwnedByCurrentUser: record.remoteOwnedByCurrentUser,
remoteAccessRole: record.remoteAccessRole,
remoteCanEdit: record.remoteCanEdit,
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,
@@ -853,9 +862,12 @@ class FileStorageService {
remoteOwnerUsername: record.remoteOwnerUsername,
remoteOwnedByCurrentUser: record.remoteOwnedByCurrentUser,
remoteAccessRole: record.remoteAccessRole,
remoteCanEdit: record.remoteCanEdit,
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,8 @@ interface StoredFileResponse {
owner?: string | null;
ownedByCurrentUser?: boolean;
accessRole?: string | null;
canEdit?: boolean;
version?: number | null;
shareLinks?: Array<{ token?: string | null }>;
sharedUsers?: Array<{ username?: string | null }>;
sharedWithUsers?: string[];
@@ -56,6 +58,9 @@ interface AccessedShareLinkResponse {
fileName?: string | null;
owner?: string | null;
ownedByCurrentUser?: boolean;
accessRole?: string | null;
canEdit?: boolean;
version?: number | null;
createdAt?: string | null;
lastAccessedAt?: string | null;
}
@@ -190,6 +195,10 @@ export async function reconcileServerFiles(
? serverFile.ownedByCurrentUser
: stub.remoteOwnedByCurrentUser,
remoteAccessRole: serverFile.accessRole ?? stub.remoteAccessRole,
remoteCanEdit:
typeof serverFile.canEdit === "boolean"
? serverFile.canEdit
: stub.remoteCanEdit,
remoteSharedViaLink: stub.remoteSharedViaLink,
remoteHasShareLinks: Boolean(serverFile.shareLinks?.length),
remoteHasUserShares: Boolean(
@@ -199,6 +208,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 +256,10 @@ export async function reconcileServerFiles(
? file.ownedByCurrentUser
: undefined,
remoteAccessRole: file.accessRole ?? undefined,
remoteCanEdit:
typeof file.canEdit === "boolean" ? file.canEdit : undefined,
remoteVersionLatest:
typeof file.version === "number" ? file.version : undefined,
remoteSharedViaLink: false,
remoteHasShareLinks: Boolean(file.shareLinks?.length),
remoteHasUserShares: Boolean(
@@ -361,6 +378,11 @@ export async function reconcileServerFiles(
remoteStorageUpdatedAt: lastModified,
remoteOwnerUsername: link.owner ?? undefined,
remoteOwnedByCurrentUser: false,
remoteAccessRole: link.accessRole ?? undefined,
remoteCanEdit:
typeof link.canEdit === "boolean" ? link.canEdit : undefined,
remoteVersionLatest:
typeof link.version === "number" ? link.version : undefined,
remoteSharedViaLink: true,
remoteHasShareLinks: false,
remoteShareToken: link.shareToken,
@@ -475,6 +497,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 },
);
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,
});
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,
});
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 },
);
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 },
);
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,402 @@
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}`),
});
});
}
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"),
});
});
});
+3
View File
@@ -75,9 +75,12 @@ 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
remoteCanEdit?: boolean; // Server's write decision; editor role alone is not enough
remoteSharedViaLink?: boolean; // True when imported from a share link
remoteHasShareLinks?: boolean; // True when owner has shared this file
remoteHasUserShares?: boolean; // True when owner has invited specific users
@@ -30,6 +30,7 @@ interface ShareLinkMetadata {
owner?: string | null;
ownedByCurrentUser?: boolean;
accessRole?: string | null;
canEdit?: boolean;
expiresAt?: string;
}
@@ -133,6 +134,10 @@ export default function ShareLinkLoader({ token }: ShareLinkLoaderProps) {
remoteOwnerUsername: shareMetadata?.owner ?? undefined,
remoteOwnedByCurrentUser: false,
remoteAccessRole: shareMetadata?.accessRole ?? undefined,
remoteCanEdit:
typeof shareMetadata?.canEdit === "boolean"
? shareMetadata.canEdit
: undefined,
remoteSharedViaLink: true,
remoteHasShareLinks: false,
remoteShareToken: shareMetadata?.shareToken || normalizedToken,
@@ -200,6 +205,10 @@ export default function ShareLinkLoader({ token }: ShareLinkLoaderProps) {
remoteOwnerUsername: shareMetadata?.owner ?? undefined,
remoteOwnedByCurrentUser: false,
remoteAccessRole: shareMetadata?.accessRole ?? undefined,
remoteCanEdit:
typeof shareMetadata?.canEdit === "boolean"
? shareMetadata.canEdit
: undefined,
remoteSharedViaLink: true,
remoteHasShareLinks: false,
remoteShareToken: shareMetadata?.shareToken || normalizedToken,
@@ -18,6 +18,8 @@ export interface ShareLinkMetadata {
owner?: string | null;
ownedByCurrentUser?: boolean;
accessRole?: string | null;
canEdit?: boolean;
version?: number | null;
createdAt?: string;
expiresAt?: string;
}
@@ -94,9 +96,21 @@ export async function importShareLinkToWorkbench(
remoteOwnerUsername: shareMetadata?.owner ?? undefined,
remoteOwnedByCurrentUser: false,
remoteAccessRole: shareMetadata?.accessRole ?? undefined,
remoteCanEdit:
typeof shareMetadata?.canEdit === "boolean"
? shareMetadata.canEdit
: undefined,
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) {
@@ -157,9 +171,21 @@ export async function importShareLinkToWorkbench(
remoteOwnerUsername: shareMetadata?.owner ?? undefined,
remoteOwnedByCurrentUser: false,
remoteAccessRole: shareMetadata?.accessRole ?? undefined,
remoteCanEdit:
typeof shareMetadata?.canEdit === "boolean"
? shareMetadata.canEdit
: undefined,
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);