diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminDailyActiveUserEntity.java b/backend/src/main/java/com/yoyuzh/admin/AdminDailyActiveUserEntity.java new file mode 100644 index 0000000..a225620 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminDailyActiveUserEntity.java @@ -0,0 +1,60 @@ +package com.yoyuzh.admin; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import java.time.LocalDate; + +@Entity +@Table( + name = "portal_admin_daily_active_user", + uniqueConstraints = @UniqueConstraint(name = "uk_admin_daily_active_user_date_user", columnNames = {"metric_date", "user_id"}) +) +public class AdminDailyActiveUserEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "metric_date", nullable = false) + private LocalDate metricDate; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "username", nullable = false, length = 100) + private String username; + + public Long getId() { + return id; + } + + public LocalDate getMetricDate() { + return metricDate; + } + + public void setMetricDate(LocalDate metricDate) { + this.metricDate = metricDate; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminDailyActiveUserRepository.java b/backend/src/main/java/com/yoyuzh/admin/AdminDailyActiveUserRepository.java new file mode 100644 index 0000000..6282f0a --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminDailyActiveUserRepository.java @@ -0,0 +1,28 @@ +package com.yoyuzh.admin; + +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface AdminDailyActiveUserRepository extends JpaRepository { + + List findAllByMetricDateBetweenOrderByMetricDateAscUsernameAsc(LocalDate startDate, LocalDate endDate); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + select entry from AdminDailyActiveUserEntity entry + where entry.metricDate = :metricDate and entry.userId = :userId + """) + Optional findByMetricDateAndUserIdForUpdate(@Param("metricDate") LocalDate metricDate, + @Param("userId") Long userId); + + @Modifying + void deleteAllByMetricDateBefore(LocalDate cutoffDate); +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminDailyActiveUserSummary.java b/backend/src/main/java/com/yoyuzh/admin/AdminDailyActiveUserSummary.java new file mode 100644 index 0000000..9f79398 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminDailyActiveUserSummary.java @@ -0,0 +1,12 @@ +package com.yoyuzh.admin; + +import java.time.LocalDate; +import java.util.List; + +public record AdminDailyActiveUserSummary( + LocalDate metricDate, + String label, + long userCount, + List usernames +) { +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminMetricsService.java b/backend/src/main/java/com/yoyuzh/admin/AdminMetricsService.java index 2b40951..effcd92 100644 --- a/backend/src/main/java/com/yoyuzh/admin/AdminMetricsService.java +++ b/backend/src/main/java/com/yoyuzh/admin/AdminMetricsService.java @@ -10,6 +10,7 @@ import java.time.LocalDateTime; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.TreeMap; import java.util.stream.IntStream; @Service @@ -18,13 +19,16 @@ public class AdminMetricsService { private static final Long STATE_ID = 1L; private static final long DEFAULT_OFFLINE_TRANSFER_STORAGE_LIMIT_BYTES = 20L * 1024 * 1024 * 1024; + private static final int DAILY_ACTIVE_USER_RETENTION_DAYS = 7; private final AdminMetricsStateRepository adminMetricsStateRepository; private final AdminRequestTimelinePointRepository adminRequestTimelinePointRepository; + private final AdminDailyActiveUserRepository adminDailyActiveUserRepository; @Transactional public AdminMetricsSnapshot getSnapshot() { LocalDate today = LocalDate.now(); + pruneExpiredDailyActiveUsers(today); AdminMetricsState state = refreshRequestCountDateIfNeeded(ensureCurrentState(), today, true); return toSnapshot(state, today); } @@ -34,6 +38,21 @@ public class AdminMetricsService { return ensureCurrentState().getOfflineTransferStorageLimitBytes(); } + @Transactional + public void recordUserOnline(Long userId, String username) { + if (userId == null || username == null || username.isBlank()) { + return; + } + LocalDate today = LocalDate.now(); + pruneExpiredDailyActiveUsers(today); + AdminDailyActiveUserEntity entry = adminDailyActiveUserRepository.findByMetricDateAndUserIdForUpdate(today, userId) + .orElseGet(() -> createDailyActiveUser(today, userId, username)); + if (!username.equals(entry.getUsername())) { + entry.setUsername(username); + adminDailyActiveUserRepository.save(entry); + } + } + @Transactional public void incrementRequestCount() { LocalDateTime now = LocalDateTime.now(); @@ -78,6 +97,7 @@ public class AdminMetricsService { state.getDownloadTrafficBytes(), state.getTransferUsageBytes(), state.getOfflineTransferStorageLimitBytes(), + buildDailyActiveUsers(metricDate), buildRequestTimeline(metricDate) ); } @@ -129,11 +149,34 @@ public class AdminMetricsService { for (AdminRequestTimelinePointEntity point : adminRequestTimelinePointRepository.findAllByMetricDateOrderByHourAsc(metricDate)) { countsByHour.put(point.getHour(), point.getRequestCount()); } - return IntStream.range(0, 24) + int currentHour = LocalDate.now().equals(metricDate) ? LocalDateTime.now().getHour() : 23; + return IntStream.rangeClosed(0, currentHour) .mapToObj(hour -> new AdminRequestTimelinePoint(hour, formatHourLabel(hour), countsByHour.getOrDefault(hour, 0L))) .toList(); } + private List buildDailyActiveUsers(LocalDate today) { + LocalDate startDate = today.minusDays(DAILY_ACTIVE_USER_RETENTION_DAYS - 1L); + Map> usernamesByDate = new TreeMap<>(); + for (AdminDailyActiveUserEntity entry : adminDailyActiveUserRepository + .findAllByMetricDateBetweenOrderByMetricDateAscUsernameAsc(startDate, today)) { + usernamesByDate.computeIfAbsent(entry.getMetricDate(), ignored -> new java.util.ArrayList<>()) + .add(entry.getUsername()); + } + return IntStream.range(0, DAILY_ACTIVE_USER_RETENTION_DAYS) + .mapToObj(offset -> startDate.plusDays(offset)) + .map(metricDate -> { + List usernames = List.copyOf(usernamesByDate.getOrDefault(metricDate, List.of())); + return new AdminDailyActiveUserSummary( + metricDate, + formatDailyActiveUserLabel(metricDate, today), + usernames.size(), + usernames + ); + }) + .toList(); + } + private void incrementRequestTimelinePoint(LocalDate metricDate, int hour) { AdminRequestTimelinePointEntity point = adminRequestTimelinePointRepository .findByMetricDateAndHourForUpdate(metricDate, hour) @@ -155,7 +198,34 @@ public class AdminMetricsService { } } + private AdminDailyActiveUserEntity createDailyActiveUser(LocalDate metricDate, Long userId, String username) { + AdminDailyActiveUserEntity entry = new AdminDailyActiveUserEntity(); + entry.setMetricDate(metricDate); + entry.setUserId(userId); + entry.setUsername(username); + try { + return adminDailyActiveUserRepository.saveAndFlush(entry); + } catch (DataIntegrityViolationException ignored) { + return adminDailyActiveUserRepository.findByMetricDateAndUserIdForUpdate(metricDate, userId) + .orElseThrow(() -> ignored); + } + } + + private void pruneExpiredDailyActiveUsers(LocalDate today) { + adminDailyActiveUserRepository.deleteAllByMetricDateBefore(today.minusDays(DAILY_ACTIVE_USER_RETENTION_DAYS - 1L)); + } + private String formatHourLabel(int hour) { return "%02d:00".formatted(hour); } + + private String formatDailyActiveUserLabel(LocalDate metricDate, LocalDate today) { + if (metricDate.equals(today)) { + return "今天"; + } + if (metricDate.equals(today.minusDays(1))) { + return "昨天"; + } + return "%02d-%02d".formatted(metricDate.getMonthValue(), metricDate.getDayOfMonth()); + } } diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminMetricsSnapshot.java b/backend/src/main/java/com/yoyuzh/admin/AdminMetricsSnapshot.java index 855db28..484d1a8 100644 --- a/backend/src/main/java/com/yoyuzh/admin/AdminMetricsSnapshot.java +++ b/backend/src/main/java/com/yoyuzh/admin/AdminMetricsSnapshot.java @@ -7,6 +7,7 @@ public record AdminMetricsSnapshot( long downloadTrafficBytes, long transferUsageBytes, long offlineTransferStorageLimitBytes, + List dailyActiveUsers, List requestTimeline ) { } diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminService.java b/backend/src/main/java/com/yoyuzh/admin/AdminService.java index 7c6dff7..eebcb4e 100644 --- a/backend/src/main/java/com/yoyuzh/admin/AdminService.java +++ b/backend/src/main/java/com/yoyuzh/admin/AdminService.java @@ -9,6 +9,7 @@ import com.yoyuzh.auth.RefreshTokenService; import com.yoyuzh.common.BusinessException; import com.yoyuzh.common.ErrorCode; import com.yoyuzh.common.PageResponse; +import com.yoyuzh.files.FileBlobRepository; import com.yoyuzh.files.FileService; import com.yoyuzh.files.StoredFile; import com.yoyuzh.files.StoredFileRepository; @@ -32,6 +33,7 @@ public class AdminService { private final UserRepository userRepository; private final StoredFileRepository storedFileRepository; + private final FileBlobRepository fileBlobRepository; private final FileService fileService; private final PasswordEncoder passwordEncoder; private final RefreshTokenService refreshTokenService; @@ -45,12 +47,13 @@ public class AdminService { return new AdminSummaryResponse( userRepository.count(), storedFileRepository.count(), - storedFileRepository.sumAllFileSize(), + fileBlobRepository.sumAllBlobSize(), metrics.downloadTrafficBytes(), metrics.requestCount(), metrics.transferUsageBytes(), offlineTransferSessionRepository.sumUploadedFileSizeByExpiresAtAfter(Instant.now()), metrics.offlineTransferStorageLimitBytes(), + metrics.dailyActiveUsers(), metrics.requestTimeline(), registrationInviteService.getCurrentInviteCode() ); diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminSummaryResponse.java b/backend/src/main/java/com/yoyuzh/admin/AdminSummaryResponse.java index 1e19844..470c6fa 100644 --- a/backend/src/main/java/com/yoyuzh/admin/AdminSummaryResponse.java +++ b/backend/src/main/java/com/yoyuzh/admin/AdminSummaryResponse.java @@ -11,6 +11,7 @@ public record AdminSummaryResponse( long transferUsageBytes, long offlineTransferStorageBytes, long offlineTransferStorageLimitBytes, + List dailyActiveUsers, List requestTimeline, String inviteCode ) { diff --git a/backend/src/main/java/com/yoyuzh/auth/DevBootstrapDataInitializer.java b/backend/src/main/java/com/yoyuzh/auth/DevBootstrapDataInitializer.java index 372694f..88b25a2 100644 --- a/backend/src/main/java/com/yoyuzh/auth/DevBootstrapDataInitializer.java +++ b/backend/src/main/java/com/yoyuzh/auth/DevBootstrapDataInitializer.java @@ -1,8 +1,6 @@ package com.yoyuzh.auth; -import com.yoyuzh.config.FileStorageProperties; import com.yoyuzh.files.FileService; -import com.yoyuzh.files.StoredFile; import com.yoyuzh.files.StoredFileRepository; import lombok.RequiredArgsConstructor; import org.springframework.boot.CommandLineRunner; @@ -11,10 +9,7 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.List; @Component @@ -59,7 +54,6 @@ public class DevBootstrapDataInitializer implements CommandLineRunner { private final PasswordEncoder passwordEncoder; private final FileService fileService; private final StoredFileRepository storedFileRepository; - private final FileStorageProperties fileStorageProperties; @Override @Transactional @@ -103,33 +97,17 @@ public class DevBootstrapDataInitializer implements CommandLineRunner { if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), file.path(), file.filename())) { continue; } - - Path filePath = resolveFilePath(user.getId(), file.path(), file.filename()); - try { - Files.createDirectories(filePath.getParent()); - Files.writeString(filePath, file.content(), StandardCharsets.UTF_8); - } catch (IOException ex) { - throw new IllegalStateException("无法初始化开发样例文件: " + file.filename(), ex); - } - - StoredFile storedFile = new StoredFile(); - storedFile.setUser(user); - storedFile.setFilename(file.filename()); - storedFile.setPath(file.path()); - storedFile.setStorageName(file.filename()); - storedFile.setContentType(file.contentType()); - storedFile.setSize((long) file.content().getBytes(StandardCharsets.UTF_8).length); - storedFile.setDirectory(false); - storedFileRepository.save(storedFile); + fileService.importExternalFile( + user, + file.path(), + file.filename(), + file.contentType(), + file.content().getBytes(StandardCharsets.UTF_8).length, + file.content().getBytes(StandardCharsets.UTF_8) + ); } } - private Path resolveFilePath(Long userId, String path, String filename) { - Path rootPath = Path.of(fileStorageProperties.getRootDir()).toAbsolutePath().normalize(); - String normalizedPath = path.startsWith("/") ? path.substring(1) : path; - return rootPath.resolve(userId.toString()).resolve(normalizedPath).resolve(filename).normalize(); - } - private record DemoUserSpec( String username, String password, diff --git a/backend/src/main/java/com/yoyuzh/config/CorsProperties.java b/backend/src/main/java/com/yoyuzh/config/CorsProperties.java index e6eae38..ee9dac3 100644 --- a/backend/src/main/java/com/yoyuzh/config/CorsProperties.java +++ b/backend/src/main/java/com/yoyuzh/config/CorsProperties.java @@ -11,6 +11,11 @@ public class CorsProperties { private List allowedOrigins = new ArrayList<>(List.of( "http://localhost:3000", "http://127.0.0.1:3000", + "http://localhost", + "https://localhost", + "http://127.0.0.1", + "https://127.0.0.1", + "capacitor://localhost", "https://yoyuzh.xyz", "https://www.yoyuzh.xyz" )); diff --git a/backend/src/main/java/com/yoyuzh/config/JwtAuthenticationFilter.java b/backend/src/main/java/com/yoyuzh/config/JwtAuthenticationFilter.java index 8618018..63f1776 100644 --- a/backend/src/main/java/com/yoyuzh/config/JwtAuthenticationFilter.java +++ b/backend/src/main/java/com/yoyuzh/config/JwtAuthenticationFilter.java @@ -1,5 +1,6 @@ package com.yoyuzh.config; +import com.yoyuzh.admin.AdminMetricsService; import com.yoyuzh.auth.CustomUserDetailsService; import com.yoyuzh.auth.JwtTokenProvider; import com.yoyuzh.auth.User; @@ -24,6 +25,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; private final CustomUserDetailsService userDetailsService; + private final AdminMetricsService adminMetricsService; @Override protected void doFilterInternal(HttpServletRequest request, @@ -55,6 +57,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); + adminMetricsService.recordUserOnline(domainUser.getId(), domainUser.getUsername()); } } filterChain.doFilter(request, response); diff --git a/backend/src/main/java/com/yoyuzh/files/FileBlob.java b/backend/src/main/java/com/yoyuzh/files/FileBlob.java new file mode 100644 index 0000000..a9d52af --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/FileBlob.java @@ -0,0 +1,83 @@ +package com.yoyuzh.files; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "portal_file_blob", indexes = { + @Index(name = "uk_file_blob_object_key", columnList = "object_key", unique = true), + @Index(name = "idx_file_blob_created_at", columnList = "created_at") +}) +public class FileBlob { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "object_key", nullable = false, length = 512, unique = true) + private String objectKey; + + @Column(name = "content_type", length = 255) + private String contentType; + + @Column(nullable = false) + private Long size; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @PrePersist + public void prePersist() { + if (createdAt == null) { + createdAt = LocalDateTime.now(); + } + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getObjectKey() { + return objectKey; + } + + public void setObjectKey(String objectKey) { + this.objectKey = objectKey; + } + + public String getContentType() { + return contentType; + } + + public void setContentType(String contentType) { + this.contentType = contentType; + } + + public Long getSize() { + return size; + } + + public void setSize(Long size) { + this.size = size; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/FileBlobBackfillService.java b/backend/src/main/java/com/yoyuzh/files/FileBlobBackfillService.java new file mode 100644 index 0000000..4805be2 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/FileBlobBackfillService.java @@ -0,0 +1,53 @@ +package com.yoyuzh.files; + +import com.yoyuzh.files.storage.FileContentStorage; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.CommandLineRunner; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +@Component +@Order(0) +@RequiredArgsConstructor +public class FileBlobBackfillService implements CommandLineRunner { + + private final StoredFileRepository storedFileRepository; + private final FileBlobRepository fileBlobRepository; + private final FileContentStorage fileContentStorage; + + @Override + @Transactional + public void run(String... args) { + backfillMissingBlobs(); + } + + @Transactional + public void backfillMissingBlobs() { + for (StoredFile storedFile : storedFileRepository.findAllByDirectoryFalseAndBlobIsNull()) { + String legacyStorageName = storedFile.getLegacyStorageName(); + if (!StringUtils.hasText(legacyStorageName)) { + throw new IllegalStateException("文件缺少 blob 引用且没有 legacy storage_name: " + storedFile.getId()); + } + + String objectKey = fileContentStorage.resolveLegacyFileObjectKey( + storedFile.getUser().getId(), + storedFile.getPath(), + legacyStorageName + ); + FileBlob blob = fileBlobRepository.findByObjectKey(objectKey) + .orElseGet(() -> createBlob(storedFile, objectKey)); + storedFile.setBlob(blob); + storedFileRepository.save(storedFile); + } + } + + private FileBlob createBlob(StoredFile storedFile, String objectKey) { + FileBlob blob = new FileBlob(); + blob.setObjectKey(objectKey); + blob.setContentType(storedFile.getContentType()); + blob.setSize(storedFile.getSize()); + return fileBlobRepository.save(blob); + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/FileBlobRepository.java b/backend/src/main/java/com/yoyuzh/files/FileBlobRepository.java new file mode 100644 index 0000000..df3aeab --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/FileBlobRepository.java @@ -0,0 +1,17 @@ +package com.yoyuzh.files; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.Optional; + +public interface FileBlobRepository extends JpaRepository { + + Optional findByObjectKey(String objectKey); + + @Query(""" + select coalesce(sum(b.size), 0) + from FileBlob b + """) + long sumAllBlobSize(); +} diff --git a/backend/src/main/java/com/yoyuzh/files/FileService.java b/backend/src/main/java/com/yoyuzh/files/FileService.java index 82edd00..6b6d049 100644 --- a/backend/src/main/java/com/yoyuzh/files/FileService.java +++ b/backend/src/main/java/com/yoyuzh/files/FileService.java @@ -25,8 +25,10 @@ import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Comparator; +import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.zip.ZipEntry; @@ -37,17 +39,20 @@ public class FileService { private static final List DEFAULT_DIRECTORIES = List.of("下载", "文档", "图片"); private final StoredFileRepository storedFileRepository; + private final FileBlobRepository fileBlobRepository; private final FileContentStorage fileContentStorage; private final FileShareLinkRepository fileShareLinkRepository; private final AdminMetricsService adminMetricsService; private final long maxFileSize; public FileService(StoredFileRepository storedFileRepository, + FileBlobRepository fileBlobRepository, FileContentStorage fileContentStorage, FileShareLinkRepository fileShareLinkRepository, AdminMetricsService adminMetricsService, FileStorageProperties properties) { this.storedFileRepository = storedFileRepository; + this.fileBlobRepository = fileBlobRepository; this.fileContentStorage = fileContentStorage; this.fileShareLinkRepository = fileShareLinkRepository; this.adminMetricsService = adminMetricsService; @@ -61,8 +66,12 @@ public class FileService { validateUpload(user, normalizedPath, filename, multipartFile.getSize()); ensureDirectoryHierarchy(user, normalizedPath); - fileContentStorage.upload(user.getId(), normalizedPath, filename, multipartFile); - return saveFileMetadata(user, normalizedPath, filename, filename, multipartFile.getContentType(), multipartFile.getSize()); + String objectKey = createBlobObjectKey(); + return executeAfterBlobStored(objectKey, () -> { + fileContentStorage.uploadBlob(objectKey, multipartFile); + FileBlob blob = createAndSaveBlob(objectKey, multipartFile.getContentType(), multipartFile.getSize()); + return saveFileMetadata(user, normalizedPath, filename, multipartFile.getContentType(), multipartFile.getSize(), blob); + }); } public InitiateUploadResponse initiateUpload(User user, InitiateUploadRequest request) { @@ -70,10 +79,11 @@ public class FileService { String filename = normalizeLeafName(request.filename()); validateUpload(user, normalizedPath, filename, request.size()); - PreparedUpload preparedUpload = fileContentStorage.prepareUpload( - user.getId(), + String objectKey = createBlobObjectKey(); + PreparedUpload preparedUpload = fileContentStorage.prepareBlobUpload( normalizedPath, filename, + objectKey, request.contentType(), request.size() ); @@ -91,12 +101,15 @@ public class FileService { public FileMetadataResponse completeUpload(User user, CompleteUploadRequest request) { String normalizedPath = normalizeDirectoryPath(request.path()); String filename = normalizeLeafName(request.filename()); - String storageName = normalizeLeafName(request.storageName()); + String objectKey = normalizeBlobObjectKey(request.storageName()); validateUpload(user, normalizedPath, filename, request.size()); ensureDirectoryHierarchy(user, normalizedPath); - fileContentStorage.completeUpload(user.getId(), normalizedPath, storageName, request.contentType(), request.size()); - return saveFileMetadata(user, normalizedPath, filename, storageName, request.contentType(), request.size()); + return executeAfterBlobStored(objectKey, () -> { + fileContentStorage.completeBlobUpload(objectKey, request.contentType(), request.size()); + FileBlob blob = createAndSaveBlob(objectKey, request.contentType(), request.size()); + return saveFileMetadata(user, normalizedPath, filename, request.contentType(), request.size(), blob); + }); } @Transactional @@ -117,7 +130,6 @@ public class FileService { storedFile.setUser(user); storedFile.setFilename(directoryName); storedFile.setPath(parentPath); - storedFile.setStorageName(directoryName); storedFile.setContentType("directory"); storedFile.setSize(0L); storedFile.setDirectory(true); @@ -153,7 +165,6 @@ public class FileService { storedFile.setUser(user); storedFile.setFilename(directoryName); storedFile.setPath("/"); - storedFile.setStorageName(directoryName); storedFile.setContentType("directory"); storedFile.setSize(0L); storedFile.setDirectory(true); @@ -164,17 +175,20 @@ public class FileService { @Transactional public void delete(User user, Long fileId) { StoredFile storedFile = getOwnedFile(user, fileId, "删除"); + List filesToDelete = new ArrayList<>(); if (storedFile.isDirectory()) { String logicalPath = buildLogicalPath(storedFile); List descendants = storedFileRepository.findByUserIdAndPathEqualsOrDescendant(user.getId(), logicalPath); - fileContentStorage.deleteDirectory(user.getId(), logicalPath, descendants); + filesToDelete.addAll(descendants.stream().filter(descendant -> !descendant.isDirectory()).toList()); if (!descendants.isEmpty()) { storedFileRepository.deleteAll(descendants); } } else { - fileContentStorage.deleteFile(user.getId(), storedFile.getPath(), storedFile.getStorageName()); + filesToDelete.add(storedFile); } + List blobsToDelete = collectBlobsToDelete(filesToDelete); storedFileRepository.delete(storedFile); + deleteBlobs(blobsToDelete); } @Transactional @@ -195,7 +209,6 @@ public class FileService { : storedFile.getPath() + "/" + sanitizedFilename; List descendants = storedFileRepository.findByUserIdAndPathEqualsOrDescendant(user.getId(), oldLogicalPath); - fileContentStorage.renameDirectory(user.getId(), oldLogicalPath, newLogicalPath, descendants); for (StoredFile descendant : descendants) { if (descendant.getPath().equals(oldLogicalPath)) { descendant.setPath(newLogicalPath); @@ -207,12 +220,9 @@ public class FileService { if (!descendants.isEmpty()) { storedFileRepository.saveAll(descendants); } - } else { - fileContentStorage.renameFile(user.getId(), storedFile.getPath(), storedFile.getStorageName(), sanitizedFilename); } storedFile.setFilename(sanitizedFilename); - storedFile.setStorageName(sanitizedFilename); return toResponse(storedFileRepository.save(storedFile)); } @@ -239,7 +249,6 @@ public class FileService { } List descendants = storedFileRepository.findByUserIdAndPathEqualsOrDescendant(user.getId(), oldLogicalPath); - fileContentStorage.renameDirectory(user.getId(), oldLogicalPath, newLogicalPath, descendants); for (StoredFile descendant : descendants) { if (descendant.getPath().equals(oldLogicalPath)) { descendant.setPath(newLogicalPath); @@ -251,8 +260,6 @@ public class FileService { if (!descendants.isEmpty()) { storedFileRepository.saveAll(descendants); } - } else { - fileContentStorage.moveFile(user.getId(), storedFile.getPath(), normalizedTargetPath, storedFile.getStorageName()); } storedFile.setPath(normalizedTargetPath); @@ -270,8 +277,7 @@ public class FileService { if (!storedFile.isDirectory()) { ensureWithinStorageQuota(user, storedFile.getSize()); - fileContentStorage.copyFile(user.getId(), storedFile.getPath(), normalizedTargetPath, storedFile.getStorageName()); - return toResponse(storedFileRepository.save(copyStoredFile(storedFile, normalizedTargetPath))); + return toResponse(storedFileRepository.save(copyStoredFile(storedFile, user, normalizedTargetPath))); } String oldLogicalPath = buildLogicalPath(storedFile); @@ -288,8 +294,7 @@ public class FileService { ensureWithinStorageQuota(user, additionalBytes); List copiedEntries = new ArrayList<>(); - fileContentStorage.ensureDirectory(user.getId(), newLogicalPath); - StoredFile copiedRoot = copyStoredFile(storedFile, normalizedTargetPath); + StoredFile copiedRoot = copyStoredFile(storedFile, user, normalizedTargetPath); copiedEntries.add(copiedRoot); descendants.stream() @@ -303,12 +308,7 @@ public class FileService { throw new BusinessException(ErrorCode.UNKNOWN, "目标目录已存在同名文件"); } - if (descendant.isDirectory()) { - fileContentStorage.ensureDirectory(user.getId(), buildTargetLogicalPath(copiedPath, descendant.getFilename())); - } else { - fileContentStorage.copyFile(user.getId(), descendant.getPath(), copiedPath, descendant.getStorageName()); - } - copiedEntries.add(copyStoredFile(descendant, copiedPath)); + copiedEntries.add(copyStoredFile(descendant, user, copiedPath)); }); StoredFile savedRoot = null; @@ -329,10 +329,8 @@ public class FileService { if (fileContentStorage.supportsDirectDownload()) { return ResponseEntity.status(302) - .location(URI.create(fileContentStorage.createDownloadUrl( - user.getId(), - storedFile.getPath(), - storedFile.getStorageName(), + .location(URI.create(fileContentStorage.createBlobDownloadUrl( + getRequiredBlob(storedFile).getObjectKey(), storedFile.getFilename()))) .build(); } @@ -342,7 +340,7 @@ public class FileService { "attachment; filename*=UTF-8''" + URLEncoder.encode(storedFile.getFilename(), StandardCharsets.UTF_8)) .contentType(MediaType.parseMediaType( storedFile.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM_VALUE : storedFile.getContentType())) - .body(fileContentStorage.readFile(user.getId(), storedFile.getPath(), storedFile.getStorageName())); + .body(fileContentStorage.readBlob(getRequiredBlob(storedFile).getObjectKey())); } public DownloadUrlResponse getDownloadUrl(User user, Long fileId) { @@ -353,10 +351,8 @@ public class FileService { adminMetricsService.recordDownloadTraffic(storedFile.getSize()); if (fileContentStorage.supportsDirectDownload()) { - return new DownloadUrlResponse(fileContentStorage.createDownloadUrl( - user.getId(), - storedFile.getPath(), - storedFile.getStorageName(), + return new DownloadUrlResponse(fileContentStorage.createBlobDownloadUrl( + getRequiredBlob(storedFile).getObjectKey(), storedFile.getFilename() )); } @@ -407,19 +403,13 @@ public class FileService { if (sourceFile.isDirectory()) { throw new BusinessException(ErrorCode.UNKNOWN, "目录暂不支持导入"); } - - byte[] content = fileContentStorage.readFile( - sourceFile.getUser().getId(), - sourceFile.getPath(), - sourceFile.getStorageName() - ); - return importExternalFile( + return importReferencedBlob( recipient, path, sourceFile.getFilename(), sourceFile.getContentType(), sourceFile.getSize(), - content + getRequiredBlob(sourceFile) ); } @@ -434,22 +424,20 @@ public class FileService { String normalizedFilename = normalizeLeafName(filename); validateUpload(recipient, normalizedPath, normalizedFilename, size); ensureDirectoryHierarchy(recipient, normalizedPath); - fileContentStorage.storeImportedFile( - recipient.getId(), - normalizedPath, - normalizedFilename, - contentType, - content - ); + String objectKey = createBlobObjectKey(); + return executeAfterBlobStored(objectKey, () -> { + fileContentStorage.storeBlob(objectKey, contentType, content); + FileBlob blob = createAndSaveBlob(objectKey, contentType, size); - return saveFileMetadata( - recipient, - normalizedPath, - normalizedFilename, - normalizedFilename, - contentType, - size - ); + return saveFileMetadata( + recipient, + normalizedPath, + normalizedFilename, + contentType, + size, + blob + ); + }); } private ResponseEntity downloadDirectory(User user, StoredFile directory) { @@ -475,7 +463,7 @@ public class FileService { ensureParentDirectoryEntries(zipOutputStream, createdEntries, entryName); writeFileEntry(zipOutputStream, createdEntries, entryName, - fileContentStorage.readFile(user.getId(), descendant.getPath(), descendant.getStorageName())); + fileContentStorage.readBlob(getRequiredBlob(descendant).getObjectKey())); } zipOutputStream.finish(); archiveBytes = outputStream.toByteArray(); @@ -493,17 +481,17 @@ public class FileService { private FileMetadataResponse saveFileMetadata(User user, String normalizedPath, String filename, - String storageName, String contentType, - long size) { + long size, + FileBlob blob) { StoredFile storedFile = new StoredFile(); storedFile.setUser(user); storedFile.setFilename(filename); storedFile.setPath(normalizedPath); - storedFile.setStorageName(storageName); storedFile.setContentType(contentType); storedFile.setSize(size); storedFile.setDirectory(false); + storedFile.setBlob(blob); return toResponse(storedFileRepository.save(storedFile)); } @@ -513,7 +501,7 @@ public class FileService { } private StoredFile getOwnedFile(User user, Long fileId, String action) { - StoredFile storedFile = storedFileRepository.findById(fileId) + StoredFile storedFile = storedFileRepository.findDetailedById(fileId) .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件不存在")); if (!storedFile.getUser().getId().equals(user.getId())) { throw new BusinessException(ErrorCode.PERMISSION_DENIED, "没有权限" + action + "该文件"); @@ -565,7 +553,6 @@ public class FileService { storedFile.setUser(user); storedFile.setFilename(segment); storedFile.setPath(currentPath); - storedFile.setStorageName(segment); storedFile.setContentType("directory"); storedFile.setSize(0L); storedFile.setDirectory(true); @@ -658,15 +645,15 @@ public class FileService { return newLogicalPath + currentPath.substring(oldLogicalPath.length()); } - private StoredFile copyStoredFile(StoredFile source, String nextPath) { + private StoredFile copyStoredFile(StoredFile source, User owner, String nextPath) { StoredFile copiedFile = new StoredFile(); - copiedFile.setUser(source.getUser()); + copiedFile.setUser(owner); copiedFile.setFilename(source.getFilename()); copiedFile.setPath(nextPath); - copiedFile.setStorageName(source.getStorageName()); copiedFile.setContentType(source.getContentType()); copiedFile.setSize(source.getSize()); copiedFile.setDirectory(source.isDirectory()); + copiedFile.setBlob(source.getBlob()); return copiedFile; } @@ -717,4 +704,108 @@ public class FileService { } return cleaned; } + + private String createBlobObjectKey() { + return "blobs/" + UUID.randomUUID(); + } + + private String normalizeBlobObjectKey(String objectKey) { + String cleaned = StringUtils.cleanPath(objectKey == null ? "" : objectKey).trim().replace("\\", "/"); + if (!StringUtils.hasText(cleaned) || cleaned.contains("..") || cleaned.startsWith("/") || !cleaned.startsWith("blobs/")) { + throw new BusinessException(ErrorCode.UNKNOWN, "上传对象标识不合法"); + } + return cleaned; + } + + private T executeAfterBlobStored(String objectKey, BlobWriteOperation operation) { + try { + return operation.run(); + } catch (RuntimeException ex) { + try { + fileContentStorage.deleteBlob(objectKey); + } catch (RuntimeException cleanupEx) { + ex.addSuppressed(cleanupEx); + } + throw ex; + } + } + + private FileBlob createAndSaveBlob(String objectKey, String contentType, long size) { + FileBlob blob = new FileBlob(); + blob.setObjectKey(objectKey); + blob.setContentType(contentType); + blob.setSize(size); + return fileBlobRepository.save(blob); + } + + private FileMetadataResponse importReferencedBlob(User recipient, + String path, + String filename, + String contentType, + long size, + FileBlob blob) { + String normalizedPath = normalizeDirectoryPath(path); + String normalizedFilename = normalizeLeafName(filename); + validateUpload(recipient, normalizedPath, normalizedFilename, size); + ensureDirectoryHierarchy(recipient, normalizedPath); + return saveFileMetadata( + recipient, + normalizedPath, + normalizedFilename, + contentType, + size, + blob + ); + } + + private FileBlob getRequiredBlob(StoredFile storedFile) { + if (storedFile.isDirectory() || storedFile.getBlob() == null) { + throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件内容不存在"); + } + return storedFile.getBlob(); + } + + private List collectBlobsToDelete(List filesToDelete) { + Map candidates = new HashMap<>(); + for (StoredFile file : filesToDelete) { + if (file.getBlob() == null || file.getBlob().getId() == null) { + continue; + } + BlobDeletionCandidate candidate = candidates.computeIfAbsent( + file.getBlob().getId(), + ignored -> new BlobDeletionCandidate(file.getBlob()) + ); + candidate.referencesToDelete += 1; + } + + List blobsToDelete = new ArrayList<>(); + for (BlobDeletionCandidate candidate : candidates.values()) { + long currentReferences = storedFileRepository.countByBlobId(candidate.blob.getId()); + if (currentReferences == candidate.referencesToDelete) { + blobsToDelete.add(candidate.blob); + } + } + return blobsToDelete; + } + + private void deleteBlobs(List blobsToDelete) { + for (FileBlob blob : blobsToDelete) { + fileContentStorage.deleteBlob(blob.getObjectKey()); + fileBlobRepository.delete(blob); + } + } + + private static final class BlobDeletionCandidate { + private final FileBlob blob; + private long referencesToDelete; + + private BlobDeletionCandidate(FileBlob blob) { + this.blob = blob; + } + } + + @FunctionalInterface + private interface BlobWriteOperation { + T run(); + } } diff --git a/backend/src/main/java/com/yoyuzh/files/StoredFile.java b/backend/src/main/java/com/yoyuzh/files/StoredFile.java index 705da05..0921716 100644 --- a/backend/src/main/java/com/yoyuzh/files/StoredFile.java +++ b/backend/src/main/java/com/yoyuzh/files/StoredFile.java @@ -36,8 +36,12 @@ public class StoredFile { @Column(nullable = false, length = 512) private String path; - @Column(name = "storage_name", nullable = false, length = 255) - private String storageName; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "blob_id") + private FileBlob blob; + + @Column(name = "storage_name", length = 255) + private String legacyStorageName; @Column(name = "content_type", length = 255) private String contentType; @@ -90,12 +94,20 @@ public class StoredFile { this.path = path; } - public String getStorageName() { - return storageName; + public FileBlob getBlob() { + return blob; } - public void setStorageName(String storageName) { - this.storageName = storageName; + public void setBlob(FileBlob blob) { + this.blob = blob; + } + + public String getLegacyStorageName() { + return legacyStorageName; + } + + public void setLegacyStorageName(String legacyStorageName) { + this.legacyStorageName = legacyStorageName; } public String getContentType() { diff --git a/backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java b/backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java index 8a0f6b6..7f584c8 100644 --- a/backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java +++ b/backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java @@ -12,10 +12,10 @@ import java.util.Optional; public interface StoredFileRepository extends JpaRepository { - @EntityGraph(attributePaths = "user") + @EntityGraph(attributePaths = {"user", "blob"}) Page findAllByOrderByCreatedAtDesc(Pageable pageable); - @EntityGraph(attributePaths = "user") + @EntityGraph(attributePaths = {"user", "blob"}) @Query(""" select f from StoredFile f join f.user u @@ -47,6 +47,7 @@ public interface StoredFileRepository extends JpaRepository { @Param("path") String path, @Param("filename") String filename); + @EntityGraph(attributePaths = "blob") @Query(""" select f from StoredFile f where f.user.id = :userId and f.path = :path @@ -56,6 +57,7 @@ public interface StoredFileRepository extends JpaRepository { @Param("path") String path, Pageable pageable); + @EntityGraph(attributePaths = "blob") @Query(""" select f from StoredFile f where f.user.id = :userId and (f.path = :path or f.path like concat(:path, '/%')) @@ -78,5 +80,22 @@ public interface StoredFileRepository extends JpaRepository { """) long sumAllFileSize(); + @EntityGraph(attributePaths = "blob") List findTop12ByUserIdAndDirectoryFalseOrderByCreatedAtDesc(Long userId); + + @Query(""" + select count(f) + from StoredFile f + where f.blob.id = :blobId + """) + long countByBlobId(@Param("blobId") Long blobId); + + @EntityGraph(attributePaths = {"user", "blob"}) + @Query(""" + select f from StoredFile f + where f.id = :id + """) + Optional findDetailedById(@Param("id") Long id); + + List findAllByDirectoryFalseAndBlobIsNull(); } diff --git a/backend/src/main/java/com/yoyuzh/transfer/TransferController.java b/backend/src/main/java/com/yoyuzh/transfer/TransferController.java index 8768c8b..15f2a36 100644 --- a/backend/src/main/java/com/yoyuzh/transfer/TransferController.java +++ b/backend/src/main/java/com/yoyuzh/transfer/TransferController.java @@ -41,16 +41,14 @@ public class TransferController { @Operation(summary = "通过取件码查找快传会话") @GetMapping("/sessions/lookup") - public ApiResponse lookupSession(@AuthenticationPrincipal UserDetails userDetails, - @RequestParam String pickupCode) { - return ApiResponse.success(transferService.lookupSession(userDetails != null, pickupCode)); + public ApiResponse lookupSession(@RequestParam String pickupCode) { + return ApiResponse.success(transferService.lookupSession(pickupCode)); } @Operation(summary = "加入快传会话") @PostMapping("/sessions/{sessionId}/join") - public ApiResponse joinSession(@AuthenticationPrincipal UserDetails userDetails, - @PathVariable String sessionId) { - return ApiResponse.success(transferService.joinSession(userDetails != null, sessionId)); + public ApiResponse joinSession(@PathVariable String sessionId) { + return ApiResponse.success(transferService.joinSession(sessionId)); } @Operation(summary = "查看当前用户的离线快传列表") @@ -80,10 +78,9 @@ public class TransferController { @Operation(summary = "下载离线快传文件") @GetMapping("/sessions/{sessionId}/files/{fileId}/download") - public ResponseEntity downloadOfflineFile(@AuthenticationPrincipal UserDetails userDetails, - @PathVariable String sessionId, + public ResponseEntity downloadOfflineFile(@PathVariable String sessionId, @PathVariable String fileId) { - return transferService.downloadOfflineFile(userDetails != null, sessionId, fileId); + return transferService.downloadOfflineFile(sessionId, fileId); } @Operation(summary = "把离线快传文件存入网盘") diff --git a/backend/src/main/java/com/yoyuzh/transfer/TransferService.java b/backend/src/main/java/com/yoyuzh/transfer/TransferService.java index 2a02d61..19cf282 100644 --- a/backend/src/main/java/com/yoyuzh/transfer/TransferService.java +++ b/backend/src/main/java/com/yoyuzh/transfer/TransferService.java @@ -67,7 +67,7 @@ public class TransferService { return createOnlineSession(request); } - public LookupTransferSessionResponse lookupSession(boolean authenticated, String pickupCode) { + public LookupTransferSessionResponse lookupSession(String pickupCode) { pruneExpiredSessions(); String normalizedPickupCode = normalizePickupCode(pickupCode); @@ -78,12 +78,11 @@ public class TransferService { OfflineTransferSession offlineSession = offlineTransferSessionRepository.findWithFilesByPickupCode(normalizedPickupCode) .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "取件码不存在或已失效")); - ensureAuthenticatedForOfflineTransfer(authenticated); validateOfflineReadySession(offlineSession, "取件码不存在或已失效"); return toLookupResponse(offlineSession); } - public TransferSessionResponse joinSession(boolean authenticated, String sessionId) { + public TransferSessionResponse joinSession(String sessionId) { pruneExpiredSessions(); TransferSession onlineSession = sessionStore.findById(sessionId).orElse(null); @@ -98,7 +97,6 @@ public class TransferService { OfflineTransferSession offlineSession = offlineTransferSessionRepository.findWithFilesBySessionId(sessionId) .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "快传会话不存在或已失效")); - ensureAuthenticatedForOfflineTransfer(authenticated); validateOfflineReadySession(offlineSession, "离线快传会话不存在或已失效"); return toSessionResponse(offlineSession); } @@ -171,9 +169,8 @@ public class TransferService { return session.poll(TransferRole.from(role), Math.max(0, after)); } - public ResponseEntity downloadOfflineFile(boolean authenticated, String sessionId, String fileId) { + public ResponseEntity downloadOfflineFile(String sessionId, String fileId) { pruneExpiredSessions(); - ensureAuthenticatedForOfflineTransfer(authenticated); OfflineTransferSession session = getRequiredOfflineReadySession(sessionId); OfflineTransferFile file = getRequiredOfflineFile(session, fileId); ensureOfflineFileUploaded(file); @@ -372,12 +369,6 @@ public class TransferService { return normalized; } - private void ensureAuthenticatedForOfflineTransfer(boolean authenticated) { - if (!authenticated) { - throw new BusinessException(ErrorCode.NOT_LOGGED_IN, "离线快传需要登录后使用"); - } - } - private void validateOfflineReadySession(OfflineTransferSession session, String notFoundMessage) { if (session.isExpired(Instant.now())) { throw new BusinessException(ErrorCode.FILE_NOT_FOUND, notFoundMessage); diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index ef6c77a..ecbd283 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -47,6 +47,11 @@ app: allowed-origins: - http://localhost:3000 - http://127.0.0.1:3000 + - http://localhost + - https://localhost + - http://127.0.0.1 + - https://127.0.0.1 + - capacitor://localhost - https://yoyuzh.xyz - https://www.yoyuzh.xyz diff --git a/backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java b/backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java index 4d970e6..e6640d8 100644 --- a/backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java +++ b/backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java @@ -4,6 +4,8 @@ import com.yoyuzh.PortalBackendApplication; import com.yoyuzh.admin.AdminMetricsStateRepository; import com.yoyuzh.auth.User; import com.yoyuzh.auth.UserRepository; +import com.yoyuzh.files.FileBlob; +import com.yoyuzh.files.FileBlobRepository; import com.yoyuzh.files.StoredFile; import com.yoyuzh.files.StoredFileRepository; import com.yoyuzh.transfer.OfflineTransferSessionRepository; @@ -17,6 +19,7 @@ import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; import java.time.LocalDateTime; +import java.time.LocalDate; import java.time.LocalTime; import static org.hamcrest.Matchers.greaterThanOrEqualTo; @@ -56,9 +59,13 @@ class AdminControllerIntegrationTest { @Autowired private StoredFileRepository storedFileRepository; @Autowired + private FileBlobRepository fileBlobRepository; + @Autowired private OfflineTransferSessionRepository offlineTransferSessionRepository; @Autowired private AdminMetricsStateRepository adminMetricsStateRepository; + @Autowired + private AdminMetricsService adminMetricsService; private User portalUser; private User secondaryUser; @@ -69,6 +76,7 @@ class AdminControllerIntegrationTest { void setUp() { offlineTransferSessionRepository.deleteAll(); storedFileRepository.deleteAll(); + fileBlobRepository.deleteAll(); userRepository.deleteAll(); adminMetricsStateRepository.deleteAll(); @@ -88,33 +96,47 @@ class AdminControllerIntegrationTest { secondaryUser.setCreatedAt(LocalDateTime.now().minusDays(1)); secondaryUser = userRepository.save(secondaryUser); + FileBlob reportBlob = createBlob("blobs/admin-report", "application/pdf", 1024L); storedFile = new StoredFile(); storedFile.setUser(portalUser); storedFile.setFilename("report.pdf"); storedFile.setPath("/"); - storedFile.setStorageName("report.pdf"); storedFile.setContentType("application/pdf"); storedFile.setSize(1024L); storedFile.setDirectory(false); + storedFile.setBlob(reportBlob); storedFile.setCreatedAt(LocalDateTime.now()); storedFile = storedFileRepository.save(storedFile); + FileBlob notesBlob = createBlob("blobs/admin-notes", "text/plain", 256L); secondaryFile = new StoredFile(); secondaryFile.setUser(secondaryUser); secondaryFile.setFilename("notes.txt"); secondaryFile.setPath("/docs"); - secondaryFile.setStorageName("notes.txt"); secondaryFile.setContentType("text/plain"); secondaryFile.setSize(256L); secondaryFile.setDirectory(false); + secondaryFile.setBlob(notesBlob); secondaryFile.setCreatedAt(LocalDateTime.now().minusHours(2)); secondaryFile = storedFileRepository.save(secondaryFile); } + private FileBlob createBlob(String objectKey, String contentType, long size) { + FileBlob blob = new FileBlob(); + blob.setObjectKey(objectKey); + blob.setContentType(contentType); + blob.setSize(size); + blob.setCreatedAt(LocalDateTime.now()); + return fileBlobRepository.save(blob); + } + @Test @WithMockUser(username = "admin") void shouldAllowConfiguredAdminToListUsersAndSummary() throws Exception { int currentHour = LocalTime.now().getHour(); + LocalDate today = LocalDate.now(); + adminMetricsService.recordUserOnline(portalUser.getId(), portalUser.getUsername()); + adminMetricsService.recordUserOnline(secondaryUser.getId(), secondaryUser.getUsername()); mockMvc.perform(get("/api/admin/users?page=0&size=10")) .andExpect(status().isOk()) @@ -134,13 +156,18 @@ class AdminControllerIntegrationTest { .andExpect(jsonPath("$.data.totalStorageBytes").value(1280L)) .andExpect(jsonPath("$.data.downloadTrafficBytes").value(0L)) .andExpect(jsonPath("$.data.requestCount", greaterThanOrEqualTo(1))) - .andExpect(jsonPath("$.data.requestTimeline.length()").value(24)) + .andExpect(jsonPath("$.data.requestTimeline.length()").value(currentHour + 1)) .andExpect(jsonPath("$.data.requestTimeline[" + currentHour + "].hour").value(currentHour)) .andExpect(jsonPath("$.data.requestTimeline[" + currentHour + "].label").value(String.format("%02d:00", currentHour))) .andExpect(jsonPath("$.data.requestTimeline[" + currentHour + "].requestCount", greaterThanOrEqualTo(1))) .andExpect(jsonPath("$.data.transferUsageBytes").value(0L)) .andExpect(jsonPath("$.data.offlineTransferStorageBytes").value(0L)) .andExpect(jsonPath("$.data.offlineTransferStorageLimitBytes").isNumber()) + .andExpect(jsonPath("$.data.dailyActiveUsers.length()").value(7)) + .andExpect(jsonPath("$.data.dailyActiveUsers[6].metricDate").value(today.toString())) + .andExpect(jsonPath("$.data.dailyActiveUsers[6].userCount").value(2)) + .andExpect(jsonPath("$.data.dailyActiveUsers[6].usernames[0]").value("alice")) + .andExpect(jsonPath("$.data.dailyActiveUsers[6].usernames[1]").value("bob")) .andExpect(jsonPath("$.data.inviteCode").isNotEmpty()); } diff --git a/backend/src/test/java/com/yoyuzh/admin/AdminMetricsServiceTest.java b/backend/src/test/java/com/yoyuzh/admin/AdminMetricsServiceTest.java index bb5c471..fb98d9d 100644 --- a/backend/src/test/java/com/yoyuzh/admin/AdminMetricsServiceTest.java +++ b/backend/src/test/java/com/yoyuzh/admin/AdminMetricsServiceTest.java @@ -12,6 +12,8 @@ import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -22,12 +24,18 @@ class AdminMetricsServiceTest { private AdminMetricsStateRepository adminMetricsStateRepository; @Mock private AdminRequestTimelinePointRepository adminRequestTimelinePointRepository; + @Mock + private AdminDailyActiveUserRepository adminDailyActiveUserRepository; private AdminMetricsService adminMetricsService; @BeforeEach void setUp() { - adminMetricsService = new AdminMetricsService(adminMetricsStateRepository, adminRequestTimelinePointRepository); + adminMetricsService = new AdminMetricsService( + adminMetricsStateRepository, + adminRequestTimelinePointRepository, + adminDailyActiveUserRepository + ); } @Test @@ -41,15 +49,21 @@ class AdminMetricsServiceTest { when(adminMetricsStateRepository.findById(1L)).thenReturn(Optional.of(state)); when(adminMetricsStateRepository.save(any(AdminMetricsState.class))).thenAnswer(invocation -> invocation.getArgument(0)); when(adminRequestTimelinePointRepository.findAllByMetricDateOrderByHourAsc(LocalDate.now())).thenReturn(java.util.List.of()); + when(adminDailyActiveUserRepository.findAllByMetricDateBetweenOrderByMetricDateAscUsernameAsc(LocalDate.now().minusDays(6), LocalDate.now())) + .thenReturn(java.util.List.of()); AdminMetricsSnapshot snapshot = adminMetricsService.getSnapshot(); assertThat(snapshot.requestCount()).isZero(); assertThat(state.getRequestCount()).isZero(); assertThat(state.getRequestCountDate()).isEqualTo(LocalDate.now()); - assertThat(snapshot.requestTimeline()).hasSize(24); + assertThat(snapshot.requestTimeline()).hasSize(LocalTime.now().getHour() + 1); assertThat(snapshot.requestTimeline().get(0)).isEqualTo(new AdminRequestTimelinePoint(0, "00:00", 0L)); + assertThat(snapshot.dailyActiveUsers()).hasSize(7); + assertThat(snapshot.dailyActiveUsers().get(6).metricDate()).isEqualTo(LocalDate.now()); + assertThat(snapshot.dailyActiveUsers().get(6).userCount()).isZero(); verify(adminMetricsStateRepository).save(state); + verify(adminDailyActiveUserRepository).deleteAllByMetricDateBefore(LocalDate.now().minusDays(6)); } @Test @@ -76,4 +90,46 @@ class AdminMetricsServiceTest { verify(adminMetricsStateRepository).save(state); verify(adminRequestTimelinePointRepository).save(any(AdminRequestTimelinePointEntity.class)); } + + @Test + void shouldRecordUniqueDailyActiveUserAndBuildSevenDayHistory() { + LocalDate today = LocalDate.now(); + AdminDailyActiveUserEntity existing = new AdminDailyActiveUserEntity(); + existing.setMetricDate(today); + existing.setUserId(7L); + existing.setUsername("alice"); + + AdminDailyActiveUserEntity yesterday = new AdminDailyActiveUserEntity(); + yesterday.setMetricDate(today.minusDays(1)); + yesterday.setUserId(8L); + yesterday.setUsername("bob"); + + when(adminDailyActiveUserRepository.findByMetricDateAndUserIdForUpdate(today, 7L)).thenReturn(Optional.of(existing)); + when(adminDailyActiveUserRepository.findAllByMetricDateBetweenOrderByMetricDateAscUsernameAsc(today.minusDays(6), today)) + .thenReturn(java.util.List.of(yesterday, existing)); + when(adminMetricsStateRepository.findById(1L)).thenReturn(Optional.of(createCurrentState(today))); + when(adminRequestTimelinePointRepository.findAllByMetricDateOrderByHourAsc(today)).thenReturn(java.util.List.of()); + + adminMetricsService.recordUserOnline(7L, "alice"); + AdminMetricsSnapshot snapshot = adminMetricsService.getSnapshot(); + + assertThat(snapshot.dailyActiveUsers()).hasSize(7); + assertThat(snapshot.dailyActiveUsers().get(5).metricDate()).isEqualTo(today.minusDays(1)); + assertThat(snapshot.dailyActiveUsers().get(5).userCount()).isEqualTo(1L); + assertThat(snapshot.dailyActiveUsers().get(5).usernames()).containsExactly("bob"); + assertThat(snapshot.dailyActiveUsers().get(6).metricDate()).isEqualTo(today); + assertThat(snapshot.dailyActiveUsers().get(6).userCount()).isEqualTo(1L); + assertThat(snapshot.dailyActiveUsers().get(6).usernames()).containsExactly("alice"); + verify(adminDailyActiveUserRepository, never()).save(any(AdminDailyActiveUserEntity.class)); + verify(adminDailyActiveUserRepository, times(2)).deleteAllByMetricDateBefore(today.minusDays(6)); + } + + private AdminMetricsState createCurrentState(LocalDate metricDate) { + AdminMetricsState state = new AdminMetricsState(); + state.setId(1L); + state.setRequestCount(0L); + state.setRequestCountDate(metricDate); + state.setOfflineTransferStorageLimitBytes(20L * 1024 * 1024 * 1024); + return state; + } } diff --git a/backend/src/test/java/com/yoyuzh/admin/AdminServiceTest.java b/backend/src/test/java/com/yoyuzh/admin/AdminServiceTest.java index a855a71..7bafcbd 100644 --- a/backend/src/test/java/com/yoyuzh/admin/AdminServiceTest.java +++ b/backend/src/test/java/com/yoyuzh/admin/AdminServiceTest.java @@ -8,6 +8,7 @@ import com.yoyuzh.auth.UserRepository; import com.yoyuzh.auth.UserRole; import com.yoyuzh.common.BusinessException; import com.yoyuzh.common.PageResponse; +import com.yoyuzh.files.FileBlobRepository; import com.yoyuzh.files.FileService; import com.yoyuzh.files.StoredFile; import com.yoyuzh.files.StoredFileRepository; @@ -42,6 +43,8 @@ class AdminServiceTest { @Mock private StoredFileRepository storedFileRepository; @Mock + private FileBlobRepository fileBlobRepository; + @Mock private FileService fileService; @Mock private PasswordEncoder passwordEncoder; @@ -59,7 +62,7 @@ class AdminServiceTest { @BeforeEach void setUp() { adminService = new AdminService( - userRepository, storedFileRepository, fileService, + userRepository, storedFileRepository, fileBlobRepository, fileService, passwordEncoder, refreshTokenService, registrationInviteService, offlineTransferSessionRepository, adminMetricsService); } @@ -70,12 +73,16 @@ class AdminServiceTest { void shouldReturnSummaryWithCountsAndInviteCode() { when(userRepository.count()).thenReturn(5L); when(storedFileRepository.count()).thenReturn(42L); - when(storedFileRepository.sumAllFileSize()).thenReturn(8192L); + when(fileBlobRepository.sumAllBlobSize()).thenReturn(8192L); when(adminMetricsService.getSnapshot()).thenReturn(new AdminMetricsSnapshot( 0L, 0L, 0L, 20L * 1024 * 1024 * 1024, + List.of( + new AdminDailyActiveUserSummary(LocalDateTime.now().toLocalDate().minusDays(1), "昨天", 1L, List.of("alice")), + new AdminDailyActiveUserSummary(LocalDateTime.now().toLocalDate(), "今天", 2L, List.of("alice", "bob")) + ), List.of( new AdminRequestTimelinePoint(0, "00:00", 0L), new AdminRequestTimelinePoint(1, "01:00", 3L) @@ -94,6 +101,10 @@ class AdminServiceTest { assertThat(summary.transferUsageBytes()).isZero(); assertThat(summary.offlineTransferStorageBytes()).isZero(); assertThat(summary.offlineTransferStorageLimitBytes()).isGreaterThan(0L); + assertThat(summary.dailyActiveUsers()).containsExactly( + new AdminDailyActiveUserSummary(LocalDateTime.now().toLocalDate().minusDays(1), "昨天", 1L, List.of("alice")), + new AdminDailyActiveUserSummary(LocalDateTime.now().toLocalDate(), "今天", 2L, List.of("alice", "bob")) + ); assertThat(summary.requestTimeline()).containsExactly( new AdminRequestTimelinePoint(0, "00:00", 0L), new AdminRequestTimelinePoint(1, "01:00", 3L) diff --git a/backend/src/test/java/com/yoyuzh/auth/DevBootstrapDataInitializerTest.java b/backend/src/test/java/com/yoyuzh/auth/DevBootstrapDataInitializerTest.java index b656a07..cc1ff46 100644 --- a/backend/src/test/java/com/yoyuzh/auth/DevBootstrapDataInitializerTest.java +++ b/backend/src/test/java/com/yoyuzh/auth/DevBootstrapDataInitializerTest.java @@ -1,17 +1,14 @@ package com.yoyuzh.auth; -import com.yoyuzh.config.FileStorageProperties; import com.yoyuzh.files.FileService; import com.yoyuzh.files.StoredFileRepository; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.crypto.password.PasswordEncoder; -import java.nio.file.Path; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -39,15 +36,9 @@ class DevBootstrapDataInitializerTest { @Mock private StoredFileRepository storedFileRepository; - @Mock - private FileStorageProperties fileStorageProperties; - @InjectMocks private DevBootstrapDataInitializer initializer; - @TempDir - Path tempDir; - @Test void shouldCreateInitialDevUsersWhenMissing() throws Exception { when(userRepository.findByUsername("portal-demo")).thenReturn(Optional.empty()); @@ -57,7 +48,6 @@ class DevBootstrapDataInitializerTest { when(passwordEncoder.encode("study123456")).thenReturn("encoded-study-password"); when(passwordEncoder.encode("design123456")).thenReturn("encoded-design-password"); when(storedFileRepository.existsByUserIdAndPathAndFilename(anyLong(), anyString(), anyString())).thenReturn(false); - when(fileStorageProperties.getRootDir()).thenReturn(tempDir.toString()); List savedUsers = new ArrayList<>(); when(userRepository.save(any(User.class))).thenAnswer(invocation -> { User user = invocation.getArgument(0); @@ -71,9 +61,9 @@ class DevBootstrapDataInitializerTest { verify(userRepository, times(3)).save(any(User.class)); verify(fileService, times(3)).ensureDefaultDirectories(any(User.class)); + verify(fileService, times(9)).importExternalFile(any(User.class), anyString(), anyString(), anyString(), anyLong(), any()); org.assertj.core.api.Assertions.assertThat(savedUsers) .extracting(User::getUsername) .containsExactly("portal-demo", "portal-study", "portal-design"); - verify(storedFileRepository, times(9)).save(any()); } } diff --git a/backend/src/test/java/com/yoyuzh/config/JwtAuthenticationFilterTest.java b/backend/src/test/java/com/yoyuzh/config/JwtAuthenticationFilterTest.java index d135a02..9536275 100644 --- a/backend/src/test/java/com/yoyuzh/config/JwtAuthenticationFilterTest.java +++ b/backend/src/test/java/com/yoyuzh/config/JwtAuthenticationFilterTest.java @@ -1,5 +1,6 @@ package com.yoyuzh.config; +import com.yoyuzh.admin.AdminMetricsService; import com.yoyuzh.auth.CustomUserDetailsService; import com.yoyuzh.auth.JwtTokenProvider; import com.yoyuzh.auth.User; @@ -33,13 +34,15 @@ class JwtAuthenticationFilterTest { @Mock private CustomUserDetailsService userDetailsService; @Mock + private AdminMetricsService adminMetricsService; + @Mock private FilterChain filterChain; private JwtAuthenticationFilter filter; @BeforeEach void setUp() { - filter = new JwtAuthenticationFilter(jwtTokenProvider, userDetailsService); + filter = new JwtAuthenticationFilter(jwtTokenProvider, userDetailsService, adminMetricsService); SecurityContextHolder.clearContext(); } @@ -159,6 +162,7 @@ class JwtAuthenticationFilterTest { verify(filterChain).doFilter(request, response); assertThat(SecurityContextHolder.getContext().getAuthentication()).isNotNull(); assertThat(SecurityContextHolder.getContext().getAuthentication().getName()).isEqualTo("alice"); + verify(adminMetricsService).recordUserOnline(1L, "alice"); } private User createDomainUser(String username, String sessionId) { diff --git a/backend/src/test/java/com/yoyuzh/config/SecurityConfigTest.java b/backend/src/test/java/com/yoyuzh/config/SecurityConfigTest.java index 723905a..a8a0647 100644 --- a/backend/src/test/java/com/yoyuzh/config/SecurityConfigTest.java +++ b/backend/src/test/java/com/yoyuzh/config/SecurityConfigTest.java @@ -15,7 +15,15 @@ class SecurityConfigTest { CorsProperties corsProperties = new CorsProperties(); assertThat(corsProperties.getAllowedOrigins()) - .contains("https://yoyuzh.xyz", "https://www.yoyuzh.xyz"); + .contains( + "http://localhost", + "https://localhost", + "http://127.0.0.1", + "https://127.0.0.1", + "capacitor://localhost", + "https://yoyuzh.xyz", + "https://www.yoyuzh.xyz" + ); } @Test diff --git a/backend/src/test/java/com/yoyuzh/files/FileBlobBackfillServiceTest.java b/backend/src/test/java/com/yoyuzh/files/FileBlobBackfillServiceTest.java new file mode 100644 index 0000000..3e7bae7 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/files/FileBlobBackfillServiceTest.java @@ -0,0 +1,89 @@ +package com.yoyuzh.files; + +import com.yoyuzh.auth.User; +import com.yoyuzh.files.storage.FileContentStorage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class FileBlobBackfillServiceTest { + + @Mock + private StoredFileRepository storedFileRepository; + @Mock + private FileBlobRepository fileBlobRepository; + @Mock + private FileContentStorage fileContentStorage; + + private FileBlobBackfillService backfillService; + + @BeforeEach + void setUp() { + backfillService = new FileBlobBackfillService(storedFileRepository, fileBlobRepository, fileContentStorage); + } + + @Test + void shouldCreateMissingBlobFromLegacyStorageName() { + StoredFile legacyFile = createLegacyFile(10L, 7L, "/docs", "notes.txt", "notes.txt"); + when(storedFileRepository.findAllByDirectoryFalseAndBlobIsNull()).thenReturn(java.util.List.of(legacyFile)); + when(fileContentStorage.resolveLegacyFileObjectKey(7L, "/docs", "notes.txt")).thenReturn("users/7/docs/notes.txt"); + when(fileBlobRepository.findByObjectKey("users/7/docs/notes.txt")).thenReturn(Optional.empty()); + when(fileBlobRepository.save(any(FileBlob.class))).thenAnswer(invocation -> { + FileBlob blob = invocation.getArgument(0); + blob.setId(100L); + return blob; + }); + + backfillService.backfillMissingBlobs(); + + verify(fileBlobRepository).save(any(FileBlob.class)); + verify(storedFileRepository).save(legacyFile); + } + + @Test + void shouldReuseExistingBlobWhenObjectKeyAlreadyBackfilled() { + StoredFile legacyFile = createLegacyFile(11L, 8L, "/docs", "report.pdf", "report.pdf"); + FileBlob existingBlob = new FileBlob(); + existingBlob.setId(101L); + existingBlob.setObjectKey("users/8/docs/report.pdf"); + existingBlob.setContentType("application/pdf"); + existingBlob.setSize(5L); + when(storedFileRepository.findAllByDirectoryFalseAndBlobIsNull()).thenReturn(java.util.List.of(legacyFile)); + when(fileContentStorage.resolveLegacyFileObjectKey(8L, "/docs", "report.pdf")).thenReturn("users/8/docs/report.pdf"); + when(fileBlobRepository.findByObjectKey("users/8/docs/report.pdf")).thenReturn(Optional.of(existingBlob)); + + backfillService.backfillMissingBlobs(); + + verify(fileBlobRepository, never()).save(any(FileBlob.class)); + verify(storedFileRepository).save(legacyFile); + } + + private StoredFile createLegacyFile(Long id, Long userId, String path, String filename, String legacyStorageName) { + User user = new User(); + user.setId(userId); + user.setUsername("user-" + userId); + + StoredFile file = new StoredFile(); + file.setId(id); + file.setUser(user); + file.setPath(path); + file.setFilename(filename); + file.setLegacyStorageName(legacyStorageName); + file.setContentType("application/pdf"); + file.setSize(5L); + file.setDirectory(false); + file.setCreatedAt(LocalDateTime.now()); + return file; + } +} diff --git a/backend/src/test/java/com/yoyuzh/files/FileServiceEdgeCaseTest.java b/backend/src/test/java/com/yoyuzh/files/FileServiceEdgeCaseTest.java index a612837..43ad512 100644 --- a/backend/src/test/java/com/yoyuzh/files/FileServiceEdgeCaseTest.java +++ b/backend/src/test/java/com/yoyuzh/files/FileServiceEdgeCaseTest.java @@ -32,6 +32,8 @@ class FileServiceEdgeCaseTest { @Mock private StoredFileRepository storedFileRepository; @Mock + private FileBlobRepository fileBlobRepository; + @Mock private FileContentStorage fileContentStorage; @Mock private FileShareLinkRepository fileShareLinkRepository; @@ -44,7 +46,14 @@ class FileServiceEdgeCaseTest { void setUp() { FileStorageProperties properties = new FileStorageProperties(); properties.setMaxFileSize(500L * 1024 * 1024); - fileService = new FileService(storedFileRepository, fileContentStorage, fileShareLinkRepository, adminMetricsService, properties); + fileService = new FileService( + storedFileRepository, + fileBlobRepository, + fileContentStorage, + fileShareLinkRepository, + adminMetricsService, + properties + ); } // --- normalizeDirectoryPath edge cases --- @@ -131,9 +140,9 @@ class FileServiceEdgeCaseTest { void shouldReturn302RedirectWhenStorageSupportsDirectDownloadForFile() { User user = createUser(1L); StoredFile file = createFile(10L, user, "/docs", "notes.txt"); - when(storedFileRepository.findById(10L)).thenReturn(Optional.of(file)); + when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(file)); when(fileContentStorage.supportsDirectDownload()).thenReturn(true); - when(fileContentStorage.createDownloadUrl(1L, "/docs", "notes.txt", "notes.txt")) + when(fileContentStorage.createBlobDownloadUrl("blobs/blob-10", "notes.txt")) .thenReturn("https://cdn.example.com/notes.txt"); ResponseEntity response = fileService.download(user, 10L); @@ -149,7 +158,7 @@ class FileServiceEdgeCaseTest { void shouldRejectCreatingShareLinkForDirectory() { User user = createUser(1L); StoredFile directory = createDirectory(10L, user, "/", "docs"); - when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory)); + when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(directory)); assertThatThrownBy(() -> fileService.createShareLink(user, 10L)) .isInstanceOf(BusinessException.class) @@ -162,7 +171,7 @@ class FileServiceEdgeCaseTest { void shouldRejectDownloadUrlForDirectory() { User user = createUser(1L); StoredFile directory = createDirectory(10L, user, "/", "docs"); - when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory)); + when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(directory)); assertThatThrownBy(() -> fileService.getDownloadUrl(user, 10L)) .isInstanceOf(BusinessException.class) @@ -211,7 +220,7 @@ class FileServiceEdgeCaseTest { void shouldReturnUnchangedFileWhenRenameToSameName() { User user = createUser(1L); StoredFile file = createFile(10L, user, "/docs", "notes.txt"); - when(storedFileRepository.findById(10L)).thenReturn(Optional.of(file)); + when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(file)); FileMetadataResponse response = fileService.rename(user, 10L, "notes.txt"); @@ -237,9 +246,10 @@ class FileServiceEdgeCaseTest { file.setUser(user); file.setFilename(filename); file.setPath(path); - file.setStorageName(filename); file.setSize(5L); file.setDirectory(false); + file.setContentType("text/plain"); + file.setBlob(createBlob(id, "blobs/blob-" + id, 5L, "text/plain")); file.setCreatedAt(LocalDateTime.now()); return file; } @@ -249,6 +259,17 @@ class FileServiceEdgeCaseTest { dir.setDirectory(true); dir.setContentType("directory"); dir.setSize(0L); + dir.setBlob(null); return dir; } + + private FileBlob createBlob(Long id, String objectKey, Long size, String contentType) { + FileBlob blob = new FileBlob(); + blob.setId(id); + blob.setObjectKey(objectKey); + blob.setSize(size); + blob.setContentType(contentType); + blob.setCreatedAt(LocalDateTime.now()); + return blob; + } } diff --git a/backend/src/test/java/com/yoyuzh/files/FileServiceTest.java b/backend/src/test/java/com/yoyuzh/files/FileServiceTest.java index c7024ba..2147a7a 100644 --- a/backend/src/test/java/com/yoyuzh/files/FileServiceTest.java +++ b/backend/src/test/java/com/yoyuzh/files/FileServiceTest.java @@ -31,6 +31,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.AdditionalMatchers.aryEq; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -42,6 +43,9 @@ class FileServiceTest { @Mock private StoredFileRepository storedFileRepository; + @Mock + private FileBlobRepository fileBlobRepository; + @Mock private FileContentStorage fileContentStorage; @@ -56,7 +60,14 @@ class FileServiceTest { void setUp() { FileStorageProperties properties = new FileStorageProperties(); properties.setMaxFileSize(500L * 1024 * 1024); - fileService = new FileService(storedFileRepository, fileContentStorage, fileShareLinkRepository, adminMetricsService, properties); + fileService = new FileService( + storedFileRepository, + fileBlobRepository, + fileContentStorage, + fileShareLinkRepository, + adminMetricsService, + properties + ); } @Test @@ -65,6 +76,11 @@ class FileServiceTest { MockMultipartFile multipartFile = new MockMultipartFile( "file", "notes.txt", "text/plain", "hello".getBytes()); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "notes.txt")).thenReturn(false); + when(fileBlobRepository.save(any(FileBlob.class))).thenAnswer(invocation -> { + FileBlob blob = invocation.getArgument(0); + blob.setId(100L); + return blob; + }); when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> { StoredFile file = invocation.getArgument(0); file.setId(10L); @@ -75,22 +91,28 @@ class FileServiceTest { assertThat(response.id()).isEqualTo(10L); assertThat(response.path()).isEqualTo("/docs"); - verify(fileContentStorage).upload(7L, "/docs", "notes.txt", multipartFile); + verify(fileContentStorage).uploadBlob(org.mockito.ArgumentMatchers.argThat(key -> key != null && key.startsWith("blobs/")), eq(multipartFile)); + verify(fileBlobRepository).save(org.mockito.ArgumentMatchers.argThat(blob -> + blob.getObjectKey() != null + && blob.getObjectKey().startsWith("blobs/") + && blob.getSize().equals(5L) + && "text/plain".equals(blob.getContentType()))); } @Test void shouldInitiateDirectUploadThroughStorage() { User user = createUser(7L); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "notes.txt")).thenReturn(false); - when(fileContentStorage.prepareUpload(7L, "/docs", "notes.txt", "text/plain", 12L)) - .thenReturn(new PreparedUpload(true, "https://upload.example.com", "PUT", Map.of("Content-Type", "text/plain"), "notes.txt")); + when(fileContentStorage.prepareBlobUpload(eq("/docs"), eq("notes.txt"), org.mockito.ArgumentMatchers.argThat(key -> key != null && key.startsWith("blobs/")), eq("text/plain"), eq(12L))) + .thenReturn(new PreparedUpload(true, "https://upload.example.com", "PUT", Map.of("Content-Type", "text/plain"), "blobs/upload-1")); InitiateUploadResponse response = fileService.initiateUpload(user, new InitiateUploadRequest("/docs", "notes.txt", "text/plain", 12L)); assertThat(response.direct()).isTrue(); assertThat(response.uploadUrl()).isEqualTo("https://upload.example.com"); - verify(fileContentStorage).prepareUpload(7L, "/docs", "notes.txt", "text/plain", 12L); + assertThat(response.storageName()).startsWith("blobs/"); + verify(fileContentStorage).prepareBlobUpload(eq("/docs"), eq("notes.txt"), org.mockito.ArgumentMatchers.argThat(key -> key != null && key.startsWith("blobs/")), eq("text/plain"), eq(12L)); } @Test @@ -98,20 +120,25 @@ class FileServiceTest { User user = createUser(7L); long uploadSize = 500L * 1024 * 1024; when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "movie.zip")).thenReturn(false); - when(fileContentStorage.prepareUpload(7L, "/docs", "movie.zip", "application/zip", uploadSize)) - .thenReturn(new PreparedUpload(true, "https://upload.example.com", "PUT", Map.of(), "movie.zip")); + when(fileContentStorage.prepareBlobUpload(eq("/docs"), eq("movie.zip"), org.mockito.ArgumentMatchers.argThat(key -> key != null && key.startsWith("blobs/")), eq("application/zip"), eq(uploadSize))) + .thenReturn(new PreparedUpload(true, "https://upload.example.com", "PUT", Map.of(), "blobs/upload-2")); InitiateUploadResponse response = fileService.initiateUpload(user, new InitiateUploadRequest("/docs", "movie.zip", "application/zip", uploadSize)); assertThat(response.direct()).isTrue(); - verify(fileContentStorage).prepareUpload(7L, "/docs", "movie.zip", "application/zip", uploadSize); + verify(fileContentStorage).prepareBlobUpload(eq("/docs"), eq("movie.zip"), org.mockito.ArgumentMatchers.argThat(key -> key != null && key.startsWith("blobs/")), eq("application/zip"), eq(uploadSize)); } @Test void shouldCompleteDirectUploadAndPersistMetadata() { User user = createUser(7L); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "notes.txt")).thenReturn(false); + when(fileBlobRepository.save(any(FileBlob.class))).thenAnswer(invocation -> { + FileBlob blob = invocation.getArgument(0); + blob.setId(101L); + return blob; + }); when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> { StoredFile file = invocation.getArgument(0); file.setId(11L); @@ -119,10 +146,44 @@ class FileServiceTest { }); FileMetadataResponse response = fileService.completeUpload(user, - new CompleteUploadRequest("/docs", "notes.txt", "notes.txt", "text/plain", 12L)); + new CompleteUploadRequest("/docs", "notes.txt", "blobs/upload-3", "text/plain", 12L)); assertThat(response.id()).isEqualTo(11L); - verify(fileContentStorage).completeUpload(7L, "/docs", "notes.txt", "text/plain", 12L); + verify(fileContentStorage).completeBlobUpload("blobs/upload-3", "text/plain", 12L); + } + + @Test + void shouldDeleteUploadedBlobWhenMetadataSaveFails() { + User user = createUser(7L); + MockMultipartFile multipartFile = new MockMultipartFile( + "file", "notes.txt", "text/plain", "hello".getBytes()); + when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "notes.txt")).thenReturn(false); + when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/", "docs")).thenReturn(true); + when(fileBlobRepository.save(any(FileBlob.class))).thenAnswer(invocation -> invocation.getArgument(0)); + doThrow(new IllegalStateException("insert failed")).when(storedFileRepository).save(any(StoredFile.class)); + + assertThatThrownBy(() -> fileService.upload(user, "/docs", multipartFile)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("insert failed"); + + verify(fileContentStorage).deleteBlob(org.mockito.ArgumentMatchers.argThat( + key -> key != null && key.startsWith("blobs/"))); + } + + @Test + void shouldDeleteCompletedUploadBlobWhenMetadataSaveFails() { + User user = createUser(7L); + when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "notes.txt")).thenReturn(false); + when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/", "docs")).thenReturn(true); + when(fileBlobRepository.save(any(FileBlob.class))).thenAnswer(invocation -> invocation.getArgument(0)); + doThrow(new IllegalStateException("insert failed")).when(storedFileRepository).save(any(StoredFile.class)); + + assertThatThrownBy(() -> fileService.completeUpload(user, + new CompleteUploadRequest("/docs", "notes.txt", "blobs/upload-fail", "text/plain", 12L))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("insert failed"); + + verify(fileContentStorage).deleteBlob("blobs/upload-fail"); } @Test @@ -131,14 +192,15 @@ class FileServiceTest { when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/projects/site", "logo.png")).thenReturn(false); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/", "projects")).thenReturn(false); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/projects", "site")).thenReturn(false); + when(fileBlobRepository.save(any(FileBlob.class))).thenAnswer(invocation -> invocation.getArgument(0)); when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> invocation.getArgument(0)); fileService.completeUpload(user, - new CompleteUploadRequest("/projects/site", "logo.png", "logo.png", "image/png", 12L)); + new CompleteUploadRequest("/projects/site", "logo.png", "blobs/upload-4", "image/png", 12L)); verify(fileContentStorage).ensureDirectory(7L, "/projects"); verify(fileContentStorage).ensureDirectory(7L, "/projects/site"); - verify(fileContentStorage).completeUpload(7L, "/projects/site", "logo.png", "image/png", 12L); + verify(fileContentStorage).completeBlobUpload("blobs/upload-4", "image/png", 12L); verify(storedFileRepository, times(3)).save(any(StoredFile.class)); } @@ -146,14 +208,14 @@ class FileServiceTest { void shouldRenameFileThroughConfiguredStorage() { User user = createUser(7L); StoredFile storedFile = createFile(10L, user, "/docs", "notes.txt"); - when(storedFileRepository.findById(10L)).thenReturn(Optional.of(storedFile)); + when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(storedFile)); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "renamed.txt")).thenReturn(false); when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> invocation.getArgument(0)); FileMetadataResponse response = fileService.rename(user, 10L, "renamed.txt"); assertThat(response.filename()).isEqualTo("renamed.txt"); - verify(fileContentStorage).renameFile(7L, "/docs", "notes.txt", "renamed.txt"); + verify(fileContentStorage, never()).renameFile(any(), any(), any(), any()); } @Test @@ -162,7 +224,7 @@ class FileServiceTest { StoredFile directory = createDirectory(10L, user, "/docs", "archive"); StoredFile childFile = createFile(11L, user, "/docs/archive", "nested.txt"); - when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory)); + when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(directory)); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "renamed-archive")).thenReturn(false); when(storedFileRepository.findByUserIdAndPathEqualsOrDescendant(7L, "/docs/archive")).thenReturn(List.of(childFile)); when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> invocation.getArgument(0)); @@ -171,7 +233,7 @@ class FileServiceTest { assertThat(response.filename()).isEqualTo("renamed-archive"); assertThat(childFile.getPath()).isEqualTo("/docs/renamed-archive"); - verify(fileContentStorage).renameDirectory(7L, "/docs/archive", "/docs/renamed-archive", List.of(childFile)); + verify(fileContentStorage, never()).renameDirectory(any(), any(), any(), any()); } @Test @@ -179,7 +241,7 @@ class FileServiceTest { User user = createUser(7L); StoredFile file = createFile(10L, user, "/docs", "notes.txt"); StoredFile targetDirectory = createDirectory(11L, user, "/", "下载"); - when(storedFileRepository.findById(10L)).thenReturn(Optional.of(file)); + when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(file)); when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "下载")).thenReturn(Optional.of(targetDirectory)); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/下载", "notes.txt")).thenReturn(false); when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> invocation.getArgument(0)); @@ -188,7 +250,7 @@ class FileServiceTest { assertThat(response.path()).isEqualTo("/下载"); assertThat(file.getPath()).isEqualTo("/下载"); - verify(fileContentStorage).moveFile(7L, "/docs", "/下载", "notes.txt"); + verify(fileContentStorage, never()).moveFile(any(), any(), any(), any()); } @Test @@ -197,7 +259,7 @@ class FileServiceTest { StoredFile directory = createDirectory(10L, user, "/docs", "archive"); StoredFile targetDirectory = createDirectory(11L, user, "/", "图片"); StoredFile childFile = createFile(12L, user, "/docs/archive", "nested.txt"); - when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory)); + when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(directory)); when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "图片")).thenReturn(Optional.of(targetDirectory)); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/图片", "archive")).thenReturn(false); when(storedFileRepository.findByUserIdAndPathEqualsOrDescendant(7L, "/docs/archive")).thenReturn(List.of(childFile)); @@ -209,7 +271,7 @@ class FileServiceTest { assertThat(response.path()).isEqualTo("/图片/archive"); assertThat(directory.getPath()).isEqualTo("/图片"); assertThat(childFile.getPath()).isEqualTo("/图片/archive"); - verify(fileContentStorage).renameDirectory(7L, "/docs/archive", "/图片/archive", List.of(childFile)); + verify(fileContentStorage, never()).renameDirectory(any(), any(), any(), any()); } @Test @@ -219,7 +281,7 @@ class FileServiceTest { StoredFile docsDirectory = createDirectory(11L, user, "/", "docs"); StoredFile archiveDirectory = createDirectory(12L, user, "/docs", "archive"); StoredFile descendantDirectory = createDirectory(13L, user, "/docs/archive", "nested"); - when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory)); + when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(directory)); when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "docs")) .thenReturn(Optional.of(docsDirectory)); when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/docs", "archive")) @@ -235,9 +297,10 @@ class FileServiceTest { @Test void shouldCopyFileToAnotherDirectory() { User user = createUser(7L); - StoredFile file = createFile(10L, user, "/docs", "notes.txt"); + FileBlob blob = createBlob(50L, "blobs/blob-copy", 5L, "text/plain"); + StoredFile file = createFile(10L, user, "/docs", "notes.txt", blob); StoredFile targetDirectory = createDirectory(11L, user, "/", "下载"); - when(storedFileRepository.findById(10L)).thenReturn(Optional.of(file)); + when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(file)); when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "下载")).thenReturn(Optional.of(targetDirectory)); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/下载", "notes.txt")).thenReturn(false); when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> { @@ -252,7 +315,8 @@ class FileServiceTest { assertThat(response.id()).isEqualTo(20L); assertThat(response.path()).isEqualTo("/下载"); - verify(fileContentStorage).copyFile(7L, "/docs", "/下载", "notes.txt"); + assertThat(file.getBlob()).isSameAs(blob); + verify(fileContentStorage, never()).copyFile(any(), any(), any(), any()); } @Test @@ -261,9 +325,11 @@ class FileServiceTest { StoredFile directory = createDirectory(10L, user, "/docs", "archive"); StoredFile targetDirectory = createDirectory(11L, user, "/", "图片"); StoredFile childDirectory = createDirectory(12L, user, "/docs/archive", "nested"); - StoredFile childFile = createFile(13L, user, "/docs/archive", "notes.txt"); - StoredFile nestedFile = createFile(14L, user, "/docs/archive/nested", "todo.txt"); - when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory)); + FileBlob childBlob = createBlob(51L, "blobs/blob-archive-1", 5L, "text/plain"); + FileBlob nestedBlob = createBlob(52L, "blobs/blob-archive-2", 5L, "text/plain"); + StoredFile childFile = createFile(13L, user, "/docs/archive", "notes.txt", childBlob); + StoredFile nestedFile = createFile(14L, user, "/docs/archive/nested", "todo.txt", nestedBlob); + when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(directory)); when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "图片")).thenReturn(Optional.of(targetDirectory)); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/图片", "archive")).thenReturn(false); when(storedFileRepository.findByUserIdAndPathEqualsOrDescendant(7L, "/docs/archive")) @@ -282,10 +348,7 @@ class FileServiceTest { FileMetadataResponse response = fileService.copy(user, 10L, "/图片"); assertThat(response.path()).isEqualTo("/图片/archive"); - verify(fileContentStorage).ensureDirectory(7L, "/图片/archive"); - verify(fileContentStorage).ensureDirectory(7L, "/图片/archive/nested"); - verify(fileContentStorage).copyFile(7L, "/docs/archive", "/图片/archive", "notes.txt"); - verify(fileContentStorage).copyFile(7L, "/docs/archive/nested", "/图片/archive/nested", "todo.txt"); + verify(fileContentStorage, never()).copyFile(any(), any(), any(), any()); } @Test @@ -295,7 +358,7 @@ class FileServiceTest { StoredFile docsDirectory = createDirectory(11L, user, "/", "docs"); StoredFile archiveDirectory = createDirectory(12L, user, "/docs", "archive"); StoredFile descendantDirectory = createDirectory(13L, user, "/docs/archive", "nested"); - when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory)); + when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(directory)); when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "docs")) .thenReturn(Optional.of(docsDirectory)); when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/docs", "archive")) @@ -313,7 +376,7 @@ class FileServiceTest { User owner = createUser(1L); User requester = createUser(2L); StoredFile storedFile = createFile(100L, owner, "/docs", "notes.txt"); - when(storedFileRepository.findById(100L)).thenReturn(Optional.of(storedFile)); + when(storedFileRepository.findDetailedById(100L)).thenReturn(Optional.of(storedFile)); assertThatThrownBy(() -> fileService.delete(requester, 100L)) .isInstanceOf(BusinessException.class) @@ -324,18 +387,51 @@ class FileServiceTest { void shouldDeleteDirectoryWithNestedFilesViaStorage() { User user = createUser(7L); StoredFile directory = createDirectory(10L, user, "/docs", "archive"); - StoredFile childFile = createFile(11L, user, "/docs/archive", "nested.txt"); + FileBlob blob = createBlob(60L, "blobs/blob-delete", 5L, "text/plain"); + StoredFile childFile = createFile(11L, user, "/docs/archive", "nested.txt", blob); - when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory)); + when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(directory)); when(storedFileRepository.findByUserIdAndPathEqualsOrDescendant(7L, "/docs/archive")).thenReturn(List.of(childFile)); + when(storedFileRepository.countByBlobId(60L)).thenReturn(1L); fileService.delete(user, 10L); - verify(fileContentStorage).deleteDirectory(7L, "/docs/archive", List.of(childFile)); + verify(fileContentStorage).deleteBlob("blobs/blob-delete"); + verify(fileBlobRepository).delete(blob); verify(storedFileRepository).deleteAll(List.of(childFile)); verify(storedFileRepository).delete(directory); } + @Test + void shouldDeleteSharedBlobOnlyWhenLastReferenceIsRemoved() { + User user = createUser(7L); + FileBlob blob = createBlob(70L, "blobs/blob-shared", 5L, "text/plain"); + StoredFile storedFile = createFile(15L, user, "/docs", "shared.txt", blob); + when(storedFileRepository.findDetailedById(15L)).thenReturn(Optional.of(storedFile)); + when(storedFileRepository.countByBlobId(70L)).thenReturn(2L); + + fileService.delete(user, 15L); + + verify(fileContentStorage, never()).deleteBlob(any()); + verify(fileBlobRepository, never()).delete(any()); + verify(storedFileRepository).delete(storedFile); + } + + @Test + void shouldDeleteBlobObjectWhenLastReferenceIsRemoved() { + User user = createUser(7L); + FileBlob blob = createBlob(71L, "blobs/blob-last", 5L, "text/plain"); + StoredFile storedFile = createFile(16L, user, "/docs", "last.txt", blob); + when(storedFileRepository.findDetailedById(16L)).thenReturn(Optional.of(storedFile)); + when(storedFileRepository.countByBlobId(71L)).thenReturn(1L); + + fileService.delete(user, 16L); + + verify(fileContentStorage).deleteBlob("blobs/blob-last"); + verify(fileBlobRepository).delete(blob); + verify(storedFileRepository).delete(storedFile); + } + @Test void shouldListFilesByPathWithPagination() { User user = createUser(7L); @@ -370,9 +466,9 @@ class FileServiceTest { void shouldUseSignedDownloadUrlWhenStorageSupportsDirectDownload() { User user = createUser(7L); StoredFile file = createFile(22L, user, "/docs", "notes.txt"); - when(storedFileRepository.findById(22L)).thenReturn(Optional.of(file)); + when(storedFileRepository.findDetailedById(22L)).thenReturn(Optional.of(file)); when(fileContentStorage.supportsDirectDownload()).thenReturn(true); - when(fileContentStorage.createDownloadUrl(7L, "/docs", "notes.txt", "notes.txt")) + when(fileContentStorage.createBlobDownloadUrl("blobs/blob-22", "notes.txt")) .thenReturn("https://download.example.com/file"); DownloadUrlResponse response = fileService.getDownloadUrl(user, 22L); @@ -384,7 +480,7 @@ class FileServiceTest { void shouldFallbackToBackendDownloadUrlWhenStorageIsLocal() { User user = createUser(7L); StoredFile file = createFile(22L, user, "/docs", "notes.txt"); - when(storedFileRepository.findById(22L)).thenReturn(Optional.of(file)); + when(storedFileRepository.findDetailedById(22L)).thenReturn(Optional.of(file)); when(fileContentStorage.supportsDirectDownload()).thenReturn(false); DownloadUrlResponse response = fileService.getDownloadUrl(user, 22L); @@ -401,12 +497,12 @@ class FileServiceTest { StoredFile childFile = createFile(12L, user, "/docs/archive", "notes.txt"); StoredFile nestedFile = createFile(13L, user, "/docs/archive/nested", "todo.txt"); - when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory)); + when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(directory)); when(storedFileRepository.findByUserIdAndPathEqualsOrDescendant(7L, "/docs/archive")) .thenReturn(List.of(childDirectory, childFile, nestedFile)); - when(fileContentStorage.readFile(7L, "/docs/archive", "notes.txt")) + when(fileContentStorage.readBlob("blobs/blob-12")) .thenReturn("hello".getBytes(StandardCharsets.UTF_8)); - when(fileContentStorage.readFile(7L, "/docs/archive/nested", "todo.txt")) + when(fileContentStorage.readBlob("blobs/blob-13")) .thenReturn("world".getBytes(StandardCharsets.UTF_8)); var response = fileService.download(user, 10L); @@ -430,15 +526,15 @@ class FileServiceTest { assertThat(entries).containsEntry("archive/nested/", ""); assertThat(entries).containsEntry("archive/notes.txt", "hello"); assertThat(entries).containsEntry("archive/nested/todo.txt", "world"); - verify(fileContentStorage).readFile(7L, "/docs/archive", "notes.txt"); - verify(fileContentStorage).readFile(7L, "/docs/archive/nested", "todo.txt"); + verify(fileContentStorage).readBlob("blobs/blob-12"); + verify(fileContentStorage).readBlob("blobs/blob-13"); } @Test void shouldCreateShareLinkForOwnedFile() { User user = createUser(7L); StoredFile file = createFile(22L, user, "/docs", "notes.txt"); - when(storedFileRepository.findById(22L)).thenReturn(Optional.of(file)); + when(storedFileRepository.findDetailedById(22L)).thenReturn(Optional.of(file)); when(fileShareLinkRepository.save(any(FileShareLink.class))).thenAnswer(invocation -> { FileShareLink shareLink = invocation.getArgument(0); shareLink.setId(100L); @@ -457,7 +553,8 @@ class FileServiceTest { void shouldImportSharedFileIntoRecipientWorkspace() { User owner = createUser(7L); User recipient = createUser(8L); - StoredFile sourceFile = createFile(22L, owner, "/docs", "notes.txt"); + FileBlob blob = createBlob(80L, "blobs/blob-import", 5L, "text/plain"); + StoredFile sourceFile = createFile(22L, owner, "/docs", "notes.txt", blob); FileShareLink shareLink = new FileShareLink(); shareLink.setId(100L); shareLink.setToken("share-token-1"); @@ -471,21 +568,30 @@ class FileServiceTest { file.setId(200L); return file; }); - when(fileContentStorage.readFile(7L, "/docs", "notes.txt")) - .thenReturn("hello".getBytes(StandardCharsets.UTF_8)); - FileMetadataResponse response = fileService.importSharedFile(recipient, "share-token-1", "/下载"); assertThat(response.id()).isEqualTo(200L); assertThat(response.path()).isEqualTo("/下载"); assertThat(response.filename()).isEqualTo("notes.txt"); - verify(fileContentStorage).storeImportedFile( - eq(8L), - eq("/下载"), - eq("notes.txt"), - eq(sourceFile.getContentType()), - aryEq("hello".getBytes(StandardCharsets.UTF_8)) - ); + verify(fileContentStorage, never()).storeImportedFile(any(), any(), any(), any(), any()); + verify(fileContentStorage, never()).readFile(any(), any(), any()); + } + + @Test + void shouldDeleteImportedBlobWhenMetadataSaveFails() { + User recipient = createUser(8L); + byte[] content = "hello".getBytes(StandardCharsets.UTF_8); + when(storedFileRepository.existsByUserIdAndPathAndFilename(8L, "/下载", "notes.txt")).thenReturn(false); + when(storedFileRepository.existsByUserIdAndPathAndFilename(8L, "/", "下载")).thenReturn(true); + when(fileBlobRepository.save(any(FileBlob.class))).thenAnswer(invocation -> invocation.getArgument(0)); + doThrow(new IllegalStateException("insert failed")).when(storedFileRepository).save(any(StoredFile.class)); + + assertThatThrownBy(() -> fileService.importExternalFile(recipient, "/下载", "notes.txt", "text/plain", content.length, content)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("insert failed"); + + verify(fileContentStorage).deleteBlob(org.mockito.ArgumentMatchers.argThat( + key -> key != null && key.startsWith("blobs/"))); } private User createUser(Long id) { @@ -498,7 +604,21 @@ class FileServiceTest { return user; } + private FileBlob createBlob(Long id, String objectKey, Long size, String contentType) { + FileBlob blob = new FileBlob(); + blob.setId(id); + blob.setObjectKey(objectKey); + blob.setSize(size); + blob.setContentType(contentType); + blob.setCreatedAt(LocalDateTime.now()); + return blob; + } + private StoredFile createFile(Long id, User user, String path, String filename) { + return createFile(id, user, path, filename, createBlob(id, "blobs/blob-" + id, 5L, "text/plain")); + } + + private StoredFile createFile(Long id, User user, String path, String filename, FileBlob blob) { StoredFile file = new StoredFile(); file.setId(id); file.setUser(user); @@ -506,7 +626,8 @@ class FileServiceTest { file.setPath(path); file.setSize(5L); file.setDirectory(false); - file.setStorageName(filename); + file.setContentType("text/plain"); + file.setBlob(blob); file.setCreatedAt(LocalDateTime.now()); return file; } @@ -516,6 +637,7 @@ class FileServiceTest { directory.setDirectory(true); directory.setContentType("directory"); directory.setSize(0L); + directory.setBlob(null); return directory; } } diff --git a/backend/src/test/java/com/yoyuzh/files/FileShareControllerIntegrationTest.java b/backend/src/test/java/com/yoyuzh/files/FileShareControllerIntegrationTest.java index 808a34b..bb72180 100644 --- a/backend/src/test/java/com/yoyuzh/files/FileShareControllerIntegrationTest.java +++ b/backend/src/test/java/com/yoyuzh/files/FileShareControllerIntegrationTest.java @@ -15,7 +15,10 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -50,6 +53,8 @@ class FileShareControllerIntegrationTest { @Autowired private StoredFileRepository storedFileRepository; + @Autowired + private FileBlobRepository fileBlobRepository; @Autowired private FileShareLinkRepository fileShareLinkRepository; @@ -58,6 +63,7 @@ class FileShareControllerIntegrationTest { void setUp() throws Exception { fileShareLinkRepository.deleteAll(); storedFileRepository.deleteAll(); + fileBlobRepository.deleteAll(); userRepository.deleteAll(); if (Files.exists(STORAGE_ROOT)) { try (var paths = Files.walk(STORAGE_ROOT)) { @@ -88,19 +94,26 @@ class FileShareControllerIntegrationTest { recipient.setCreatedAt(LocalDateTime.now()); recipient = userRepository.save(recipient); + FileBlob blob = new FileBlob(); + blob.setObjectKey("blobs/share-notes"); + blob.setContentType("text/plain"); + blob.setSize(5L); + blob.setCreatedAt(LocalDateTime.now()); + blob = fileBlobRepository.save(blob); + StoredFile file = new StoredFile(); file.setUser(owner); file.setFilename("notes.txt"); file.setPath("/docs"); - file.setStorageName("notes.txt"); file.setContentType("text/plain"); file.setSize(5L); file.setDirectory(false); + file.setBlob(blob); sharedFileId = storedFileRepository.save(file).getId(); - Path ownerDir = STORAGE_ROOT.resolve(owner.getId().toString()).resolve("docs"); - Files.createDirectories(ownerDir); - Files.writeString(ownerDir.resolve("notes.txt"), "hello", StandardCharsets.UTF_8); + Path blobPath = STORAGE_ROOT.resolve("blobs").resolve("share-notes"); + Files.createDirectories(blobPath.getParent()); + Files.writeString(blobPath, "hello", StandardCharsets.UTF_8); } @Test @@ -152,6 +165,18 @@ class FileShareControllerIntegrationTest { .param("size", "20")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.items[0].filename").value("notes.txt")); + + List allFiles = storedFileRepository.findAll().stream() + .filter(file -> !file.isDirectory()) + .sorted(Comparator.comparing(StoredFile::getId)) + .toList(); + assertThat(allFiles).hasSize(2); + assertThat(allFiles.get(0).getBlob().getId()).isEqualTo(allFiles.get(1).getBlob().getId()); + assertThat(fileBlobRepository.count()).isEqualTo(1L); + try (var paths = Files.walk(STORAGE_ROOT)) { + long physicalObjects = paths.filter(Files::isRegularFile).count(); + assertThat(physicalObjects).isEqualTo(1L); + } } @Test @@ -162,7 +187,6 @@ class FileShareControllerIntegrationTest { downloadDirectory.setUser(owner); downloadDirectory.setFilename("下载"); downloadDirectory.setPath("/"); - downloadDirectory.setStorageName("下载"); downloadDirectory.setContentType("directory"); downloadDirectory.setSize(0L); downloadDirectory.setDirectory(true); @@ -198,7 +222,6 @@ class FileShareControllerIntegrationTest { downloadDirectory.setUser(owner); downloadDirectory.setFilename("下载"); downloadDirectory.setPath("/"); - downloadDirectory.setStorageName("下载"); downloadDirectory.setContentType("directory"); downloadDirectory.setSize(0L); downloadDirectory.setDirectory(true); @@ -224,5 +247,13 @@ class FileShareControllerIntegrationTest { .param("size", "20")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.items[0].filename").value("notes.txt")); + + List allFiles = storedFileRepository.findAll().stream() + .filter(file -> !file.isDirectory()) + .sorted(Comparator.comparing(StoredFile::getId)) + .toList(); + assertThat(allFiles).hasSize(2); + assertThat(allFiles.get(0).getBlob().getId()).isEqualTo(allFiles.get(1).getBlob().getId()); + assertThat(fileBlobRepository.count()).isEqualTo(1L); } } diff --git a/backend/src/test/java/com/yoyuzh/transfer/TransferControllerIntegrationTest.java b/backend/src/test/java/com/yoyuzh/transfer/TransferControllerIntegrationTest.java index 13a57f5..71995ae 100644 --- a/backend/src/test/java/com/yoyuzh/transfer/TransferControllerIntegrationTest.java +++ b/backend/src/test/java/com/yoyuzh/transfer/TransferControllerIntegrationTest.java @@ -144,7 +144,7 @@ class TransferControllerIntegrationTest { @Test @WithMockUser(username = "alice") - void shouldRejectAnonymousOfflineLookupJoinAndDownload() throws Exception { + void shouldAllowAnonymousOfflineLookupJoinAndDownloadButKeepImportProtected() throws Exception { String response = mockMvc.perform(post("/api/transfer/sessions") .contentType(MediaType.APPLICATION_JSON) .content(""" @@ -176,12 +176,27 @@ class TransferControllerIntegrationTest { .andExpect(status().isOk()); mockMvc.perform(get("/api/transfer/sessions/lookup").with(anonymous()).param("pickupCode", pickupCode)) - .andExpect(status().isUnauthorized()); + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.sessionId").value(sessionId)) + .andExpect(jsonPath("$.data.mode").value("OFFLINE")); mockMvc.perform(post("/api/transfer/sessions/{sessionId}/join", sessionId).with(anonymous())) - .andExpect(status().isUnauthorized()); + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.sessionId").value(sessionId)) + .andExpect(jsonPath("$.data.files[0].name").value("offline.txt")); mockMvc.perform(get("/api/transfer/sessions/{sessionId}/files/{fileId}/download", sessionId, fileId).with(anonymous())) + .andExpect(status().isOk()) + .andExpect(content().bytes("hello offline".getBytes(StandardCharsets.UTF_8))); + + mockMvc.perform(post("/api/transfer/sessions/{sessionId}/files/{fileId}/import", sessionId, fileId) + .with(anonymous()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "path": "/" + } + """)) .andExpect(status().isUnauthorized()); } diff --git a/docs/api-reference.md b/docs/api-reference.md index 76924dc..f357648 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -158,6 +158,8 @@ - 兼容普通上传和 OSS 直传 - 前端会优先尝试“初始化上传 -> 直传/代理 -> 完成上传” +- `upload/initiate` 返回的 `storageName` 现在是一次上传对应的 opaque blob object key;新文件会落到全局 `blobs/...` key,而不是用户目录路径 key +- `upload/complete` 必须回传这个 opaque blob key,后端会据此创建 `FileBlob` 并把新 `StoredFile` 绑定到该 blob ### 3.2 目录与列表 @@ -192,6 +194,7 @@ - `move` 用于移动到目标路径 - `copy` 用于复制到目标路径 - 文件和文件夹都支持移动 / 复制 +- 普通文件的 `move` / `rename` / `copy` 只改逻辑元数据;`copy` 会复用原有 `FileBlob`,不会复制底层对象 ### 3.5 分享链接 @@ -204,6 +207,7 @@ - 已登录用户可为自己的文件或文件夹创建分享链接 - 公开访客可查看分享详情 - 登录用户可将分享内容导入自己的网盘 +- 普通文件导入时会新建自己的 `StoredFile` 并复用源 `FileBlob`,不会再次写入物理文件 ## 4. 快传模块 @@ -231,7 +235,7 @@ 说明: - 接收端通过 6 位取件码查找会话 -- 未登录用户只能查找在线快传 +- 在线快传和离线快传都允许未登录用户查找 ### 4.3 加入会话 @@ -241,7 +245,7 @@ - 在线快传会占用一次性会话 - 离线快传返回可下载文件清单,不需要建立 P2P 通道 -- 未登录用户只能加入在线快传 +- 在线快传和离线快传都允许未登录用户加入 ### 4.4 信令交换 @@ -281,7 +285,7 @@ 说明: -- 需要登录 +- 不需要登录 - 离线文件在有效期内可以被重复下载 ### 4.7 存入网盘 @@ -308,6 +312,16 @@ - 用户总数 - 文件总数 - 当前邀请码 +- 今日请求次数 +- 今日按小时请求折线图 +- 最近 7 天每日上线人数和用户名单 +- 当前离线快传占用与上限 + +补充说明: + +- `requestTimeline` 现在只返回当天已经过去的小时,例如当天只到 `07:xx` 时只会返回 `00:00` 到 `07:00` +- `dailyActiveUsers` 固定返回最近 7 天,按日期升序排列;每项包含日期、展示标签、当天去重后的上线人数和用户名列表 +- “上线”定义为用户成功通过 JWT 鉴权访问受保护接口后的当天首次记录 ### 5.2 用户管理 diff --git a/docs/architecture.md b/docs/architecture.md index 3d17d19..eca464e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,6 +10,8 @@ 2. Spring Boot 后端 API 3. 文件存储层(本地文件系统或 S3 兼容对象存储) +当前前端除了作为 Web 站点发布外,也已支持通过 Capacitor 打包成 Android WebView 壳应用。 + 业务主线已经从旧教务方向切换为: - 账号系统 @@ -33,6 +35,7 @@ - 快传发/收流程 - 管理台前端 - 生产环境 API 基址拼装与调用 +- Android WebView 壳的静态资源承载与 Capacitor 同步 关键入口: @@ -41,6 +44,8 @@ - `front/src/main.tsx` - `front/src/lib/api.ts` - `front/src/components/layout/Layout.tsx` +- `front/capacitor.config.ts` +- `front/android/` 主要页面: @@ -133,11 +138,26 @@ 关键实现说明: - 文件元数据在数据库 -- 文件内容走存储层抽象 +- 文件内容通过独立 `FileBlob` 实体映射到底层对象;`StoredFile` 只负责用户、目录、文件名、路径、分享关系等逻辑元数据 +- 新文件的物理对象 key 使用全局 `blobs/...` 命名,不再把 `userId/path` 编进对象 key - 支持本地磁盘和 S3 兼容对象存储 +- 分享导入与网盘复制会直接复用源文件的 `FileBlob`,不会再次写入字节内容 +- 文件重命名、移动只更新 `StoredFile` 元数据,不会移动底层对象 +- 删除文件时会先删除 `StoredFile` 引用;只有最后一个引用消失时,才真正删除 `FileBlob` 对应的底层对象 +- 应用启动时会把旧 `portal_file.storage_name` 行自动回填到新的 `blob_id` 引用,保证存量数据能继续读取 - 当前线上网盘文件存储已切到多吉云对象存储,后端先通过多吉云临时密钥 API 换取短期 S3 会话,再访问底层 COS 兼容桶 - 前端会缓存目录列表和最后访问路径 +Android 壳补充说明: + +- Android 客户端当前使用 Capacitor 直接承载 `front/dist`,不单独维护原生业务页面 +- 当前包名是 `xyz.yoyuzh.portal` +- 前端 API 基址在 Web 与 Android 壳上分开解析:网页继续走相对 `/api`,Capacitor `localhost` 壳在 `http://localhost` 与 `https://localhost` 下都默认改走 `https://api.yoyuzh.xyz/api` +- 后端 CORS 默认放行 `http://localhost`、`https://localhost`、`http://127.0.0.1`、`https://127.0.0.1` 与 `capacitor://localhost`,以兼容 Web 开发环境和 Android WebView 壳 +- Web 端构建完成后,通过 `npx cap sync android` 把静态资源复制到 `front/android/app/src/main/assets/public` +- Android 调试包当前通过 `cd front/android && ./gradlew assembleDebug` 生成,输出路径是 `front/android/app/build/outputs/apk/debug/app-debug.apk` +- 由于当前开发机直连 `dl.google.com` 与 Google Android Maven 仓库存在 TLS 握手失败,本地 Android 构建仓库源已切到可访问镜像;如果后续重新生成 Capacitor 工程,需要重新确认镜像配置仍存在 + ### 3.3 快传模块 核心文件: @@ -166,8 +186,10 @@ - 多文件或文件夹可走 ZIP 下载 - 在线快传是一次性浏览器 P2P 传输,首个接收者进入后即占用该会话 - 离线快传会把文件内容落到站点存储,线上环境使用多吉云对象存储,默认保留 7 天并支持重复接收 -- 登录页提供直达快传入口;匿名用户只允许创建在线快传并接收在线快传,离线快传相关操作仍要求登录 +- 登录页提供直达快传入口;匿名用户允许创建在线快传、接收在线快传和接收离线快传,离线快传的发送以及“存入网盘”仍要求登录 - 已登录发送端可在快传页查看自己未过期的离线快传记录,并重新打开取件码 / 二维码 / 分享链接详情弹层 +- 生产环境当前已经部署 `GET /api/transfer/sessions/offline/mine`,用于驱动“我的离线快传”列表 +- 前端默认内置 STUN 服务器,并支持通过 `VITE_TRANSFER_ICE_SERVERS_JSON` 追加 TURN / ICE 配置;未配置 TURN 时,跨运营商或手机蜂窝网络下的在线 P2P 直连不保证成功 ### 3.4 管理台模块 @@ -182,7 +204,7 @@ - 管理用户 - 管理文件 - 查看邀请码 -- 展示总存储量、下载流量、今日请求次数、快传使用量、离线快传占用和请求折线图 +- 展示总存储量、下载流量、今日请求次数、快传使用量、离线快传占用、请求折线图和最近 7 天上线记录 - 调整离线快传总上限 关键实现说明: @@ -191,6 +213,8 @@ - 当前邀请码由后端返回给管理台展示 - 用户列表会展示每个用户的已用空间 / 配额 - 管理员修改用户密码后,旧密码应立即失效,新密码可直接重新登录 +- JWT 过滤器在受保护接口鉴权成功后,会把当天首次上线的用户写入管理统计表,只保留最近 7 天 +- 管理台请求折线图只渲染当天已发生的小时,不再为未来小时补空点 ## 4. 关键业务流程 @@ -198,6 +222,7 @@ - 前端主入口会在 `main.tsx` 按屏幕宽度选择桌面壳或移动壳 - 当前规则为:宽度小于 `768px` 时渲染 `MobileApp`,否则渲染桌面 `App` +- 移动端 `MobileFiles` 与 `MobileTransfer` 独立维护页面级动态光晕层,视觉上与桌面端网盘/快传保持同一背景语言 ### 4.1 登录流程 @@ -226,16 +251,19 @@ 1. 前端在 `Files` 页面选择文件或文件夹 2. 前端优先调用 `/api/files/upload/initiate` -3. 如果存储支持直传,则浏览器直接上传到对象存储 -4. 前端再调用 `/api/files/upload/complete` -5. 如果直传失败,会回退到代理上传接口 `/api/files/upload` +3. 后端为新文件预留一个全局 blob object key(`blobs/...`)并返回给前端 +4. 如果存储支持直传,则浏览器直接把字节上传到该 blob key +5. 前端再调用 `/api/files/upload/complete` +6. 如果直传失败,会回退到代理上传接口 `/api/files/upload` +7. 后端创建 `FileBlob`,再创建指向该 blob 的 `StoredFile` ### 4.4 文件分享流程 1. 登录用户创建分享链接 2. 后端生成 token 3. 公开用户通过 `/share/:token` 查看详情 -4. 登录用户可以导入到自己的网盘 +4. 登录用户导入时会新建自己的 `StoredFile` +5. 若源对象是普通文件,则新条目直接复用源 `FileBlob`,不会复制物理内容 ### 4.5 快传流程 @@ -258,7 +286,7 @@ 补充说明: -- 离线快传的创建、查找、加入和下载都要求登录 +- 离线快传只有“创建会话 / 上传文件 / 存入网盘”要求登录;匿名用户可以查找、加入和下载离线快传 - 匿名用户进入 `/transfer` 时默认落在发送页,但仅会看到在线模式 - 登录用户可通过 `/api/transfer/sessions/offline/mine` 拉取自己仍在有效期内的离线快传会话,用于在快传页回看历史取件信息 diff --git a/docs/superpowers/plans/2026-04-02-shared-file-blob-storage.md b/docs/superpowers/plans/2026-04-02-shared-file-blob-storage.md new file mode 100644 index 0000000..29700f1 --- /dev/null +++ b/docs/superpowers/plans/2026-04-02-shared-file-blob-storage.md @@ -0,0 +1,115 @@ +# Shared File Blob Storage Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将网盘文件模型改造成 `StoredFile -> FileBlob` 引用关系,让分享导入与网盘复制复用同一份底层对象,而不是再次写入物理文件。 + +**Architecture:** 新增独立 `FileBlob` 实体承载真实对象 key、大小和内容类型,`StoredFile` 只保留逻辑目录元数据并引用 `FileBlob`。网盘上传为每个新文件创建新 blob,分享导入和文件复制直接复用 blob;删除时按引用是否归零决定是否删除底层对象。补一个面向旧 `portal_file.storage_name` 数据的一次性回填路径,避免线上旧数据在新模型下失联。 + +**Tech Stack:** Spring Boot 3.3.8, Spring Data JPA, Java 17, Maven, H2 tests, local filesystem storage, S3-compatible object storage + +--- + +### Task 1: Define Blob Data Model + +**Files:** +- Create: `backend/src/main/java/com/yoyuzh/files/FileBlob.java` +- Create: `backend/src/main/java/com/yoyuzh/files/FileBlobRepository.java` +- Modify: `backend/src/main/java/com/yoyuzh/files/StoredFile.java` +- Modify: `backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java` + +- [ ] **Step 1: Write the failing tests** + +Add/update backend tests that expect file copies and share imports to preserve a shared blob id rather than a per-user storage name. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /Users/mac/Documents/my_site/backend && mvn test -Dtest=FileServiceTest,FileShareControllerIntegrationTest` +Expected: FAIL because `StoredFile` does not yet expose blob references and current logic still duplicates file content. + +- [ ] **Step 3: Write minimal implementation** + +Add `FileBlob` entity/repository, move file-object ownership from `StoredFile.storageName` to `StoredFile.blob`, and add repository helpers for blob reference counting / physical size queries. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd /Users/mac/Documents/my_site/backend && mvn test -Dtest=FileServiceTest,FileShareControllerIntegrationTest` +Expected: PASS for data-model expectations. + +### Task 2: Refactor File Storage Flow To Blob Keys + +**Files:** +- Modify: `backend/src/main/java/com/yoyuzh/files/FileService.java` +- Modify: `backend/src/main/java/com/yoyuzh/files/storage/FileContentStorage.java` +- Modify: `backend/src/main/java/com/yoyuzh/files/storage/LocalFileContentStorage.java` +- Modify: `backend/src/main/java/com/yoyuzh/files/storage/S3FileContentStorage.java` +- Test: `backend/src/test/java/com/yoyuzh/files/FileServiceTest.java` +- Test: `backend/src/test/java/com/yoyuzh/files/FileServiceEdgeCaseTest.java` + +- [ ] **Step 1: Write the failing tests** + +Add/update tests for: +- upload creates a new blob +- share import reuses the source blob and does not store bytes again +- file copy reuses the source blob and does not copy bytes again +- deleting a non-final reference keeps the blob/object alive +- deleting the final reference removes the blob/object + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /Users/mac/Documents/my_site/backend && mvn test -Dtest=FileServiceTest,FileServiceEdgeCaseTest` +Expected: FAIL because current service still calls `readFile/storeImportedFile/copyFile/moveFile/renameFile` for ordinary files. + +- [ ] **Step 3: Write minimal implementation** + +Refactor file upload/download/import/copy/delete to operate on blob object keys. Keep directory behavior metadata-only. Ensure file rename/move/copy no longer trigger physical object mutations, and deletion only removes the object when the last `StoredFile` reference disappears. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd /Users/mac/Documents/my_site/backend && mvn test -Dtest=FileServiceTest,FileServiceEdgeCaseTest` +Expected: PASS. + +### Task 3: Backfill Old File Rows Into Blob References + +**Files:** +- Create: `backend/src/main/java/com/yoyuzh/files/FileBlobBackfillService.java` +- Modify: `backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java` +- Test: `backend/src/test/java/com/yoyuzh/files/FileShareControllerIntegrationTest.java` + +- [ ] **Step 1: Write the failing test** + +Add an integration-path expectation that persisted file rows still download/import correctly after the blob model change. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /Users/mac/Documents/my_site/backend && mvn test -Dtest=FileShareControllerIntegrationTest` +Expected: FAIL because legacy `portal_file` rows created without blobs can no longer resolve file content. + +- [ ] **Step 3: Write minimal implementation** + +Add a startup/on-demand backfill that creates `FileBlob` rows for existing non-directory files using their legacy object key and attaches them to `StoredFile` rows with missing blob references. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd /Users/mac/Documents/my_site/backend && mvn test -Dtest=FileShareControllerIntegrationTest` +Expected: PASS. + +### Task 4: Update Docs And Full Verification + +**Files:** +- Modify: `memory.md` +- Modify: `docs/architecture.md` +- Modify: `docs/api-reference.md` (only if API semantics changed) + +- [ ] **Step 1: Document the new storage model** + +Record that logical file metadata now points to shared blobs, that share import and copy reuse blobs, and that physical keys are global rather than user-path keys. + +- [ ] **Step 2: Run full backend verification** + +Run: `cd /Users/mac/Documents/my_site/backend && mvn test` +Expected: PASS. + +- [ ] **Step 3: Summarize migration requirement** + +In the final handoff, call out that old production data must be backfilled to `FileBlob` rows before or during rollout; otherwise pre-existing files cannot resolve blob references under the new model. diff --git a/docs/superpowers/plans/2026-04-03-transfer-simple-peer-integration.md b/docs/superpowers/plans/2026-04-03-transfer-simple-peer-integration.md new file mode 100644 index 0000000..11e4555 --- /dev/null +++ b/docs/superpowers/plans/2026-04-03-transfer-simple-peer-integration.md @@ -0,0 +1,115 @@ +# Transfer Simple Peer Integration Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the hand-rolled online transfer WebRTC peer wiring with `simple-peer` while preserving the current pickup-code flow, backend signaling APIs, and offline transfer mode. + +**Architecture:** Keep the existing product boundaries: Spring Boot remains a dumb signaling relay and session store, while the React frontend owns online-transfer peer creation and file streaming. Instead of manually managing `RTCPeerConnection`, ICE candidates, and SDP state across sender/receiver pages, introduce a thin `simple-peer` adapter that serializes peer signals through the existing `/api/transfer/sessions/{id}/signals` endpoints and reuses the current transfer control/data protocol. + +**Tech Stack:** Vite 6, React 19, TypeScript, node:test, `simple-peer`, existing Spring Boot transfer signaling API. + +--- + +### Task 1: Lock the new peer adapter contract with failing tests + +**Files:** +- Create: `front/src/lib/transfer-peer.test.ts` +- Create: `front/src/lib/transfer-peer.ts` + +- [ ] **Step 1: Write the failing test** + +Add tests that assert: +- local `simple-peer` signal events serialize into a backend-friendly payload +- incoming backend signal payloads are routed back into the peer instance +- peer connection lifecycle maps to app-friendly callbacks without exposing raw browser SDP/ICE handling to pages + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd front && npm run test -- src/lib/transfer-peer.test.ts` + +- [ ] **Step 3: Write minimal implementation** + +Create a focused adapter around `simple-peer` with: +- sender/receiver construction +- `signal` event forwarding +- `connect`, `data`, `close`, and `error` callbacks +- send helpers used by the existing transfer pages + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd front && npm run test -- src/lib/transfer-peer.test.ts` + +### Task 2: Replace online sender wiring in the desktop and mobile transfer pages + +**Files:** +- Modify: `front/src/pages/Transfer.tsx` +- Modify: `front/src/mobile-pages/MobileTransfer.tsx` +- Modify: `front/src/lib/transfer.ts` + +- [ ] **Step 1: Write the failing test** + +Add or extend focused tests for the new signaling payload shape if needed so the sender path no longer depends on manual offer/answer/ICE branches. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd front && npm run test` + +- [ ] **Step 3: Write minimal implementation** + +Use the adapter in both sender pages: +- build an initiator peer instead of a raw `RTCPeerConnection` +- post serialized peer signals through the existing backend endpoint +- keep the current file manifest and binary chunk sending protocol + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd front && npm run test` + +### Task 3: Replace online receiver wiring while keeping the current receive UX + +**Files:** +- Modify: `front/src/pages/TransferReceive.tsx` + +- [ ] **Step 1: Write the failing test** + +Add or update tests around receiver signal handling and data delivery if gaps remain after Task 2. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd front && npm run test` + +- [ ] **Step 3: Write minimal implementation** + +Use the adapter in the receiver page: +- build a non-initiator peer +- feed backend-delivered signals into it +- keep the current control messages, archive flow, and netdisk save flow unchanged + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd front && npm run test` + +### Task 4: Verification and release + +**Files:** +- Modify if required by validation failures: `front/package.json`, `front/package-lock.json` + +- [ ] **Step 1: Install the dependency** + +Run: `cd front && npm install simple-peer @types/simple-peer` + +- [ ] **Step 2: Run frontend tests** + +Run: `cd front && npm run test` + +- [ ] **Step 3: Run frontend typecheck** + +Run: `cd front && npm run lint` + +- [ ] **Step 4: Run frontend build** + +Run: `cd front && npm run build` + +- [ ] **Step 5: Publish the frontend** + +Run: `node scripts/deploy-front-oss.mjs` diff --git a/front/android/.gitignore b/front/android/.gitignore new file mode 100644 index 0000000..48354a3 --- /dev/null +++ b/front/android/.gitignore @@ -0,0 +1,101 @@ +# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore + +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +# Android Profiling +*.hprof + +# Cordova plugins for Capacitor +capacitor-cordova-android-plugins + +# Copied web assets +app/src/main/assets/public + +# Generated Config files +app/src/main/assets/capacitor.config.json +app/src/main/assets/capacitor.plugins.json +app/src/main/res/xml/config.xml diff --git a/front/android/app/.gitignore b/front/android/app/.gitignore new file mode 100644 index 0000000..043df80 --- /dev/null +++ b/front/android/app/.gitignore @@ -0,0 +1,2 @@ +/build/* +!/build/.npmkeep diff --git a/front/android/app/build.gradle b/front/android/app/build.gradle new file mode 100644 index 0000000..74c120d --- /dev/null +++ b/front/android/app/build.gradle @@ -0,0 +1,54 @@ +apply plugin: 'com.android.application' + +android { + namespace = "xyz.yoyuzh.portal" + compileSdk = rootProject.ext.compileSdkVersion + defaultConfig { + applicationId "xyz.yoyuzh.portal" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 1 + versionName "1.0" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + aaptOptions { + // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. + // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 + ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' + } + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +repositories { + flatDir{ + dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs' + } +} + +dependencies { + implementation fileTree(include: ['*.jar'], dir: 'libs') + implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" + implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" + implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" + implementation project(':capacitor-android') + testImplementation "junit:junit:$junitVersion" + androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" + androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" + implementation project(':capacitor-cordova-android-plugins') +} + +apply from: 'capacitor.build.gradle' + +try { + def servicesJSON = file('google-services.json') + if (servicesJSON.text) { + apply plugin: 'com.google.gms.google-services' + } +} catch(Exception e) { + logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work") +} diff --git a/front/android/app/capacitor.build.gradle b/front/android/app/capacitor.build.gradle new file mode 100644 index 0000000..bbfb44f --- /dev/null +++ b/front/android/app/capacitor.build.gradle @@ -0,0 +1,19 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN + +android { + compileOptions { + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 + } +} + +apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" +dependencies { + + +} + + +if (hasProperty('postBuildExtras')) { + postBuildExtras() +} diff --git a/front/android/app/proguard-rules.pro b/front/android/app/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/front/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/front/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java b/front/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java new file mode 100644 index 0000000..f2c2217 --- /dev/null +++ b/front/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package com.getcapacitor.myapp; + +import static org.junit.Assert.*; + +import android.content.Context; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + + @Test + public void useAppContext() throws Exception { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + + assertEquals("com.getcapacitor.app", appContext.getPackageName()); + } +} diff --git a/front/android/app/src/main/AndroidManifest.xml b/front/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b06ddbf --- /dev/null +++ b/front/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/front/android/app/src/main/java/xyz/yoyuzh/portal/MainActivity.java b/front/android/app/src/main/java/xyz/yoyuzh/portal/MainActivity.java new file mode 100644 index 0000000..9ab7501 --- /dev/null +++ b/front/android/app/src/main/java/xyz/yoyuzh/portal/MainActivity.java @@ -0,0 +1,5 @@ +package xyz.yoyuzh.portal; + +import com.getcapacitor.BridgeActivity; + +public class MainActivity extends BridgeActivity {} diff --git a/front/android/app/src/main/res/drawable-land-hdpi/splash.png b/front/android/app/src/main/res/drawable-land-hdpi/splash.png new file mode 100644 index 0000000..e31573b Binary files /dev/null and b/front/android/app/src/main/res/drawable-land-hdpi/splash.png differ diff --git a/front/android/app/src/main/res/drawable-land-mdpi/splash.png b/front/android/app/src/main/res/drawable-land-mdpi/splash.png new file mode 100644 index 0000000..f7a6492 Binary files /dev/null and b/front/android/app/src/main/res/drawable-land-mdpi/splash.png differ diff --git a/front/android/app/src/main/res/drawable-land-xhdpi/splash.png b/front/android/app/src/main/res/drawable-land-xhdpi/splash.png new file mode 100644 index 0000000..8077255 Binary files /dev/null and b/front/android/app/src/main/res/drawable-land-xhdpi/splash.png differ diff --git a/front/android/app/src/main/res/drawable-land-xxhdpi/splash.png b/front/android/app/src/main/res/drawable-land-xxhdpi/splash.png new file mode 100644 index 0000000..14c6c8f Binary files /dev/null and b/front/android/app/src/main/res/drawable-land-xxhdpi/splash.png differ diff --git a/front/android/app/src/main/res/drawable-land-xxxhdpi/splash.png b/front/android/app/src/main/res/drawable-land-xxxhdpi/splash.png new file mode 100644 index 0000000..244ca25 Binary files /dev/null and b/front/android/app/src/main/res/drawable-land-xxxhdpi/splash.png differ diff --git a/front/android/app/src/main/res/drawable-port-hdpi/splash.png b/front/android/app/src/main/res/drawable-port-hdpi/splash.png new file mode 100644 index 0000000..74faaa5 Binary files /dev/null and b/front/android/app/src/main/res/drawable-port-hdpi/splash.png differ diff --git a/front/android/app/src/main/res/drawable-port-mdpi/splash.png b/front/android/app/src/main/res/drawable-port-mdpi/splash.png new file mode 100644 index 0000000..e944f4a Binary files /dev/null and b/front/android/app/src/main/res/drawable-port-mdpi/splash.png differ diff --git a/front/android/app/src/main/res/drawable-port-xhdpi/splash.png b/front/android/app/src/main/res/drawable-port-xhdpi/splash.png new file mode 100644 index 0000000..564a82f Binary files /dev/null and b/front/android/app/src/main/res/drawable-port-xhdpi/splash.png differ diff --git a/front/android/app/src/main/res/drawable-port-xxhdpi/splash.png b/front/android/app/src/main/res/drawable-port-xxhdpi/splash.png new file mode 100644 index 0000000..bfabe68 Binary files /dev/null and b/front/android/app/src/main/res/drawable-port-xxhdpi/splash.png differ diff --git a/front/android/app/src/main/res/drawable-port-xxxhdpi/splash.png b/front/android/app/src/main/res/drawable-port-xxxhdpi/splash.png new file mode 100644 index 0000000..6929071 Binary files /dev/null and b/front/android/app/src/main/res/drawable-port-xxxhdpi/splash.png differ diff --git a/front/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/front/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..c7bd21d --- /dev/null +++ b/front/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/front/android/app/src/main/res/drawable/ic_launcher_background.xml b/front/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..d5fccc5 --- /dev/null +++ b/front/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/front/android/app/src/main/res/drawable/splash.png b/front/android/app/src/main/res/drawable/splash.png new file mode 100644 index 0000000..f7a6492 Binary files /dev/null and b/front/android/app/src/main/res/drawable/splash.png differ diff --git a/front/android/app/src/main/res/layout/activity_main.xml b/front/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..b5ad138 --- /dev/null +++ b/front/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/front/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/front/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/front/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/front/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/front/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/front/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/front/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/front/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..c023e50 Binary files /dev/null and b/front/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/front/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/front/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..2127973 Binary files /dev/null and b/front/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/front/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/front/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..b441f37 Binary files /dev/null and b/front/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/front/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/front/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..72905b8 Binary files /dev/null and b/front/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/front/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/front/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..8ed0605 Binary files /dev/null and b/front/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/front/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/front/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..9502e47 Binary files /dev/null and b/front/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/front/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/front/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..4d1e077 Binary files /dev/null and b/front/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/front/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/front/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..df0f158 Binary files /dev/null and b/front/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/front/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/front/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..853db04 Binary files /dev/null and b/front/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/front/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/front/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..6cdf97c Binary files /dev/null and b/front/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/front/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/front/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..2960cbb Binary files /dev/null and b/front/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/front/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/front/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..8e3093a Binary files /dev/null and b/front/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/front/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/front/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..46de6e2 Binary files /dev/null and b/front/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/front/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/front/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..d2ea9ab Binary files /dev/null and b/front/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/front/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/front/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..a40d73e Binary files /dev/null and b/front/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/front/android/app/src/main/res/values/ic_launcher_background.xml b/front/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..c5d5899 --- /dev/null +++ b/front/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/front/android/app/src/main/res/values/strings.xml b/front/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..a8953cd --- /dev/null +++ b/front/android/app/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + YOYUZH + YOYUZH + xyz.yoyuzh.portal + xyz.yoyuzh.portal + diff --git a/front/android/app/src/main/res/values/styles.xml b/front/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..be874e5 --- /dev/null +++ b/front/android/app/src/main/res/values/styles.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/front/android/app/src/main/res/xml/file_paths.xml b/front/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..bd0c4d8 --- /dev/null +++ b/front/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/front/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java b/front/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java new file mode 100644 index 0000000..0297327 --- /dev/null +++ b/front/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java @@ -0,0 +1,18 @@ +package com.getcapacitor.myapp; + +import static org.junit.Assert.*; + +import org.junit.Test; + +/** + * Example local unit test, which will execute on the development machine (host). + * + * @see Testing documentation + */ +public class ExampleUnitTest { + + @Test + public void addition_isCorrect() throws Exception { + assertEquals(4, 2 + 2); + } +} diff --git a/front/android/build.gradle b/front/android/build.gradle new file mode 100644 index 0000000..981aef8 --- /dev/null +++ b/front/android/build.gradle @@ -0,0 +1,31 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + def googleMirror = 'https://maven.aliyun.com/repository/google' + + repositories { + maven { url googleMirror } + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.13.0' + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +apply from: "variables.gradle" + +allprojects { + def googleMirror = 'https://maven.aliyun.com/repository/google' + + repositories { + maven { url googleMirror } + mavenCentral() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/front/android/capacitor.settings.gradle b/front/android/capacitor.settings.gradle new file mode 100644 index 0000000..9a5fa87 --- /dev/null +++ b/front/android/capacitor.settings.gradle @@ -0,0 +1,3 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN +include ':capacitor-android' +project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') diff --git a/front/android/gradle.properties b/front/android/gradle.properties new file mode 100644 index 0000000..2e87c52 --- /dev/null +++ b/front/android/gradle.properties @@ -0,0 +1,22 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true diff --git a/front/android/gradle/wrapper/gradle-wrapper.jar b/front/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/front/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/front/android/gradle/wrapper/gradle-wrapper.properties b/front/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..7705927 --- /dev/null +++ b/front/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/front/android/gradlew b/front/android/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/front/android/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/front/android/gradlew.bat b/front/android/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/front/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/front/android/settings.gradle b/front/android/settings.gradle new file mode 100644 index 0000000..3b4431d --- /dev/null +++ b/front/android/settings.gradle @@ -0,0 +1,5 @@ +include ':app' +include ':capacitor-cordova-android-plugins' +project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/') + +apply from: 'capacitor.settings.gradle' \ No newline at end of file diff --git a/front/android/variables.gradle b/front/android/variables.gradle new file mode 100644 index 0000000..ee4ba41 --- /dev/null +++ b/front/android/variables.gradle @@ -0,0 +1,16 @@ +ext { + minSdkVersion = 24 + compileSdkVersion = 36 + targetSdkVersion = 36 + androidxActivityVersion = '1.11.0' + androidxAppCompatVersion = '1.7.1' + androidxCoordinatorLayoutVersion = '1.3.0' + androidxCoreVersion = '1.17.0' + androidxFragmentVersion = '1.8.9' + coreSplashScreenVersion = '1.2.0' + androidxWebkitVersion = '1.14.0' + junitVersion = '4.13.2' + androidxJunitVersion = '1.3.0' + androidxEspressoCoreVersion = '3.7.0' + cordovaAndroidVersion = '14.0.1' +} \ No newline at end of file diff --git a/front/capacitor.config.ts b/front/capacitor.config.ts new file mode 100644 index 0000000..85227a8 --- /dev/null +++ b/front/capacitor.config.ts @@ -0,0 +1,9 @@ +import type { CapacitorConfig } from '@capacitor/cli'; + +const config: CapacitorConfig = { + appId: 'xyz.yoyuzh.portal', + appName: 'YOYUZH', + webDir: 'dist' +}; + +export default config; diff --git a/front/index.html b/front/index.html index d85c3de..680266b 100644 --- a/front/index.html +++ b/front/index.html @@ -2,7 +2,7 @@ - + 优立云盘 diff --git a/front/package-lock.json b/front/package-lock.json index 2be3420..885cba7 100644 --- a/front/package-lock.json +++ b/front/package-lock.json @@ -8,12 +8,16 @@ "name": "react-example", "version": "0.0.0", "dependencies": { + "@capacitor/android": "^8.3.0", + "@capacitor/cli": "^8.3.0", + "@capacitor/core": "^8.3.0", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@google/genai": "^1.29.0", "@mui/icons-material": "^7.3.9", "@mui/material": "^7.3.9", "@tailwindcss/vite": "^4.1.14", + "@types/simple-peer": "^9.11.9", "@vitejs/plugin-react": "^5.0.4", "better-sqlite3": "^12.4.1", "clsx": "^2.1.1", @@ -27,6 +31,7 @@ "react-admin": "^5.14.4", "react-dom": "^19.0.0", "react-router-dom": "^7.13.1", + "simple-peer": "^9.11.1", "tailwind-merge": "^3.5.0", "vite": "^6.2.0" }, @@ -312,6 +317,165 @@ "node": ">=6.9.0" } }, + "node_modules/@capacitor/android": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.3.0.tgz", + "integrity": "sha512-EQy6ByUuKayQBJmMm/e0byJiHavqsQHrvW23BuT2GNVQvenAvipqwaePiJHzrv2PZr7A0T0+se4kgDCeROj0mQ==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^8.3.0" + } + }, + "node_modules/@capacitor/cli": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.3.0.tgz", + "integrity": "sha512-n3QDUimtFNbagoo8kLdjvTz3i3Y4jX1fOjvo6ptUKLzErmuqeamL8kECASoyQvg/OzJisZToGZrgLphBsptJcw==", + "license": "MIT", + "dependencies": { + "@ionic/cli-framework-output": "^2.2.8", + "@ionic/utils-subprocess": "^3.0.1", + "@ionic/utils-terminal": "^2.3.5", + "commander": "^12.1.0", + "debug": "^4.4.0", + "env-paths": "^2.2.0", + "fs-extra": "^11.2.0", + "kleur": "^4.1.5", + "native-run": "^2.0.3", + "open": "^8.4.0", + "plist": "^3.1.0", + "prompts": "^2.4.2", + "rimraf": "^6.0.1", + "semver": "^7.6.3", + "tar": "^7.5.3", + "tslib": "^2.8.1", + "xml2js": "^0.6.2" + }, + "bin": { + "cap": "bin/capacitor", + "capacitor": "bin/capacitor" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@capacitor/cli/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@capacitor/cli/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@capacitor/cli/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@capacitor/cli/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@capacitor/cli/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@capacitor/cli/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@capacitor/cli/node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@capacitor/cli/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@capacitor/core": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.3.0.tgz", + "integrity": "sha512-S4ajn4G/fS3VJj8salxqH/3LO5PPWv1VxGKQ27OCajnDcLJjEg9VXwgMPnlypgkIOqCJ2fmQLtk8GT+BlI9/rw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", @@ -903,6 +1067,230 @@ } } }, + "node_modules/@ionic/cli-framework-output": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz", + "integrity": "sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==", + "license": "MIT", + "dependencies": { + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-array": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-array/-/utils-array-2.1.6.tgz", + "integrity": "sha512-0JZ1Zkp3wURnv8oq6Qt7fMPo5MpjbLoUoa9Bu2Q4PJuSDWM8H8gwF3dQO7VTeUj3/0o1IB1wGkFWZZYgUXZMUg==", + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-fs/-/utils-fs-3.1.7.tgz", + "integrity": "sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA==", + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^8.0.0", + "debug": "^4.0.0", + "fs-extra": "^9.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@ionic/utils-object": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-object/-/utils-object-2.1.6.tgz", + "integrity": "sha512-vCl7sl6JjBHFw99CuAqHljYJpcE88YaH2ZW4ELiC/Zwxl5tiwn4kbdP/gxi2OT3MQb1vOtgAmSNRtusvgxI8ww==", + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-process": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@ionic/utils-process/-/utils-process-2.1.12.tgz", + "integrity": "sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg==", + "license": "MIT", + "dependencies": { + "@ionic/utils-object": "2.1.6", + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "tree-kill": "^1.2.2", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-process/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/@ionic/utils-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-stream/-/utils-stream-3.1.7.tgz", + "integrity": "sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w==", + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-subprocess": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ionic/utils-subprocess/-/utils-subprocess-3.0.1.tgz", + "integrity": "sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A==", + "license": "MIT", + "dependencies": { + "@ionic/utils-array": "2.1.6", + "@ionic/utils-fs": "3.1.7", + "@ionic/utils-process": "2.1.12", + "@ionic/utils-stream": "3.1.7", + "@ionic/utils-terminal": "2.3.5", + "cross-spawn": "^7.0.3", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-terminal": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@ionic/utils-terminal/-/utils-terminal-2.3.5.tgz", + "integrity": "sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A==", + "license": "MIT", + "dependencies": { + "@types/slice-ansi": "^4.0.0", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "slice-ansi": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "tslib": "^2.0.1", + "untildify": "^4.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-terminal/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@ionic/utils-terminal/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@ionic/utils-terminal/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/@ionic/utils-terminal/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/@ionic/utils-terminal/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@ionic/utils-terminal/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@ionic/utils-terminal/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -920,6 +1308,18 @@ "node": ">=12" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1990,6 +2390,15 @@ "@types/send": "*" } }, + "node_modules/@types/fs-extra": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-8.1.5.tgz", + "integrity": "sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -2097,6 +2506,21 @@ "@types/node": "*" } }, + "node_modules/@types/simple-peer": { + "version": "9.11.9", + "resolved": "https://registry.npmjs.org/@types/simple-peer/-/simple-peer-9.11.9.tgz", + "integrity": "sha512-6Gdl7TSS5oh9nuwKD4Pl8cSmaxWycYeZz9HLnJBNvIwWjZuGVsmHe9RwW3+9RxfhC1aIR9Z83DvaJoMw6rhkbg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==", + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -2124,6 +2548,15 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", + "integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -2176,6 +2609,24 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/attr-accept": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", @@ -2298,6 +2749,15 @@ "node": "20.x || 22.x || 23.x || 24.x || 25.x" } }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", @@ -2366,6 +2826,18 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/bplist-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", + "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -2432,6 +2904,15 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -2556,6 +3037,15 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -2735,6 +3225,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/define-properties": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", @@ -2858,6 +3357,18 @@ "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", "license": "ISC" }, + "node_modules/elementtree": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/elementtree/-/elementtree-0.1.7.tgz", + "integrity": "sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==", + "license": "Apache-2.0", + "dependencies": { + "sax": "1.1.4" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -2895,6 +3406,21 @@ "node": ">=10.13.0" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-3.0.1.tgz", + "integrity": "sha512-GiaH0KJUewYok+eeY05IIgjtAe4Yltygk9Wqp1V5yVWLdhf0hYZchRjNIT9bb0mSwRcIusT3cx7PJUf3zEIfUA==", + "license": "MIT" + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -3094,6 +3620,15 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -3293,6 +3828,20 @@ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "license": "MIT" }, + "node_modules/fs-extra": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3354,6 +3903,12 @@ "node": ">=6.9.0" } }, + "node_modules/get-browser-rtc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-browser-rtc/-/get-browser-rtc-1.1.0.tgz", + "integrity": "sha512-MghbMJ61EJrRsDe7w1Bvqt3ZsBuqhce5nrn/XAwgwOXhcsz53/ltdxOse1h/8eKXj5slzxdsz56g5rzOFSGwfQ==", + "license": "MIT" + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -3658,6 +4213,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -3667,6 +4237,18 @@ "node": ">=8" } }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3751,6 +4333,18 @@ "jsonexport": "bin/jsonexport.js" } }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -3772,6 +4366,15 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/lightningcss": { "version": "1.31.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", @@ -4192,6 +4795,18 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -4269,6 +4884,40 @@ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", "license": "MIT" }, + "node_modules/native-run": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/native-run/-/native-run-2.0.3.tgz", + "integrity": "sha512-U1PllBuzW5d1gfan+88L+Hky2eZx+9gv3Pf6rNBxKbORxi7boHzqiA6QFGSnqMem4j0A9tZ08NMIs5+0m/VS1Q==", + "license": "MIT", + "dependencies": { + "@ionic/utils-fs": "^3.1.7", + "@ionic/utils-terminal": "^2.3.4", + "bplist-parser": "^0.3.2", + "debug": "^4.3.4", + "elementtree": "^0.1.7", + "ini": "^4.1.1", + "plist": "^3.1.0", + "split2": "^4.2.0", + "through2": "^4.0.2", + "tslib": "^2.6.2", + "yauzl": "^2.10.0" + }, + "bin": { + "native-run": "bin/native-run" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/native-run/node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -4432,6 +5081,23 @@ "wrappy": "1" } }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-retry": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", @@ -4542,6 +5208,12 @@ "node": ">=8" } }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4560,6 +5232,20 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", @@ -4622,6 +5308,28 @@ "node": ">=10" } }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prompts/node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -4719,6 +5427,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/ra-core": { "version": "5.14.4", "resolved": "https://registry.npmjs.org/ra-core/-/ra-core-5.14.4.tgz", @@ -4808,6 +5536,15 @@ "react-router-dom": "^6.28.1 || ^7.1.1" } }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -5182,6 +5919,12 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sax": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.1.4.tgz", + "integrity": "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==", + "license": "ISC" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -5430,6 +6173,97 @@ "simple-concat": "^1.0.0" } }, + "node_modules/simple-peer": { + "version": "9.11.1", + "resolved": "https://registry.npmjs.org/simple-peer/-/simple-peer-9.11.1.tgz", + "integrity": "sha512-D1SaWpOW8afq1CZGWB8xTfrT3FekjQmPValrqncJMX7QFl8YwhrPTZvMCANLtgBwwdS+7zURyqxDDEmY558tTw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "debug": "^4.3.2", + "err-code": "^3.0.1", + "get-browser-rtc": "^1.1.0", + "queue-microtask": "^1.2.3", + "randombytes": "^2.1.0", + "readable-stream": "^3.6.0" + } + }, + "node_modules/simple-peer/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -5457,6 +6291,15 @@ "node": ">=6" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -5636,6 +6479,22 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", + "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", @@ -5664,6 +6523,33 @@ "node": ">=6" } }, + "node_modules/tar/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "license": "MIT", + "dependencies": { + "readable-stream": "3" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -5689,6 +6575,15 @@ "node": ">=0.6" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -5760,6 +6655,15 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -5769,6 +6673,15 @@ "node": ">= 0.8" } }, + "node_modules/untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -6505,11 +7418,52 @@ } } }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } } } } diff --git a/front/package.json b/front/package.json index a9c73e8..834d150 100644 --- a/front/package.json +++ b/front/package.json @@ -12,12 +12,16 @@ "test": "node --import tsx --test src/**/*.test.ts" }, "dependencies": { + "@capacitor/android": "^8.3.0", + "@capacitor/cli": "^8.3.0", + "@capacitor/core": "^8.3.0", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@google/genai": "^1.29.0", "@mui/icons-material": "^7.3.9", "@mui/material": "^7.3.9", "@tailwindcss/vite": "^4.1.14", + "@types/simple-peer": "^9.11.9", "@vitejs/plugin-react": "^5.0.4", "better-sqlite3": "^12.4.1", "clsx": "^2.1.1", @@ -31,6 +35,7 @@ "react-admin": "^5.14.4", "react-dom": "^19.0.0", "react-router-dom": "^7.13.1", + "simple-peer": "^9.11.1", "tailwind-merge": "^3.5.0", "vite": "^6.2.0" }, diff --git a/front/src/admin/dashboard-state.test.ts b/front/src/admin/dashboard-state.test.ts index 5bbb60b..17ac253 100644 --- a/front/src/admin/dashboard-state.test.ts +++ b/front/src/admin/dashboard-state.test.ts @@ -3,6 +3,7 @@ import test from 'node:test'; import { buildRequestLineChartModel, + buildRequestLineChartXAxisPoints, formatMetricValue, getInviteCodePanelState, parseStorageLimitInput, @@ -19,6 +20,7 @@ test('getInviteCodePanelState returns a copyable invite code when summary contai transferUsageBytes: 0, offlineTransferStorageBytes: 0, offlineTransferStorageLimitBytes: 0, + dailyActiveUsers: [], requestTimeline: [], inviteCode: ' AbCd1234 ', }), @@ -40,6 +42,7 @@ test('getInviteCodePanelState falls back to a placeholder when summary has no in transferUsageBytes: 0, offlineTransferStorageBytes: 0, offlineTransferStorageLimitBytes: 0, + dailyActiveUsers: [], requestTimeline: [], inviteCode: ' ', }), @@ -87,3 +90,38 @@ test('buildRequestLineChartModel converts hourly request data into chart coordin assert.deepEqual(model.yAxisTicks, [0, 15, 30, 45, 60]); assert.equal(model.peakPoint?.label, '02:00'); }); + +test('buildRequestLineChartModel stretches only the available hours across the chart width', () => { + const model = buildRequestLineChartModel([ + { hour: 0, label: '00:00', requestCount: 2 }, + { hour: 1, label: '01:00', requestCount: 4 }, + { hour: 2, label: '02:00', requestCount: 3 }, + { hour: 3, label: '03:00', requestCount: 6 }, + { hour: 4, label: '04:00', requestCount: 5 }, + { hour: 5, label: '05:00', requestCount: 1 }, + { hour: 6, label: '06:00', requestCount: 2 }, + { hour: 7, label: '07:00', requestCount: 4 }, + ]); + + assert.equal(model.points[0]?.x, 0); + assert.equal(model.points.at(-1)?.x, 100); + assert.equal(model.points.length, 8); +}); + +test('buildRequestLineChartXAxisPoints only shows elapsed-hour labels plus start and end', () => { + const model = buildRequestLineChartModel([ + { hour: 0, label: '00:00', requestCount: 2 }, + { hour: 1, label: '01:00', requestCount: 4 }, + { hour: 2, label: '02:00', requestCount: 3 }, + { hour: 3, label: '03:00', requestCount: 6 }, + { hour: 4, label: '04:00', requestCount: 5 }, + { hour: 5, label: '05:00', requestCount: 1 }, + { hour: 6, label: '06:00', requestCount: 2 }, + { hour: 7, label: '07:00', requestCount: 4 }, + ]); + + assert.deepEqual( + buildRequestLineChartXAxisPoints(model.points).map((point) => point.label), + ['00:00', '06:00', '07:00'], + ); +}); diff --git a/front/src/admin/dashboard-state.ts b/front/src/admin/dashboard-state.ts index 6d274a1..2b7cf22 100644 --- a/front/src/admin/dashboard-state.ts +++ b/front/src/admin/dashboard-state.ts @@ -22,6 +22,7 @@ export interface RequestLineChartModel { type MetricValueKind = 'bytes' | 'count'; const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; +const REQUEST_CHART_X_AXIS_HOURS = [0, 6, 12, 18, 23]; export function formatMetricValue(value: number, kind: MetricValueKind): string { if (kind === 'count') { @@ -103,6 +104,23 @@ export function buildRequestLineChartModel(timeline: AdminRequestTimelinePoint[] }; } +export function buildRequestLineChartXAxisPoints(points: RequestLineChartPoint[]): RequestLineChartPoint[] { + if (points.length === 0) { + return []; + } + + const firstHour = points[0]?.hour ?? 0; + const lastHour = points.at(-1)?.hour ?? firstHour; + const visibleHours = new Set([firstHour, lastHour]); + for (const hour of REQUEST_CHART_X_AXIS_HOURS) { + if (hour > firstHour && hour < lastHour) { + visibleHours.add(hour); + } + } + + return points.filter((point) => visibleHours.has(point.hour)); +} + export function getInviteCodePanelState(summary: AdminSummary | null | undefined): InviteCodePanelState { const inviteCode = summary?.inviteCode?.trim() ?? ''; if (!inviteCode) { diff --git a/front/src/admin/dashboard.tsx b/front/src/admin/dashboard.tsx index f68729a..0c2c1ff 100644 --- a/front/src/admin/dashboard.tsx +++ b/front/src/admin/dashboard.tsx @@ -14,7 +14,13 @@ import { useNavigate } from 'react-router-dom'; import { apiRequest } from '@/src/lib/api'; import { readStoredSession } from '@/src/lib/session'; import type { AdminOfflineTransferStorageLimitResponse, AdminSummary } from '@/src/lib/types'; -import { buildRequestLineChartModel, formatMetricValue, getInviteCodePanelState, parseStorageLimitInput } from './dashboard-state'; +import { + buildRequestLineChartModel, + buildRequestLineChartXAxisPoints, + formatMetricValue, + getInviteCodePanelState, + parseStorageLimitInput, +} from './dashboard-state'; interface DashboardState { summary: AdminSummary | null; @@ -30,7 +36,6 @@ interface MetricCardDefinition { helper: string; } -const REQUEST_CHART_X_AXIS_HOURS = new Set([0, 6, 12, 18, 23]); const DASHBOARD_CARD_BG = '#111827'; const DASHBOARD_CARD_BORDER = 'rgba(148, 163, 184, 0.22)'; const DASHBOARD_CARD_TEXT = '#f8fafc'; @@ -129,7 +134,7 @@ function RequestTrendChart({ summary }: { summary: AdminSummary }) { const chart = buildRequestLineChartModel(summary.requestTimeline); const currentHour = new Date().getHours(); const currentPoint = chart.points.find((point) => point.hour === currentHour) ?? chart.points.at(-1) ?? null; - const xAxisPoints = chart.points.filter((point) => REQUEST_CHART_X_AXIS_HOURS.has(point.hour)); + const xAxisPoints = buildRequestLineChartXAxisPoints(chart.points); const hasRequests = chart.maxValue > 0; const scaleMax = chart.maxValue > 0 ? chart.maxValue : 4; @@ -161,7 +166,7 @@ function RequestTrendChart({ summary }: { summary: AdminSummary }) { color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary', })} > - 按小时统计今天的 `/api/**` 请求,当前小时会持续累加,方便判断白天峰值和异常抖动。 + 按小时统计今天已发生的 `/api/**` 请求;曲线会随当天已过时间自然拉长,不再预留未来小时。 @@ -340,21 +345,26 @@ function RequestTrendChart({ summary }: { summary: AdminSummary }) { vectorEffect="non-scaling-stroke" /> )} - - {chart.points.map((point) => ( - - ))} + {chart.points.map((point) => ( + + ))} + {!hasRequests && ( ({ + borderColor: theme.palette.mode === 'dark' ? DASHBOARD_CARD_BORDER : 'divider', + backgroundColor: theme.palette.mode === 'dark' ? DASHBOARD_CARD_BG : '#fff', + color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : theme.palette.text.primary, + boxShadow: theme.palette.mode === 'dark' ? '0 20px 45px rgba(15, 23, 42, 0.28)' : 'none', + })} + > + + + + + + 最近 7 天上线记录 + + ({ + color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary', + })} + > + JWT 鉴权成功后会记录当天首次上线用户,只保留最近 7 天,便于回看每天有多少人上线以及具体是谁。 + + + + theme.palette.mode === 'dark' ? 'rgba(16, 185, 129, 0.12)' : '#ecfdf5', + border: (theme) => theme.palette.mode === 'dark' ? '1px solid rgba(52, 211, 153, 0.18)' : '1px solid transparent', + }} + > + ({ + color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary', + })} + fontWeight={700} + > + 今日上线人数 + + ({ + color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : 'text.primary', + })} + > + {formatMetricValue(latestDay?.userCount ?? 0, 'count')} + + ({ + color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary', + })} + > + {latestDay?.label ?? '--'} + + + + + + {summary.dailyActiveUsers.slice().reverse().map((day) => ( + ({ + px: 1.5, + py: 1.25, + borderRadius: 2, + border: theme.palette.mode === 'dark' ? '1px solid rgba(148, 163, 184, 0.16)' : '1px solid rgba(148, 163, 184, 0.24)', + backgroundColor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.03)' : '#f8fafc', + })} + > + + + {day.label} + ({ + px: 0.9, + py: 0.3, + borderRadius: 99, + color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : 'text.primary', + backgroundColor: theme.palette.mode === 'dark' ? 'rgba(59, 130, 246, 0.18)' : '#dbeafe', + })} + > + {formatMetricValue(day.userCount, 'count')} 人 + + ({ + color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary', + })} + > + {day.metricDate} + + + + ({ + color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary', + })} + > + {day.usernames.length > 0 ? day.usernames.join('、') : '当天无人上线'} + + + + ))} + + + + + ); +} + export function PortalAdminDashboard() { const [state, setState] = useState({ summary: null, @@ -586,7 +731,12 @@ export function PortalAdminDashboard() { ))} - {summary && } + {summary && ( + + + + + )} diff --git a/front/src/auth/admin-access.test.ts b/front/src/auth/admin-access.test.ts index 836c697..9d661ba 100644 --- a/front/src/auth/admin-access.test.ts +++ b/front/src/auth/admin-access.test.ts @@ -15,6 +15,7 @@ test('fetchAdminAccessStatus returns true when the admin summary request succeed transferUsageBytes: 0, offlineTransferStorageBytes: 0, offlineTransferStorageLimitBytes: 0, + dailyActiveUsers: [], requestTimeline: [], inviteCode: 'invite-code', }); diff --git a/front/src/index.css b/front/src/index.css index d2f4ccf..216525f 100644 --- a/front/src/index.css +++ b/front/src/index.css @@ -19,12 +19,29 @@ --color-glass-active: rgba(255, 255, 255, 0.1); } +:root { + --app-safe-area-top: max(env(safe-area-inset-top, 0px), var(--safe-area-inset-top, 0px)); + --app-safe-area-bottom: max(env(safe-area-inset-bottom, 0px), var(--safe-area-inset-bottom, 0px)); +} + +html, +body, +#root { + width: 100%; + min-height: 100%; + height: 100%; + margin: 0; + padding: 0; + background-color: var(--color-bg-base); +} + body { background-color: var(--color-bg-base); color: var(--color-text-primary); font-family: var(--font-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + overflow-x: hidden; } /* Custom Scrollbar */ @@ -59,6 +76,14 @@ body { background: var(--color-glass-active); } +.safe-area-pt { + padding-top: var(--app-safe-area-top); +} + +.safe-area-pb { + padding-bottom: var(--app-safe-area-bottom); +} + /* Animations */ @keyframes blob { 0% { diff --git a/front/src/lib/api.test.ts b/front/src/lib/api.test.ts index cc1d4bd..80bb7b2 100644 --- a/front/src/lib/api.test.ts +++ b/front/src/lib/api.test.ts @@ -35,6 +35,7 @@ class MemoryStorage implements Storage { const originalFetch = globalThis.fetch; const originalStorage = globalThis.localStorage; const originalXMLHttpRequest = globalThis.XMLHttpRequest; +const originalLocation = globalThis.location; class FakeXMLHttpRequest { static latest: FakeXMLHttpRequest | null = null; @@ -136,6 +137,10 @@ afterEach(() => { configurable: true, value: originalXMLHttpRequest, }); + Object.defineProperty(globalThis, 'location', { + configurable: true, + value: originalLocation, + }); }); test('apiRequest attaches bearer token and unwraps response payload', async () => { @@ -180,6 +185,74 @@ test('apiRequest attaches bearer token and unwraps response payload', async () = assert.equal(request.url, 'http://localhost/api/files/recent'); }); +test('apiRequest uses the production api origin inside the Capacitor localhost shell', async () => { + let request: Request | URL | string | undefined; + Object.defineProperty(globalThis, 'location', { + configurable: true, + value: new URL('http://localhost'), + }); + + globalThis.fetch = async (input, init) => { + request = + input instanceof Request + ? input + : new Request(new URL(String(input), 'https://fallback.example.com'), init); + return new Response( + JSON.stringify({ + code: 0, + msg: 'success', + data: { + ok: true, + }, + }), + { + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + }; + + await apiRequest<{ok: boolean}>('/files/recent'); + + assert.ok(request instanceof Request); + assert.equal(request.url, 'https://api.yoyuzh.xyz/api/files/recent'); +}); + +test('apiRequest uses the production api origin inside the Capacitor https localhost shell', async () => { + let request: Request | URL | string | undefined; + Object.defineProperty(globalThis, 'location', { + configurable: true, + value: new URL('https://localhost'), + }); + + globalThis.fetch = async (input, init) => { + request = + input instanceof Request + ? input + : new Request(new URL(String(input), 'https://fallback.example.com'), init); + return new Response( + JSON.stringify({ + code: 0, + msg: 'success', + data: { + ok: true, + }, + }), + { + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + }; + + await apiRequest<{ok: boolean}>('/files/recent'); + + assert.ok(request instanceof Request); + assert.equal(request.url, 'https://api.yoyuzh.xyz/api/files/recent'); +}); + test('apiRequest throws backend message on business error', async () => { globalThis.fetch = async () => new Response( diff --git a/front/src/lib/api.ts b/front/src/lib/api.ts index 06c55b4..c8eea95 100644 --- a/front/src/lib/api.ts +++ b/front/src/lib/api.ts @@ -27,8 +27,9 @@ interface ApiBinaryUploadRequestInit { signal?: AbortSignal; } -const API_BASE_URL = (import.meta.env?.VITE_API_BASE_URL || '/api').replace(/\/$/, ''); const AUTH_REFRESH_PATH = '/auth/refresh'; +const DEFAULT_API_BASE_URL = '/api'; +const DEFAULT_CAPACITOR_API_ORIGIN = 'https://api.yoyuzh.xyz'; let refreshRequestPromise: Promise | null = null; @@ -90,13 +91,57 @@ function getRetryDelayForRequest(path: string, init: ApiRequestInit = {}, attemp return getRetryDelayMs(attempt); } +function resolveRuntimeLocation() { + if (typeof globalThis.location !== 'undefined') { + return globalThis.location; + } + + if (typeof window !== 'undefined') { + return window.location; + } + + return null; +} + +function isCapacitorLocalhostOrigin(location: Location | URL | null) { + if (!location) { + return false; + } + + const protocol = location.protocol || ''; + const hostname = location.hostname || ''; + const port = location.port || ''; + + if (protocol === 'capacitor:') { + return true; + } + + const isLocalhostHost = hostname === 'localhost' || hostname === '127.0.0.1'; + const isCapacitorLocalScheme = protocol === 'http:' || protocol === 'https:'; + + return isCapacitorLocalScheme && isLocalhostHost && port === ''; +} + +export function getApiBaseUrl() { + const configuredBaseUrl = import.meta.env?.VITE_API_BASE_URL?.replace(/\/$/, ''); + if (configuredBaseUrl) { + return configuredBaseUrl; + } + + if (isCapacitorLocalhostOrigin(resolveRuntimeLocation())) { + return `${DEFAULT_CAPACITOR_API_ORIGIN}${DEFAULT_API_BASE_URL}`; + } + + return DEFAULT_API_BASE_URL; +} + function resolveUrl(path: string) { if (/^https?:\/\//.test(path)) { return path; } const normalizedPath = path.startsWith('/') ? path : `/${path}`; - return `${API_BASE_URL}${normalizedPath}`; + return `${getApiBaseUrl()}${normalizedPath}`; } function normalizePath(path: string) { diff --git a/front/src/lib/transfer-ice.test.ts b/front/src/lib/transfer-ice.test.ts new file mode 100644 index 0000000..760bd14 --- /dev/null +++ b/front/src/lib/transfer-ice.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + DEFAULT_TRANSFER_ICE_SERVERS, + hasRelayTransferIceServer, + resolveTransferIceServers, +} from './transfer-ice'; + +test('resolveTransferIceServers falls back to the default STUN list when no custom config is provided', () => { + assert.deepEqual(resolveTransferIceServers(), DEFAULT_TRANSFER_ICE_SERVERS); + assert.deepEqual(resolveTransferIceServers(''), DEFAULT_TRANSFER_ICE_SERVERS); + assert.deepEqual(resolveTransferIceServers('not-json'), DEFAULT_TRANSFER_ICE_SERVERS); +}); + +test('resolveTransferIceServers appends custom TURN servers after the default STUN list', () => { + const iceServers = resolveTransferIceServers(JSON.stringify([ + { + urls: ['turn:turn.yoyuzh.xyz:3478?transport=udp', 'turns:turn.yoyuzh.xyz:5349'], + username: 'portal-user', + credential: 'portal-secret', + }, + ])); + + assert.deepEqual(iceServers, [ + ...DEFAULT_TRANSFER_ICE_SERVERS, + { + urls: ['turn:turn.yoyuzh.xyz:3478?transport=udp', 'turns:turn.yoyuzh.xyz:5349'], + username: 'portal-user', + credential: 'portal-secret', + }, + ]); +}); + +test('hasRelayTransferIceServer detects whether TURN relay servers are configured', () => { + assert.equal(hasRelayTransferIceServer(DEFAULT_TRANSFER_ICE_SERVERS), false); + assert.equal(hasRelayTransferIceServer(resolveTransferIceServers(JSON.stringify([ + { + urls: 'turn:turn.yoyuzh.xyz:3478?transport=udp', + username: 'portal-user', + credential: 'portal-secret', + }, + ]))), true); +}); diff --git a/front/src/lib/transfer-ice.ts b/front/src/lib/transfer-ice.ts new file mode 100644 index 0000000..ce87342 --- /dev/null +++ b/front/src/lib/transfer-ice.ts @@ -0,0 +1,91 @@ +const DEFAULT_STUN_ICE_SERVERS: RTCIceServer[] = [ + { urls: 'stun:stun.cloudflare.com:3478' }, + { urls: 'stun:stun.l.google.com:19302' }, +]; + +const RELAY_HINT = + '当前环境只配置了 STUN,跨运营商或手机移动网络通常还需要 TURN 中继。'; + +type RawIceServer = { + urls?: unknown; + username?: unknown; + credential?: unknown; +}; + +export const DEFAULT_TRANSFER_ICE_SERVERS = DEFAULT_STUN_ICE_SERVERS; + +export function resolveTransferIceServers(rawConfig = import.meta.env?.VITE_TRANSFER_ICE_SERVERS_JSON) { + if (typeof rawConfig !== 'string' || !rawConfig.trim()) { + return DEFAULT_TRANSFER_ICE_SERVERS; + } + + try { + const parsed = JSON.parse(rawConfig) as unknown; + if (!Array.isArray(parsed)) { + return DEFAULT_TRANSFER_ICE_SERVERS; + } + + const customServers = parsed + .map(normalizeIceServer) + .filter((server): server is RTCIceServer => server != null); + + if (customServers.length === 0) { + return DEFAULT_TRANSFER_ICE_SERVERS; + } + + return [...DEFAULT_TRANSFER_ICE_SERVERS, ...customServers]; + } catch { + return DEFAULT_TRANSFER_ICE_SERVERS; + } +} + +export function hasRelayTransferIceServer(iceServers: RTCIceServer[]) { + return iceServers.some((server) => toUrls(server.urls).some((url) => /^turns?:/i.test(url))); +} + +export function appendTransferRelayHint(message: string, hasRelaySupport: boolean) { + const normalizedMessage = message.trim(); + if (!normalizedMessage || hasRelaySupport || normalizedMessage.includes(RELAY_HINT)) { + return normalizedMessage; + } + return `${normalizedMessage} ${RELAY_HINT}`; +} + +function normalizeIceServer(rawServer: RawIceServer) { + const urls = normalizeUrls(rawServer?.urls); + if (urls == null) { + return null; + } + + const server: RTCIceServer = { urls }; + if (typeof rawServer.username === 'string' && rawServer.username.trim()) { + server.username = rawServer.username.trim(); + } + if (typeof rawServer.credential === 'string' && rawServer.credential.trim()) { + server.credential = rawServer.credential.trim(); + } + return server; +} + +function normalizeUrls(rawUrls: unknown): string | string[] | null { + if (typeof rawUrls === 'string' && rawUrls.trim()) { + return rawUrls.trim(); + } + + if (!Array.isArray(rawUrls)) { + return null; + } + + const urls = rawUrls + .filter((item): item is string => typeof item === 'string' && item.trim().length > 0) + .map((item) => item.trim()); + + return urls.length > 0 ? urls : null; +} + +function toUrls(urls: string | string[] | undefined) { + if (!urls) { + return []; + } + return Array.isArray(urls) ? urls : [urls]; +} diff --git a/front/src/lib/transfer-peer.test.ts b/front/src/lib/transfer-peer.test.ts new file mode 100644 index 0000000..1ba188e --- /dev/null +++ b/front/src/lib/transfer-peer.test.ts @@ -0,0 +1,153 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import test from 'node:test'; + +import { + createTransferPeer, + parseTransferPeerSignal, + serializeTransferPeerSignal, + type TransferPeerPayload, +} from './transfer-peer'; + +class FakePeer extends EventEmitter { + destroyed = false; + sent: Array = []; + signaled: unknown[] = []; + writeReturnValue = true; + bufferSize = 0; + + send(payload: string | Uint8Array | ArrayBuffer) { + this.sent.push(payload); + } + + write(payload: string | Uint8Array | ArrayBuffer) { + this.sent.push(payload); + return this.writeReturnValue; + } + + signal(payload: unknown) { + this.signaled.push(payload); + } + + destroy() { + this.destroyed = true; + this.emit('close'); + } +} + +test('serializeTransferPeerSignal and parseTransferPeerSignal preserve signal payloads', () => { + const payload = { + type: 'offer' as const, + sdp: 'v=0', + }; + + assert.deepEqual(parseTransferPeerSignal(serializeTransferPeerSignal(payload)), payload); +}); + +test('createTransferPeer forwards local simple-peer signals to the app layer', () => { + const fakePeer = new FakePeer(); + const seenSignals: string[] = []; + let receivedOptions: Record | null = null; + + createTransferPeer({ + initiator: true, + onSignal: (payload) => { + seenSignals.push(payload); + }, + createPeer: (options) => { + receivedOptions = options as Record; + return fakePeer as never; + }, + }); + + fakePeer.emit('signal', { + type: 'answer' as const, + sdp: 'v=0', + }); + + assert.deepEqual(seenSignals, [JSON.stringify({ type: 'answer', sdp: 'v=0' })]); + assert.equal(receivedOptions?.objectMode, true); +}); + +test('createTransferPeer routes remote signals, data, connect, close, and error events through the adapter', () => { + const fakePeer = new FakePeer(); + let connected = 0; + let closed = 0; + const dataPayloads: TransferPeerPayload[] = []; + const errors: string[] = []; + + const peer = createTransferPeer({ + initiator: false, + onConnect: () => { + connected += 1; + }, + onData: (payload) => { + dataPayloads.push(payload); + }, + onClose: () => { + closed += 1; + }, + onError: (error) => { + errors.push(error.message); + }, + createPeer: () => fakePeer as never, + }); + + peer.applyRemoteSignal(JSON.stringify({ candidate: 'candidate:1' })); + peer.send('hello'); + fakePeer.emit('connect'); + fakePeer.emit('data', 'payload'); + fakePeer.emit('error', new Error('boom')); + peer.destroy(); + + assert.deepEqual(fakePeer.signaled, [{ candidate: 'candidate:1' }]); + assert.deepEqual(fakePeer.sent, ['hello']); + assert.equal(connected, 1); + assert.deepEqual(dataPayloads, ['payload']); + assert.deepEqual(errors, ['boom']); + assert.equal(closed, 1); + assert.equal(fakePeer.destroyed, true); +}); + +test('createTransferPeer waits for drain when the wrapped peer applies backpressure', async () => { + const fakePeer = new FakePeer(); + fakePeer.bufferSize = 2048; + const peer = createTransferPeer({ + initiator: true, + createPeer: () => fakePeer as never, + }); + + let completed = false; + const writePromise = peer.write('chunk').then(() => { + completed = true; + }); + + await new Promise((resolve) => setTimeout(resolve, 5)); + assert.equal(completed, false); + + fakePeer.emit('drain'); + await writePromise; + assert.equal(completed, true); +}); + +test('createTransferPeer falls back to bufferSize polling when drain is not emitted', async () => { + const fakePeer = new FakePeer(); + fakePeer.bufferSize = 2048; + const peer = createTransferPeer({ + initiator: true, + createPeer: () => fakePeer as never, + }); + + let completed = false; + const writePromise = peer.write('chunk').then(() => { + completed = true; + }); + + await new Promise((resolve) => setTimeout(resolve, 5)); + assert.equal(completed, false); + + fakePeer.bufferSize = 0; + await writePromise; + assert.equal(completed, true); + assert.deepEqual(fakePeer.sent, ['chunk']); +}); diff --git a/front/src/lib/transfer-peer.ts b/front/src/lib/transfer-peer.ts new file mode 100644 index 0000000..15ccf2e --- /dev/null +++ b/front/src/lib/transfer-peer.ts @@ -0,0 +1,138 @@ +import Peer from 'simple-peer/simplepeer.min.js'; +import type { Instance as SimplePeerInstance, Options as SimplePeerOptions, SignalData } from 'simple-peer'; + +export type TransferPeerPayload = string | Uint8Array | ArrayBuffer | Blob; + +const TRANSFER_PEER_BUFFER_POLL_INTERVAL_MS = 16; + +interface TransferPeerLike { + bufferSize?: number; + connected?: boolean; + destroyed?: boolean; + on(event: 'signal', listener: (signal: SignalData) => void): this; + on(event: 'connect', listener: () => void): this; + on(event: 'data', listener: (data: TransferPeerPayload) => void): this; + on(event: 'close', listener: () => void): this; + on(event: 'error', listener: (error: Error) => void): this; + once?(event: 'drain', listener: () => void): this; + removeListener?(event: 'drain', listener: () => void): this; + signal(signal: SignalData): void; + send(payload: TransferPeerPayload): void; + write?(payload: TransferPeerPayload): boolean; + destroy(): void; +} + +export interface TransferPeerAdapter { + readonly connected: boolean; + readonly destroyed: boolean; + applyRemoteSignal(payload: string): void; + send(payload: TransferPeerPayload): void; + write(payload: TransferPeerPayload): Promise; + destroy(): void; +} + +export interface CreateTransferPeerOptions { + initiator: boolean; + trickle?: boolean; + peerOptions?: Omit; + onSignal?: (payload: string) => void; + onConnect?: () => void; + onData?: (payload: TransferPeerPayload) => void; + onClose?: () => void; + onError?: (error: Error) => void; + createPeer?: (options: SimplePeerOptions) => TransferPeerLike; +} + +export function serializeTransferPeerSignal(signal: SignalData) { + return JSON.stringify(signal); +} + +export function parseTransferPeerSignal(payload: string) { + return JSON.parse(payload) as SignalData; +} + +function waitForPeerBufferToClear(peer: TransferPeerLike) { + if (!peer.bufferSize || peer.bufferSize <= 0) { + return Promise.resolve(); + } + + return new Promise((resolve) => { + let settled = false; + let pollTimer: ReturnType | null = null; + + const finish = () => { + if (settled) { + return; + } + settled = true; + if (pollTimer) { + clearInterval(pollTimer); + } + if (peer.removeListener) { + peer.removeListener('drain', finish); + } + resolve(); + }; + + peer.once?.('drain', finish); + pollTimer = setInterval(() => { + if (peer.destroyed || !peer.bufferSize || peer.bufferSize <= 0) { + finish(); + } + }, TRANSFER_PEER_BUFFER_POLL_INTERVAL_MS); + }); +} + +export function createTransferPeer(options: CreateTransferPeerOptions): TransferPeerAdapter { + const peerFactory = options.createPeer ?? ((peerOptions: SimplePeerOptions) => new Peer(peerOptions) as SimplePeerInstance); + const peer = peerFactory({ + initiator: options.initiator, + objectMode: true, + trickle: options.trickle ?? true, + ...options.peerOptions, + }); + + peer.on('signal', (signal) => { + options.onSignal?.(serializeTransferPeerSignal(signal)); + }); + peer.on('connect', () => { + options.onConnect?.(); + }); + peer.on('data', (payload) => { + options.onData?.(payload); + }); + peer.on('close', () => { + options.onClose?.(); + }); + peer.on('error', (error) => { + options.onError?.(error instanceof Error ? error : new Error(String(error))); + }); + + return { + get connected() { + return Boolean(peer.connected); + }, + get destroyed() { + return Boolean(peer.destroyed); + }, + applyRemoteSignal(payload: string) { + peer.signal(parseTransferPeerSignal(payload)); + }, + send(payload: TransferPeerPayload) { + peer.send(payload); + }, + async write(payload: TransferPeerPayload) { + if (!peer.write) { + peer.send(payload); + await waitForPeerBufferToClear(peer); + return; + } + + peer.write(payload); + await waitForPeerBufferToClear(peer); + }, + destroy() { + peer.destroy(); + }, + }; +} diff --git a/front/src/lib/transfer-protocol.test.ts b/front/src/lib/transfer-protocol.test.ts index fe050a8..2040060 100644 --- a/front/src/lib/transfer-protocol.test.ts +++ b/front/src/lib/transfer-protocol.test.ts @@ -118,5 +118,6 @@ test('parseTransferControlMessage returns null for invalid payloads', () => { test('toTransferChunk normalizes ArrayBuffer and Blob data into bytes', async () => { assert.deepEqual(Array.from(await toTransferChunk(new Uint8Array([1, 2, 3]).buffer)), [1, 2, 3]); + assert.deepEqual(Array.from(await toTransferChunk(new Uint8Array([4, 5, 6]))), [4, 5, 6]); assert.deepEqual(Array.from(await toTransferChunk(new Blob(['hi']))), [104, 105]); }); diff --git a/front/src/lib/transfer-runtime.test.ts b/front/src/lib/transfer-runtime.test.ts new file mode 100644 index 0000000..85c39da --- /dev/null +++ b/front/src/lib/transfer-runtime.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + SAFE_TRANSFER_CHUNK_SIZE, + TRANSFER_PROGRESS_UPDATE_INTERVAL_MS, + shouldPublishTransferProgress, + resolveTransferChunkSize, +} from './transfer-runtime'; + +test('resolveTransferChunkSize prefers a conservative default across browsers', () => { + assert.equal(SAFE_TRANSFER_CHUNK_SIZE, 64 * 1024); + assert.equal(resolveTransferChunkSize(undefined), 64 * 1024); + assert.equal(resolveTransferChunkSize(8 * 1024), 8 * 1024); + assert.equal(resolveTransferChunkSize(256 * 1024), 64 * 1024); +}); + +test('shouldPublishTransferProgress throttles noisy intermediate updates but always allows forward progress after the interval', () => { + const initialTime = 10_000; + + assert.equal(shouldPublishTransferProgress({ + nextProgress: 1, + previousProgress: 0, + now: initialTime, + lastPublishedAt: initialTime, + }), false); + + assert.equal(shouldPublishTransferProgress({ + nextProgress: 1, + previousProgress: 0, + now: initialTime + TRANSFER_PROGRESS_UPDATE_INTERVAL_MS, + lastPublishedAt: initialTime, + }), true); +}); + +test('shouldPublishTransferProgress always allows terminal or changed progress states through immediately', () => { + const initialTime = 10_000; + + assert.equal(shouldPublishTransferProgress({ + nextProgress: 100, + previousProgress: 99, + now: initialTime, + lastPublishedAt: initialTime, + }), true); + + assert.equal(shouldPublishTransferProgress({ + nextProgress: 30, + previousProgress: 30, + now: initialTime + TRANSFER_PROGRESS_UPDATE_INTERVAL_MS * 10, + lastPublishedAt: initialTime, + }), false); +}); diff --git a/front/src/lib/transfer-runtime.ts b/front/src/lib/transfer-runtime.ts index acb7f12..c2bc273 100644 --- a/front/src/lib/transfer-runtime.ts +++ b/front/src/lib/transfer-runtime.ts @@ -1,19 +1,30 @@ -export const MAX_TRANSFER_BUFFERED_AMOUNT = 1024 * 1024; +export const SAFE_TRANSFER_CHUNK_SIZE = 64 * 1024; +export const MAX_TRANSFER_CHUNK_SIZE = 64 * 1024; +export const TRANSFER_PROGRESS_UPDATE_INTERVAL_MS = 120; -export async function waitForTransferChannelDrain( - channel: RTCDataChannel, - maxBufferedAmount = MAX_TRANSFER_BUFFERED_AMOUNT, -) { - if (channel.bufferedAmount <= maxBufferedAmount) { - return; +export function resolveTransferChunkSize(maxMessageSize?: number | null) { + if (!Number.isFinite(maxMessageSize) || !maxMessageSize || maxMessageSize <= 0) { + return SAFE_TRANSFER_CHUNK_SIZE; } - await new Promise((resolve) => { - const timer = window.setInterval(() => { - if (channel.readyState !== 'open' || channel.bufferedAmount <= maxBufferedAmount) { - window.clearInterval(timer); - resolve(); - } - }, 40); - }); + return Math.max(1024, Math.min(maxMessageSize, MAX_TRANSFER_CHUNK_SIZE)); +} + +export function shouldPublishTransferProgress(params: { + nextProgress: number; + previousProgress: number; + now: number; + lastPublishedAt: number; +}) { + const { nextProgress, previousProgress, now, lastPublishedAt } = params; + + if (nextProgress === previousProgress) { + return false; + } + + if (nextProgress >= 100 || nextProgress <= 0) { + return true; + } + + return now - lastPublishedAt >= TRANSFER_PROGRESS_UPDATE_INTERVAL_MS; } diff --git a/front/src/lib/transfer-signaling.test.ts b/front/src/lib/transfer-signaling.test.ts deleted file mode 100644 index 7354ab1..0000000 --- a/front/src/lib/transfer-signaling.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { - flushPendingRemoteIceCandidates, - handleRemoteIceCandidate, -} from './transfer-signaling'; - -test('handleRemoteIceCandidate defers candidates until the remote description exists', async () => { - const appliedCandidates: RTCIceCandidateInit[] = []; - const connection = { - remoteDescription: null, - addIceCandidate: async (candidate: RTCIceCandidateInit) => { - appliedCandidates.push(candidate); - }, - }; - const candidate: RTCIceCandidateInit = { - candidate: 'candidate:1 1 udp 2122260223 10.0.0.2 54321 typ host', - sdpMid: '0', - sdpMLineIndex: 0, - }; - - const pendingCandidates = await handleRemoteIceCandidate(connection, [], candidate); - - assert.deepEqual(appliedCandidates, []); - assert.deepEqual(pendingCandidates, [candidate]); -}); - -test('flushPendingRemoteIceCandidates applies queued candidates after the remote description is set', async () => { - const appliedCandidates: RTCIceCandidateInit[] = []; - const connection = { - remoteDescription: { type: 'answer' } as RTCSessionDescription, - addIceCandidate: async (candidate: RTCIceCandidateInit) => { - appliedCandidates.push(candidate); - }, - }; - const pendingCandidates: RTCIceCandidateInit[] = [ - { - candidate: 'candidate:1 1 udp 2122260223 10.0.0.2 54321 typ host', - sdpMid: '0', - sdpMLineIndex: 0, - }, - { - candidate: 'candidate:2 1 udp 2122260223 10.0.0.3 54322 typ host', - sdpMid: '0', - sdpMLineIndex: 0, - }, - ]; - - const remainingCandidates = await flushPendingRemoteIceCandidates(connection, pendingCandidates); - - assert.deepEqual(appliedCandidates, pendingCandidates); - assert.deepEqual(remainingCandidates, []); -}); diff --git a/front/src/lib/transfer-signaling.ts b/front/src/lib/transfer-signaling.ts deleted file mode 100644 index 2ee7221..0000000 --- a/front/src/lib/transfer-signaling.ts +++ /dev/null @@ -1,32 +0,0 @@ -interface RemoteIceCapableConnection { - remoteDescription: RTCSessionDescription | null; - addIceCandidate(candidate: RTCIceCandidateInit): Promise; -} - -export async function handleRemoteIceCandidate( - connection: RemoteIceCapableConnection, - pendingCandidates: RTCIceCandidateInit[], - candidate: RTCIceCandidateInit, -) { - if (!connection.remoteDescription) { - return [...pendingCandidates, candidate]; - } - - await connection.addIceCandidate(candidate); - return pendingCandidates; -} - -export async function flushPendingRemoteIceCandidates( - connection: RemoteIceCapableConnection, - pendingCandidates: RTCIceCandidateInit[], -) { - if (!connection.remoteDescription || pendingCandidates.length === 0) { - return pendingCandidates; - } - - for (const candidate of pendingCandidates) { - await connection.addIceCandidate(candidate); - } - - return []; -} diff --git a/front/src/lib/transfer.test.ts b/front/src/lib/transfer.test.ts index c54f301..e6e69b9 100644 --- a/front/src/lib/transfer.test.ts +++ b/front/src/lib/transfer.test.ts @@ -1,8 +1,17 @@ import assert from 'node:assert/strict'; -import test from 'node:test'; +import { afterEach, test } from 'node:test'; import { buildOfflineTransferDownloadUrl, toTransferFilePayload } from './transfer'; +const originalLocation = globalThis.location; + +afterEach(() => { + Object.defineProperty(globalThis, 'location', { + configurable: true, + value: originalLocation, + }); +}); + test('toTransferFilePayload keeps relative folder paths for transfer files', () => { const report = new File(['hello'], 'report.pdf', { type: 'application/pdf', @@ -28,3 +37,27 @@ test('buildOfflineTransferDownloadUrl points to the public offline download endp '/api/transfer/sessions/session-1/files/file-1/download', ); }); + +test('buildOfflineTransferDownloadUrl uses the production api origin inside the Capacitor localhost shell', () => { + Object.defineProperty(globalThis, 'location', { + configurable: true, + value: new URL('http://localhost'), + }); + + assert.equal( + buildOfflineTransferDownloadUrl('session-1', 'file-1'), + 'https://api.yoyuzh.xyz/api/transfer/sessions/session-1/files/file-1/download', + ); +}); + +test('buildOfflineTransferDownloadUrl uses the production api origin inside the Capacitor https localhost shell', () => { + Object.defineProperty(globalThis, 'location', { + configurable: true, + value: new URL('https://localhost'), + }); + + assert.equal( + buildOfflineTransferDownloadUrl('session-1', 'file-1'), + 'https://api.yoyuzh.xyz/api/transfer/sessions/session-1/files/file-1/download', + ); +}); diff --git a/front/src/lib/transfer.ts b/front/src/lib/transfer.ts index 311b302..2e7767a 100644 --- a/front/src/lib/transfer.ts +++ b/front/src/lib/transfer.ts @@ -1,6 +1,6 @@ import type { FileMetadata, TransferMode } from './types'; -import { apiRequest } from './api'; -import { apiUploadRequest } from './api'; +import { apiRequest, apiUploadRequest, getApiBaseUrl } from './api'; +import { hasRelayTransferIceServer, resolveTransferIceServers } from './transfer-ice'; import { getTransferFileRelativePath } from './transfer-protocol'; import type { LookupTransferSessionResponse, @@ -8,10 +8,8 @@ import type { TransferSessionResponse, } from './types'; -export const DEFAULT_TRANSFER_ICE_SERVERS: RTCIceServer[] = [ - {urls: 'stun:stun.cloudflare.com:3478'}, - {urls: 'stun:stun.l.google.com:19302'}, -]; +export const DEFAULT_TRANSFER_ICE_SERVERS = resolveTransferIceServers(); +export const TRANSFER_HAS_RELAY_SUPPORT = hasRelayTransferIceServer(DEFAULT_TRANSFER_ICE_SERVERS); export function toTransferFilePayload(files: File[]) { return files.map((file) => ({ @@ -64,8 +62,7 @@ export function uploadOfflineTransferFile( } export function buildOfflineTransferDownloadUrl(sessionId: string, fileId: string) { - const apiBaseUrl = (import.meta.env?.VITE_API_BASE_URL || '/api').replace(/\/$/, ''); - return `${apiBaseUrl}/transfer/sessions/${encodeURIComponent(sessionId)}/files/${encodeURIComponent(fileId)}/download`; + return `${getApiBaseUrl()}/transfer/sessions/${encodeURIComponent(sessionId)}/files/${encodeURIComponent(fileId)}/download`; } export function importOfflineTransferFile(sessionId: string, fileId: string, path: string) { diff --git a/front/src/lib/types.ts b/front/src/lib/types.ts index 26b444b..a93c1cc 100644 --- a/front/src/lib/types.ts +++ b/front/src/lib/types.ts @@ -21,6 +21,13 @@ export interface AdminRequestTimelinePoint { requestCount: number; } +export interface AdminDailyActiveUserSummary { + metricDate: string; + label: string; + userCount: number; + usernames: string[]; +} + export interface AdminSummary { totalUsers: number; totalFiles: number; @@ -30,6 +37,7 @@ export interface AdminSummary { transferUsageBytes: number; offlineTransferStorageBytes: number; offlineTransferStorageLimitBytes: number; + dailyActiveUsers: AdminDailyActiveUserSummary[]; requestTimeline: AdminRequestTimelinePoint[]; inviteCode: string; } diff --git a/front/src/mobile-components/MobileLayout.test.ts b/front/src/mobile-components/MobileLayout.test.ts index 8704b8b..246b5a8 100644 --- a/front/src/mobile-components/MobileLayout.test.ts +++ b/front/src/mobile-components/MobileLayout.test.ts @@ -1,7 +1,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { getVisibleNavItems } from './MobileLayout'; +import { + getMobileViewportOffsetClassNames, + getVisibleNavItems, + isNativeMobileShellLocation, +} from './MobileLayout'; test('mobile navigation hides the games entry', () => { const visiblePaths = getVisibleNavItems(false).map((item) => item.path as string); @@ -9,3 +13,23 @@ test('mobile navigation hides the games entry', () => { assert.equal(visiblePaths.includes('/games'), false); assert.deepEqual(visiblePaths, ['/overview', '/files', '/transfer']); }); + +test('mobile layout reserves top safe-area space for the fixed app bar', () => { + const offsets = getMobileViewportOffsetClassNames(); + + assert.match(offsets.header, /\bsafe-area-pt\b/); + assert.match(offsets.main, /var\(--app-safe-area-top\)/); +}); + +test('mobile layout adds extra top spacing inside the native shell', () => { + const offsets = getMobileViewportOffsetClassNames(true); + + assert.match(offsets.header, /\bpt-6\b/); + assert.match(offsets.main, /1\.5rem/); +}); + +test('native mobile shell detection matches Capacitor localhost origins', () => { + assert.equal(isNativeMobileShellLocation(new URL('https://localhost')), true); + assert.equal(isNativeMobileShellLocation(new URL('http://127.0.0.1')), true); + assert.equal(isNativeMobileShellLocation(new URL('https://yoyuzh.xyz')), false); +}); diff --git a/front/src/mobile-components/MobileLayout.tsx b/front/src/mobile-components/MobileLayout.tsx index fc97ed2..f0d9b43 100644 --- a/front/src/mobile-components/MobileLayout.tsx +++ b/front/src/mobile-components/MobileLayout.tsx @@ -32,6 +32,33 @@ const NAV_ITEMS = [ { name: '快传', path: '/transfer', icon: Send }, ] as const; +export function isNativeMobileShellLocation(location: Location | URL | null) { + if (!location) { + return false; + } + + const hostname = location.hostname || ''; + const protocol = location.protocol || ''; + const isLocalhostHost = hostname === 'localhost' || hostname === '127.0.0.1'; + const isCapacitorScheme = protocol === 'http:' || protocol === 'https:' || protocol === 'capacitor:'; + + return isLocalhostHost && isCapacitorScheme; +} + +export function getMobileViewportOffsetClassNames(isNativeShell = false) { + if (isNativeShell) { + return { + header: 'safe-area-pt pt-6', + main: 'pt-[calc(3.5rem+1.5rem+var(--app-safe-area-top))]', + }; + } + + return { + header: 'safe-area-pt', + main: 'pt-[calc(3.5rem+var(--app-safe-area-top))]', + }; +} + type ActiveModal = 'security' | 'settings' | null; export function getVisibleNavItems(isAdmin: boolean) { @@ -47,6 +74,9 @@ export function MobileLayout({ children }: LayoutProps = {}) { const navigate = useNavigate(); const { isAdmin, logout, refreshProfile, user } = useAuth(); const navItems = getVisibleNavItems(isAdmin); + const viewportOffsets = getMobileViewportOffsetClassNames( + typeof window !== 'undefined' && isNativeMobileShellLocation(window.location), + ); const fileInputRef = useRef(null); const [isDropdownOpen, setIsDropdownOpen] = useState(false); @@ -234,7 +264,7 @@ export function MobileLayout({ children }: LayoutProps = {}) { {/* Top App Bar */} -
+
@@ -306,7 +336,7 @@ export function MobileLayout({ children }: LayoutProps = {}) { {/* Main Content Area */} -
+
{children ?? }
diff --git a/front/src/mobile-pages/MobileTransfer.tsx b/front/src/mobile-pages/MobileTransfer.tsx index ce2aa5f..cc7fdf8 100644 --- a/front/src/mobile-pages/MobileTransfer.tsx +++ b/front/src/mobile-pages/MobileTransfer.tsx @@ -25,6 +25,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom'; import { useAuth } from '@/src/auth/AuthProvider'; import { Button } from '@/src/components/ui/button'; +import { appendTransferRelayHint } from '@/src/lib/transfer-ice'; import { buildTransferShareUrl, getTransferRouterMode } from '@/src/lib/transfer-links'; import { createTransferFileManifest, @@ -35,12 +36,15 @@ import { createTransferFileMetaMessage, type TransferFileDescriptor, SIGNAL_POLL_INTERVAL_MS, - TRANSFER_CHUNK_SIZE, } from '@/src/lib/transfer-protocol'; -import { waitForTransferChannelDrain } from '@/src/lib/transfer-runtime'; -import { flushPendingRemoteIceCandidates, handleRemoteIceCandidate } from '@/src/lib/transfer-signaling'; +import { + shouldPublishTransferProgress, + resolveTransferChunkSize, +} from '@/src/lib/transfer-runtime'; +import { createTransferPeer, type TransferPeerAdapter } from '@/src/lib/transfer-peer'; import { DEFAULT_TRANSFER_ICE_SERVERS, + TRANSFER_HAS_RELAY_SUPPORT, createTransferSession, listMyOfflineTransferSessions, pollTransferSignals, @@ -92,7 +96,7 @@ function getPhaseMessage(mode: TransferMode, phase: SendPhase, errorMessage: str export default function MobileTransfer() { const navigate = useNavigate(); - const { session: authSession } = useAuth(); + const { ready: authReady, session: authSession } = useAuth(); const [searchParams] = useSearchParams(); const sessionId = searchParams.get('session'); const isAuthenticated = Boolean(authSession?.token); @@ -118,14 +122,14 @@ export default function MobileTransfer() { const copiedTimerRef = useRef(null); const historyCopiedTimerRef = useRef(null); const pollTimerRef = useRef(null); - const peerConnectionRef = useRef(null); - const dataChannelRef = useRef(null); + const peerRef = useRef(null); const cursorRef = useRef(0); const bootstrapIdRef = useRef(0); const totalBytesRef = useRef(0); const sentBytesRef = useRef(0); + const lastSendProgressPublishAtRef = useRef(0); + const lastPublishedSendProgressRef = useRef(0); const sendingStartedRef = useRef(false); - const pendingRemoteCandidatesRef = useRef([]); const manifestRef = useRef([]); useEffect(() => { @@ -193,14 +197,30 @@ export default function MobileTransfer() { function cleanupCurrentTransfer() { if (pollTimerRef.current) { window.clearInterval(pollTimerRef.current); pollTimerRef.current = null; } - if (dataChannelRef.current) { dataChannelRef.current.close(); dataChannelRef.current = null; } - if (peerConnectionRef.current) { peerConnectionRef.current.close(); peerConnectionRef.current = null; } - cursorRef.current = 0; sendingStartedRef.current = false; pendingRemoteCandidatesRef.current = []; + const peer = peerRef.current; + peerRef.current = null; + peer?.destroy(); + cursorRef.current = 0; lastSendProgressPublishAtRef.current = 0; lastPublishedSendProgressRef.current = 0; sendingStartedRef.current = false; + } + + function publishSendProgress(nextProgress: number, options?: {force?: boolean}) { + const normalizedProgress = Math.max(0, Math.min(100, nextProgress)); + const now = globalThis.performance?.now?.() ?? Date.now(); + if (!options?.force && !shouldPublishTransferProgress({ + nextProgress: normalizedProgress, + previousProgress: lastPublishedSendProgressRef.current, + now, + lastPublishedAt: lastSendProgressPublishAtRef.current, + })) return; + + lastSendProgressPublishAtRef.current = now; + lastPublishedSendProgressRef.current = normalizedProgress; + setSendProgress(normalizedProgress); } function resetSenderState() { cleanupCurrentTransfer(); - setSession(null); setSelectedFiles([]); setSendPhase('idle'); setSendProgress(0); setSendError(''); + setSession(null); setSelectedFiles([]); setSendPhase('idle'); publishSendProgress(0, {force: true}); setSendError(''); } async function copyToClipboard(text: string) { @@ -236,7 +256,7 @@ export default function MobileTransfer() { bootstrapIdRef.current = bootstrapId; cleanupCurrentTransfer(); - setSendError(''); setSendPhase('creating'); setSendProgress(0); + setSendError(''); setSendPhase('creating'); publishSendProgress(0, {force: true}); manifestRef.current = createTransferFileManifest(files); totalBytesRef.current = 0; sentBytesRef.current = 0; @@ -261,7 +281,7 @@ export default function MobileTransfer() { async function uploadOfflineFiles(createdSession: TransferSessionResponse, files: File[], bootstrapId: number) { setSendPhase('uploading'); - totalBytesRef.current = files.reduce((sum, f) => sum + f.size, 0); sentBytesRef.current = 0; setSendProgress(0); + totalBytesRef.current = files.reduce((sum, f) => sum + f.size, 0); sentBytesRef.current = 0; publishSendProgress(0, {force: true}); for (const [idx, file] of files.entries()) { if (bootstrapIdRef.current !== bootstrapId) return; const sessionFile = createdSession.files[idx]; @@ -271,55 +291,61 @@ export default function MobileTransfer() { await uploadOfflineTransferFile(createdSession.sessionId, sessionFile.id, file, ({ loaded, total }) => { sentBytesRef.current += (loaded - lastLoaded); lastLoaded = loaded; if (loaded >= total) sentBytesRef.current = Math.min(totalBytesRef.current, sentBytesRef.current); - if (totalBytesRef.current > 0) setSendProgress(Math.min(99, Math.round((sentBytesRef.current / totalBytesRef.current) * 100))); + if (totalBytesRef.current > 0) publishSendProgress(Math.min(99, Math.round((sentBytesRef.current / totalBytesRef.current) * 100))); }); } - setSendProgress(100); setSendPhase('completed'); + publishSendProgress(100, {force: true}); setSendPhase('completed'); void loadOfflineHistory({silent: true}); } async function setupSenderPeer(createdSession: TransferSessionResponse, files: File[], bootstrapId: number) { - const conn = new RTCPeerConnection({ iceServers: DEFAULT_TRANSFER_ICE_SERVERS }); - const channel = conn.createDataChannel('portal-transfer', { ordered: true }); - peerConnectionRef.current = conn; dataChannelRef.current = channel; channel.binaryType = 'arraybuffer'; + const peer = createTransferPeer({ + initiator: true, + peerOptions: { + config: { + iceServers: DEFAULT_TRANSFER_ICE_SERVERS, + }, + }, + onSignal: (payload) => { + void postTransferSignal(createdSession.sessionId, 'sender', 'signal', payload); + }, + onConnect: () => { + if (bootstrapIdRef.current !== bootstrapId) return; + setSendPhase(cur => (cur === 'transferring' || cur === 'completed' ? cur : 'connecting')); + peer.send(createTransferFileManifestMessage(manifestRef.current)); + }, + onData: (payload) => { + if (typeof payload !== 'string') return; + const msg = parseJsonPayload<{type?: string; fileIds?: string[]}>(payload); + if (!msg || msg.type !== 'receive-request' || !Array.isArray(msg.fileIds) || sendingStartedRef.current) return; - conn.onicecandidate = (e) => { - if (e.candidate) void postTransferSignal(createdSession.sessionId, 'sender', 'ice-candidate', JSON.stringify(e.candidate.toJSON())); - }; + const requestedFiles = manifestRef.current.filter((item) => msg.fileIds?.includes(item.id)); + if (requestedFiles.length === 0) return; - conn.onconnectionstatechange = () => { - if (conn.connectionState === 'connected') setSendPhase(cur => (cur === 'transferring' || cur === 'completed' ? cur : 'connecting')); - if (conn.connectionState === 'failed' || conn.connectionState === 'disconnected') { setSendPhase('error'); setSendError('浏览器直连失败'); } - }; - - channel.onopen = () => channel.send(createTransferFileManifestMessage(manifestRef.current)); - channel.onmessage = (e) => { - if (typeof e.data !== 'string') return; - const msg = parseJsonPayload<{type?: string; fileIds?: string[];}>(e.data); - if (!msg || msg.type !== 'receive-request' || !Array.isArray(msg.fileIds) || sendingStartedRef.current) return; - - const requestedFiles = manifestRef.current.filter((item) => msg.fileIds?.includes(item.id)); - if (requestedFiles.length === 0) return; - - sendingStartedRef.current = true; - totalBytesRef.current = requestedFiles.reduce((sum, f) => sum + f.size, 0); sentBytesRef.current = 0; setSendProgress(0); - void sendSelectedFiles(channel, files, requestedFiles, bootstrapId); - }; - channel.onerror = () => { setSendPhase('error'); setSendError('数据通道建立失败'); }; - startSenderPolling(createdSession.sessionId, conn, bootstrapId); - - const offer = await conn.createOffer(); - await conn.setLocalDescription(offer); - await postTransferSignal(createdSession.sessionId, 'sender', 'offer', JSON.stringify(offer)); + sendingStartedRef.current = true; + totalBytesRef.current = requestedFiles.reduce((sum, f) => sum + f.size, 0); sentBytesRef.current = 0; publishSendProgress(0, {force: true}); + void sendSelectedFiles(peer, files, requestedFiles, bootstrapId); + }, + onError: (error) => { + if (bootstrapIdRef.current !== bootstrapId) return; + setSendPhase('error'); + setSendError(appendTransferRelayHint( + error.message || '数据通道建立失败', + TRANSFER_HAS_RELAY_SUPPORT, + )); + }, + }); + peerRef.current = peer; + startSenderPolling(createdSession.sessionId, bootstrapId); } - function startSenderPolling(sessionId: string, conn: RTCPeerConnection, bootstrapId: number) { + function startSenderPolling(sessionId: string, bootstrapId: number) { let polling = false; pollTimerRef.current = window.setInterval(() => { if (polling || bootstrapIdRef.current !== bootstrapId) return; polling = true; void pollTransferSignals(sessionId, 'sender', cursorRef.current) - .then(async (res) => { + .then((res) => { if (bootstrapIdRef.current !== bootstrapId) return; cursorRef.current = res.nextCursor; for (const item of res.items) { @@ -327,17 +353,8 @@ export default function MobileTransfer() { setSendPhase(cur => (cur === 'waiting' ? 'connecting' : cur)); continue; } - if (item.type === 'answer' && !conn.currentRemoteDescription) { - const answer = parseJsonPayload(item.payload); - if (answer) { - await conn.setRemoteDescription(answer); - pendingRemoteCandidatesRef.current = await flushPendingRemoteIceCandidates(conn, pendingRemoteCandidatesRef.current); - } - continue; - } - if (item.type === 'ice-candidate') { - const cand = parseJsonPayload(item.payload); - if (cand) pendingRemoteCandidatesRef.current = await handleRemoteIceCandidate(conn, pendingRemoteCandidatesRef.current, cand); + if (item.type === 'signal') { + peerRef.current?.applyRemoteSignal(item.payload); } } }) @@ -349,28 +366,33 @@ export default function MobileTransfer() { }, SIGNAL_POLL_INTERVAL_MS); } - async function sendSelectedFiles(channel: RTCDataChannel, files: File[], requestedFiles: TransferFileDescriptor[], bootstrapId: number) { + async function sendSelectedFiles( + peer: TransferPeerAdapter, + files: File[], + requestedFiles: TransferFileDescriptor[], + bootstrapId: number, + ) { setSendPhase('transferring'); const filesById = new Map(files.map((f) => [createTransferFileId(f), f])); + const chunkSize = resolveTransferChunkSize(); for (const desc of requestedFiles) { - if (bootstrapIdRef.current !== bootstrapId || channel.readyState !== 'open') return; + if (bootstrapIdRef.current !== bootstrapId || !peer.connected) return; const file = filesById.get(desc.id); if (!file) continue; - channel.send(createTransferFileMetaMessage(desc)); - for (let offset = 0; offset < file.size; offset += TRANSFER_CHUNK_SIZE) { - if (bootstrapIdRef.current !== bootstrapId || channel.readyState !== 'open') return; - const chunk = await file.slice(offset, offset + TRANSFER_CHUNK_SIZE).arrayBuffer(); - await waitForTransferChannelDrain(channel); - channel.send(chunk); + peer.send(createTransferFileMetaMessage(desc)); + for (let offset = 0; offset < file.size; offset += chunkSize) { + if (bootstrapIdRef.current !== bootstrapId || !peer.connected) return; + const chunk = await file.slice(offset, offset + chunkSize).arrayBuffer(); + await peer.write(chunk); sentBytesRef.current += chunk.byteLength; - if (totalBytesRef.current > 0) setSendProgress(Math.min(99, Math.round((sentBytesRef.current / totalBytesRef.current) * 100))); + if (totalBytesRef.current > 0) publishSendProgress(Math.min(99, Math.round((sentBytesRef.current / totalBytesRef.current) * 100))); } - channel.send(createTransferFileCompleteMessage(desc.id)); + peer.send(createTransferFileCompleteMessage(desc.id)); } - channel.send(createTransferCompleteMessage()); - setSendProgress(100); setSendPhase('completed'); + peer.send(createTransferCompleteMessage()); + publishSendProgress(100, {force: true}); setSendPhase('completed'); } async function copyOfflineSessionLink(s: TransferSessionResponse) { @@ -423,9 +445,9 @@ export default function MobileTransfer() { )}
- {!isAuthenticated && ( + {authReady && !isAuthenticated && (
-

无需登录仅支持在线模式。离线模式可保留文件7天,需登录后可用。

+

无需登录即可在线发送、在线接收和离线接收。只有发离线和把离线文件存入网盘时才需要登录。

diff --git a/front/src/pages/Files.tsx b/front/src/pages/Files.tsx index 6358c2d..e516ba2 100644 --- a/front/src/pages/Files.tsx +++ b/front/src/pages/Files.tsx @@ -68,6 +68,7 @@ import { buildDirectoryTree, createExpandedDirectorySet, getMissingDirectoryListingPaths, + hasLoadedDirectoryListing, mergeDirectoryChildren, toDirectoryPath, type DirectoryChildrenMap, @@ -349,7 +350,7 @@ export default function Files() { } next.add(path); - shouldLoadChildren = !(path in directoryChildren); + shouldLoadChildren = !hasLoadedDirectoryListing(pathParts, loadedDirectoryPaths); return next; }); diff --git a/front/src/pages/Transfer.tsx b/front/src/pages/Transfer.tsx index 4984ccb..6f55806 100644 --- a/front/src/pages/Transfer.tsx +++ b/front/src/pages/Transfer.tsx @@ -25,6 +25,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom'; import { useAuth } from '@/src/auth/AuthProvider'; import { Button } from '@/src/components/ui/button'; +import { appendTransferRelayHint } from '@/src/lib/transfer-ice'; import { buildTransferShareUrl, getTransferRouterMode } from '@/src/lib/transfer-links'; import { createTransferFileManifest, @@ -35,12 +36,15 @@ import { createTransferFileMetaMessage, type TransferFileDescriptor, SIGNAL_POLL_INTERVAL_MS, - TRANSFER_CHUNK_SIZE, } from '@/src/lib/transfer-protocol'; -import { waitForTransferChannelDrain } from '@/src/lib/transfer-runtime'; -import { flushPendingRemoteIceCandidates, handleRemoteIceCandidate } from '@/src/lib/transfer-signaling'; +import { + shouldPublishTransferProgress, + resolveTransferChunkSize, +} from '@/src/lib/transfer-runtime'; +import { createTransferPeer, type TransferPeerAdapter } from '@/src/lib/transfer-peer'; import { DEFAULT_TRANSFER_ICE_SERVERS, + TRANSFER_HAS_RELAY_SUPPORT, createTransferSession, listMyOfflineTransferSessions, pollTransferSignals, @@ -108,7 +112,7 @@ function getPhaseMessage(mode: TransferMode, phase: SendPhase, errorMessage: str export default function Transfer() { const navigate = useNavigate(); - const { session: authSession } = useAuth(); + const { ready: authReady, session: authSession } = useAuth(); const [searchParams] = useSearchParams(); const sessionId = searchParams.get('session'); const isAuthenticated = Boolean(authSession?.token); @@ -134,14 +138,14 @@ export default function Transfer() { const copiedTimerRef = useRef(null); const historyCopiedTimerRef = useRef(null); const pollTimerRef = useRef(null); - const peerConnectionRef = useRef(null); - const dataChannelRef = useRef(null); + const peerRef = useRef(null); const cursorRef = useRef(0); const bootstrapIdRef = useRef(0); const totalBytesRef = useRef(0); const sentBytesRef = useRef(0); + const lastSendProgressPublishAtRef = useRef(0); + const lastPublishedSendProgressRef = useRef(0); const sendingStartedRef = useRef(false); - const pendingRemoteCandidatesRef = useRef([]); const manifestRef = useRef([]); useEffect(() => { @@ -252,19 +256,31 @@ export default function Transfer() { pollTimerRef.current = null; } - if (dataChannelRef.current) { - dataChannelRef.current.close(); - dataChannelRef.current = null; - } - - if (peerConnectionRef.current) { - peerConnectionRef.current.close(); - peerConnectionRef.current = null; - } + const peer = peerRef.current; + peerRef.current = null; + peer?.destroy(); cursorRef.current = 0; + lastSendProgressPublishAtRef.current = 0; + lastPublishedSendProgressRef.current = 0; sendingStartedRef.current = false; - pendingRemoteCandidatesRef.current = []; + } + + function publishSendProgress(nextProgress: number, options?: {force?: boolean}) { + const normalizedProgress = Math.max(0, Math.min(100, nextProgress)); + const now = globalThis.performance?.now?.() ?? Date.now(); + if (!options?.force && !shouldPublishTransferProgress({ + nextProgress: normalizedProgress, + previousProgress: lastPublishedSendProgressRef.current, + now, + lastPublishedAt: lastSendProgressPublishAtRef.current, + })) { + return; + } + + lastSendProgressPublishAtRef.current = now; + lastPublishedSendProgressRef.current = normalizedProgress; + setSendProgress(normalizedProgress); } function resetSenderState() { @@ -272,7 +288,7 @@ export default function Transfer() { setSession(null); setSelectedFiles([]); setSendPhase('idle'); - setSendProgress(0); + publishSendProgress(0, {force: true}); setSendError(''); } @@ -334,7 +350,7 @@ export default function Transfer() { cleanupCurrentTransfer(); setSendError(''); setSendPhase('creating'); - setSendProgress(0); + publishSendProgress(0, {force: true}); manifestRef.current = createTransferFileManifest(files); totalBytesRef.current = 0; sentBytesRef.current = 0; @@ -367,7 +383,7 @@ export default function Transfer() { setSendPhase('uploading'); totalBytesRef.current = files.reduce((sum, file) => sum + file.size, 0); sentBytesRef.current = 0; - setSendProgress(0); + publishSendProgress(0, {force: true}); for (const [index, file] of files.entries()) { if (bootstrapIdRef.current !== bootstrapId) { @@ -390,95 +406,71 @@ export default function Transfer() { } if (totalBytesRef.current > 0) { - setSendProgress(Math.min(99, Math.round((sentBytesRef.current / totalBytesRef.current) * 100))); + publishSendProgress(Math.min(99, Math.round((sentBytesRef.current / totalBytesRef.current) * 100))); } }); } - setSendProgress(100); + publishSendProgress(100, {force: true}); setSendPhase('completed'); void loadOfflineHistory({silent: true}); } async function setupSenderPeer(createdSession: TransferSessionResponse, files: File[], bootstrapId: number) { - const connection = new RTCPeerConnection({ - iceServers: DEFAULT_TRANSFER_ICE_SERVERS, - }); - const channel = connection.createDataChannel('portal-transfer', { - ordered: true, - }); - - peerConnectionRef.current = connection; - dataChannelRef.current = channel; - channel.binaryType = 'arraybuffer'; - - connection.onicecandidate = (event) => { - if (!event.candidate) { - return; - } - - void postTransferSignal( - createdSession.sessionId, - 'sender', - 'ice-candidate', - JSON.stringify(event.candidate.toJSON()), - ); - }; - - connection.onconnectionstatechange = () => { - if (connection.connectionState === 'connected') { + const peer = createTransferPeer({ + initiator: true, + peerOptions: { + config: { + iceServers: DEFAULT_TRANSFER_ICE_SERVERS, + }, + }, + onSignal: (payload) => { + void postTransferSignal(createdSession.sessionId, 'sender', 'signal', payload); + }, + onConnect: () => { + if (bootstrapIdRef.current !== bootstrapId) { + return; + } setSendPhase((current) => (current === 'transferring' || current === 'completed' ? current : 'connecting')); - } + peer.send(createTransferFileManifestMessage(manifestRef.current)); + }, + onData: (payload) => { + if (typeof payload !== 'string') { + return; + } - if (connection.connectionState === 'failed' || connection.connectionState === 'disconnected') { + const message = parseJsonPayload<{type?: string; fileIds?: string[]}>(payload); + if (!message || message.type !== 'receive-request' || !Array.isArray(message.fileIds) || sendingStartedRef.current) { + return; + } + + const requestedFiles = manifestRef.current.filter((item) => message.fileIds?.includes(item.id)); + if (requestedFiles.length === 0) { + return; + } + + sendingStartedRef.current = true; + totalBytesRef.current = requestedFiles.reduce((sum, file) => sum + file.size, 0); + sentBytesRef.current = 0; + publishSendProgress(0, {force: true}); + void sendSelectedFiles(peer, files, requestedFiles, bootstrapId); + }, + onError: (error) => { + if (bootstrapIdRef.current !== bootstrapId) { + return; + } setSendPhase('error'); - setSendError('浏览器直连失败,请重新生成分享链接再试一次。'); - } - }; - - channel.onopen = () => { - channel.send(createTransferFileManifestMessage(manifestRef.current)); - }; - - channel.onmessage = (event) => { - if (typeof event.data !== 'string') { - return; - } - - const message = parseJsonPayload<{type?: string; fileIds?: string[];}>(event.data); - if (!message || message.type !== 'receive-request' || !Array.isArray(message.fileIds)) { - return; - } - - if (sendingStartedRef.current) { - return; - } - - const requestedFiles = manifestRef.current.filter((item) => message.fileIds?.includes(item.id)); - if (requestedFiles.length === 0) { - return; - } - - sendingStartedRef.current = true; - totalBytesRef.current = requestedFiles.reduce((sum, file) => sum + file.size, 0); - sentBytesRef.current = 0; - setSendProgress(0); - void sendSelectedFiles(channel, files, requestedFiles, bootstrapId); - }; - - channel.onerror = () => { - setSendPhase('error'); - setSendError('数据通道建立失败,请重新开始本次快传。'); - }; - - startSenderPolling(createdSession.sessionId, connection, bootstrapId); - - const offer = await connection.createOffer(); - await connection.setLocalDescription(offer); - await postTransferSignal(createdSession.sessionId, 'sender', 'offer', JSON.stringify(offer)); + setSendError(appendTransferRelayHint( + error.message || '数据通道建立失败,请重新开始本次快传。', + TRANSFER_HAS_RELAY_SUPPORT, + )); + }, + }); + peerRef.current = peer; + startSenderPolling(createdSession.sessionId, bootstrapId); } - function startSenderPolling(sessionId: string, connection: RTCPeerConnection, bootstrapId: number) { + function startSenderPolling(sessionId: string, bootstrapId: number) { let polling = false; pollTimerRef.current = window.setInterval(() => { @@ -502,27 +494,8 @@ export default function Transfer() { continue; } - if (item.type === 'answer' && !connection.currentRemoteDescription) { - const answer = parseJsonPayload(item.payload); - if (answer) { - await connection.setRemoteDescription(answer); - pendingRemoteCandidatesRef.current = await flushPendingRemoteIceCandidates( - connection, - pendingRemoteCandidatesRef.current, - ); - } - continue; - } - - if (item.type === 'ice-candidate') { - const candidate = parseJsonPayload(item.payload); - if (candidate) { - pendingRemoteCandidatesRef.current = await handleRemoteIceCandidate( - connection, - pendingRemoteCandidatesRef.current, - candidate, - ); - } + if (item.type === 'signal') { + peerRef.current?.applyRemoteSignal(item.payload); } } }) @@ -540,16 +513,17 @@ export default function Transfer() { } async function sendSelectedFiles( - channel: RTCDataChannel, + peer: TransferPeerAdapter, files: File[], requestedFiles: TransferFileDescriptor[], bootstrapId: number, ) { setSendPhase('transferring'); const filesById = new Map(files.map((file) => [createTransferFileId(file), file])); + const chunkSize = resolveTransferChunkSize(); for (const descriptor of requestedFiles) { - if (bootstrapIdRef.current !== bootstrapId || channel.readyState !== 'open') { + if (bootstrapIdRef.current !== bootstrapId || !peer.connected) { return; } @@ -558,31 +532,30 @@ export default function Transfer() { continue; } - channel.send(createTransferFileMetaMessage(descriptor)); + peer.send(createTransferFileMetaMessage(descriptor)); - for (let offset = 0; offset < file.size; offset += TRANSFER_CHUNK_SIZE) { - if (bootstrapIdRef.current !== bootstrapId || channel.readyState !== 'open') { + for (let offset = 0; offset < file.size; offset += chunkSize) { + if (bootstrapIdRef.current !== bootstrapId || !peer.connected) { return; } - const chunk = await file.slice(offset, offset + TRANSFER_CHUNK_SIZE).arrayBuffer(); - await waitForTransferChannelDrain(channel); - channel.send(chunk); + const chunk = await file.slice(offset, offset + chunkSize).arrayBuffer(); + await peer.write(chunk); sentBytesRef.current += chunk.byteLength; if (totalBytesRef.current > 0) { - setSendProgress(Math.min( + publishSendProgress(Math.min( 99, Math.round((sentBytesRef.current / totalBytesRef.current) * 100), )); } } - channel.send(createTransferFileCompleteMessage(descriptor.id)); + peer.send(createTransferFileCompleteMessage(descriptor.id)); } - channel.send(createTransferCompleteMessage()); - setSendProgress(100); + peer.send(createTransferCompleteMessage()); + publishSendProgress(100, {force: true}); setSendPhase('completed'); } @@ -650,10 +623,10 @@ export default function Transfer() { ) : null}
- {!isAuthenticated ? ( + {authReady && !isAuthenticated ? (

- 当前无需登录即可使用快传,但仅支持在线发送和在线接收。离线快传仍需登录后使用。 + 当前无需登录即可在线发送、在线接收和离线接收。只有发离线和把离线文件存入网盘时才需要登录。

- +
+ + +
@@ -774,7 +819,7 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro