mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9608f4654e | ||
|
|
d0e1e2289b | ||
|
|
ac2c8f71ab | ||
|
|
65a6da3212 | ||
|
|
488984ac2f | ||
|
|
8e220dd7b3 | ||
|
|
a825b515b5 | ||
|
|
4fd504e8ea | ||
|
|
6854b46568 | ||
|
|
de65fda91b | ||
|
|
8d5d3972e2 | ||
|
|
247e43f4a0 | ||
|
|
089e699263 | ||
|
|
77f45806c9 |
+46
-3
@@ -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)
|
||||
|
||||
+54
-2
@@ -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()
|
||||
|
||||
+40
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
@@ -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;
|
||||
|
||||
|
||||
+5
-4
@@ -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
-1
@@ -2,5 +2,6 @@ package stirling.software.proprietary.storage.model;
|
||||
|
||||
public enum FileShareAccessType {
|
||||
VIEW,
|
||||
DOWNLOAD
|
||||
DOWNLOAD,
|
||||
EDIT
|
||||
}
|
||||
|
||||
+10
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -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;
|
||||
|
||||
+6
@@ -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;
|
||||
|
||||
+19
@@ -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();
|
||||
|
||||
+241
-28
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+29
@@ -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);
|
||||
}
|
||||
}
|
||||
+27
-2
@@ -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
|
||||
|
||||
+116
@@ -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 {}
|
||||
}
|
||||
+58
@@ -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 {}
|
||||
|
||||
+189
@@ -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 {}
|
||||
}
|
||||
+2
-3
@@ -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);
|
||||
|
||||
+504
-3
@@ -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"),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user