From a0b84f4285de073a4c22ba4cff39e2dac7f454ac Mon Sep 17 00:00:00 2001 From: Bennett Date: Thu, 10 Sep 2026 13:31:29 +0300 Subject: [PATCH 1/2] release: Remove app uploads --- .../flextuma/core/dtos/AppUploadRequest.java | 16 -- .../flextuma/core/dtos/AppUploadResponse.java | 17 -- .../whatsapp/WhatsAppInboxMessage.java | 8 +- .../WhatsAppInboxMessageRepository.java | 29 ++++ .../app/controllers/AppController.java | 40 ----- .../app/services/AppUploadService.java | 155 ------------------ .../WhatsAppInboxMessageController.java | 7 + .../WhatsAppWebhookController.java | 5 +- .../dtos/WhatsAppTenantStorageUsageDTO.java | 15 ++ .../services/WhatsAppInboxMessageService.java | 27 +++ .../services/WhatsAppMediaService.java | 59 +++++-- src/main/resources/application.properties | 12 +- .../WhatsAppWebhookControllerTest.java | 4 +- .../WhatsAppInboxMessageServiceTest.java | 94 +++++++++++ .../services/WhatsAppMediaServiceTest.java | 51 +++++- 15 files changed, 284 insertions(+), 255 deletions(-) delete mode 100644 src/main/java/com/flexcodelabs/flextuma/core/dtos/AppUploadRequest.java delete mode 100644 src/main/java/com/flexcodelabs/flextuma/core/dtos/AppUploadResponse.java delete mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/app/controllers/AppController.java delete mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/app/services/AppUploadService.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/dtos/WhatsAppTenantStorageUsageDTO.java diff --git a/src/main/java/com/flexcodelabs/flextuma/core/dtos/AppUploadRequest.java b/src/main/java/com/flexcodelabs/flextuma/core/dtos/AppUploadRequest.java deleted file mode 100644 index 6d088fe..0000000 --- a/src/main/java/com/flexcodelabs/flextuma/core/dtos/AppUploadRequest.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.flexcodelabs.flextuma.core.dtos; - -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.springframework.web.multipart.MultipartFile; - -@Data -@NoArgsConstructor -@AllArgsConstructor -public class AppUploadRequest { - private MultipartFile zipFile; - private String appName; - private String version; - private boolean overwrite = true; -} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/dtos/AppUploadResponse.java b/src/main/java/com/flexcodelabs/flextuma/core/dtos/AppUploadResponse.java deleted file mode 100644 index 3068af0..0000000 --- a/src/main/java/com/flexcodelabs/flextuma/core/dtos/AppUploadResponse.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.flexcodelabs.flextuma.core.dtos; - -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@NoArgsConstructor -@AllArgsConstructor -public class AppUploadResponse { - private String message; - private String appName; - private String version; - private String extractedPath; - private long fileSize; - private int extractedFiles; -} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/whatsapp/WhatsAppInboxMessage.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/whatsapp/WhatsAppInboxMessage.java index 028ec28..1d4f69e 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/entities/whatsapp/WhatsAppInboxMessage.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/whatsapp/WhatsAppInboxMessage.java @@ -57,10 +57,16 @@ public class WhatsAppInboxMessage extends Owner { @Column(columnDefinition = "TEXT") private String caption; - /** Filename under the configured media directory once the bytes are downloaded from Meta. Null if never downloaded or the download failed. */ + /** Tenant-scoped path ("tenantFolder/filename") under the configured media directory once + * the bytes are downloaded from Meta. Null if never downloaded or the download failed. */ @Column(name = "media_path") private String mediaPath; + /** Size in bytes of the downloaded media file. Null if never downloaded. Used to compute + * per-tenant WhatsApp media storage usage. */ + @Column(name = "media_size") + private Long mediaSize; + @Column(name = "received_at", nullable = false) private LocalDateTime receivedAt; diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppInboxMessageRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppInboxMessageRepository.java index 702a334..610ca02 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppInboxMessageRepository.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppInboxMessageRepository.java @@ -1,12 +1,41 @@ package com.flexcodelabs.flextuma.core.repositories; +import com.flexcodelabs.flextuma.core.entities.auth.Organisation; +import com.flexcodelabs.flextuma.core.entities.auth.User; import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppInboxMessage; +import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppTenantStorageUsageDTO; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; + +import java.util.List; import java.util.UUID; @Repository public interface WhatsAppInboxMessageRepository extends BaseRepository, JpaSpecificationExecutor { boolean existsByProviderMessageId(String providerMessageId); + + /** Bytes of WhatsApp media stored for one tenant: everyone in {@code organisation} when it + * is non-null, otherwise just {@code user} (the org-less-account fallback). */ + @Query("SELECT COALESCE(SUM(m.mediaSize), 0) FROM WhatsAppInboxMessage m WHERE m.mediaSize IS NOT NULL AND " + + "((:organisation IS NOT NULL AND m.createdBy.organisation = :organisation) " + + "OR (:organisation IS NULL AND m.createdBy = :user))") + long sumMediaStorageBytes(@Param("organisation") Organisation organisation, @Param("user") User user); + + /** Count of stored media files for the same tenant scope as {@link #sumMediaStorageBytes}. */ + @Query("SELECT COUNT(m) FROM WhatsAppInboxMessage m WHERE m.mediaSize IS NOT NULL AND " + + "((:organisation IS NOT NULL AND m.createdBy.organisation = :organisation) " + + "OR (:organisation IS NULL AND m.createdBy = :user))") + long countMediaForTenant(@Param("organisation") Organisation organisation, @Param("user") User user); + + /** Storage breakdown across every tenant (organisation, or org-less user), for admin views. */ + @Query("SELECT new com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppTenantStorageUsageDTO(" + + "COALESCE(o.id, u.id), COALESCE(o.name, u.username), SUM(m.mediaSize), COUNT(m)) " + + "FROM WhatsAppInboxMessage m JOIN m.createdBy u LEFT JOIN u.organisation o " + + "WHERE m.mediaSize IS NOT NULL " + + "GROUP BY COALESCE(o.id, u.id), COALESCE(o.name, u.username) " + + "ORDER BY SUM(m.mediaSize) DESC") + List findStorageUsageByTenant(); } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/app/controllers/AppController.java b/src/main/java/com/flexcodelabs/flextuma/modules/app/controllers/AppController.java deleted file mode 100644 index 49e92fd..0000000 --- a/src/main/java/com/flexcodelabs/flextuma/modules/app/controllers/AppController.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.flexcodelabs.flextuma.modules.app.controllers; - -import com.flexcodelabs.flextuma.core.dtos.AppUploadRequest; -import com.flexcodelabs.flextuma.core.dtos.AppUploadResponse; -import com.flexcodelabs.flextuma.modules.app.services.AppUploadService; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/api/apps") -@RequiredArgsConstructor -public class AppController { - - private final AppUploadService appUploadService; - - @PostMapping() - @PreAuthorize("hasAnyAuthority('SUPER_ADMIN')") - public ResponseEntity uploadApp(@ModelAttribute AppUploadRequest request) { - try { - AppUploadResponse response = appUploadService.uploadAndExtractApp(request); - - if (response.getMessage().startsWith("Failed")) { - return ResponseEntity.badRequest().body(response); - } - - return ResponseEntity.ok(response); - } catch (Exception e) { - AppUploadResponse errorResponse = new AppUploadResponse( - "Upload failed: " + e.getMessage(), - request.getAppName(), - request.getVersion(), - null, - 0L, - 0); - return ResponseEntity.internalServerError().body(errorResponse); - } - } -} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/app/services/AppUploadService.java b/src/main/java/com/flexcodelabs/flextuma/modules/app/services/AppUploadService.java deleted file mode 100644 index 3ff9540..0000000 --- a/src/main/java/com/flexcodelabs/flextuma/modules/app/services/AppUploadService.java +++ /dev/null @@ -1,155 +0,0 @@ -package com.flexcodelabs.flextuma.modules.app.services; - -import com.flexcodelabs.flextuma.core.dtos.AppUploadRequest; -import com.flexcodelabs.flextuma.core.dtos.AppUploadResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; - -import java.io.*; -import java.nio.file.*; -import java.nio.file.StandardCopyOption; -import java.nio.file.StandardOpenOption; -import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; - -@Service -@RequiredArgsConstructor -@Slf4j -public class AppUploadService { - - @Value("${flextuma.app.upload.directory:${APP_UPLOAD_DIRECTORY:/tmp/apps}}") - private String uploadDirectory; - - @Value("${flextuma.app.frontend.directory:${APP_FRONTEND_DIRECTORY:/app/client}}") - private String frontendDirectory; - - public AppUploadResponse uploadAndExtractApp(AppUploadRequest request) { - try { - MultipartFile zipFile = request.getZipFile(); - String appName = request.getAppName(); - String version = request.getVersion(); - boolean overwrite = request.isOverwrite(); - - Path appDir = Paths.get(uploadDirectory, appName); - Path versionDir = appDir.resolve(version); - - if (Files.exists(versionDir) && !overwrite) { - return new AppUploadResponse( - "App version already exists. Use overwrite=true to replace.", - appName, - version, - versionDir.toString(), - zipFile.getSize(), - 0); - } - - Files.createDirectories(appDir); - if (overwrite && Files.exists(versionDir)) { - deleteDirectory(versionDir); - } - Files.createDirectories(versionDir); - - int extractedFiles = extractZipFile(zipFile, versionDir); - - Path frontendAppDir = Paths.get(frontendDirectory); - if (overwrite && Files.exists(frontendAppDir)) { - deleteDirectory(frontendAppDir); - } - Files.createDirectories(frontendAppDir); - copyDirectory(versionDir, frontendAppDir); - - return new AppUploadResponse( - "App uploaded and extracted successfully", - appName, - version, - versionDir.toString(), - zipFile.getSize(), - extractedFiles); - - } catch (Exception e) { - log.error("Error uploading app: {}", e.getMessage(), e); - return new AppUploadResponse( - "Failed to upload app: " + e.getMessage(), - request.getAppName(), - request.getVersion(), - null, - request.getZipFile().getSize(), - 0); - } - } - - private int extractZipFile(MultipartFile zipFile, Path extractDir) throws IOException { - int extractedFiles = 0; - - try (ZipInputStream zis = new ZipInputStream(zipFile.getInputStream())) { - ZipEntry entry; - while ((entry = zis.getNextEntry()) != null) { - if (entry.isDirectory()) { - Path dir = extractDir.resolve(entry.getName()); - Files.createDirectories(dir); - continue; - } - - Path filePath = extractDir.resolve(entry.getName()); - Files.createDirectories(filePath.getParent()); - - try (OutputStream fos = Files.newOutputStream(filePath, StandardOpenOption.CREATE_NEW, - StandardOpenOption.TRUNCATE_EXISTING)) { - byte[] buffer = new byte[8192]; - int len; - while ((len = zis.read(buffer)) > 0) { - fos.write(buffer, 0, len); - } - } - - extractedFiles++; - zis.closeEntry(); - } - } - - return extractedFiles; - } - - private void deleteDirectory(Path path) throws IOException { - if (Files.exists(path)) { - try { - try (var stream = Files.walk(path)) { - stream.sorted((a, b) -> b.compareTo(a)) - .forEach(filePath -> { - try { - Files.delete(filePath); - } catch (IOException e) { - // continue with other files - } - }); - } - } catch (IOException e) { - // directory might not exist - } - } - } - - private void copyDirectory(Path source, Path target) throws IOException { - try { - try (var stream = Files.walk(source)) { - stream.filter(path -> !Files.isDirectory(path)) - .forEach(sourceFile -> { - Path targetFile = null; - try { - Path relative = source.relativize(sourceFile); - targetFile = target.resolve(relative); - Files.createDirectories(targetFile.getParent()); - Files.copy(sourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING); - } catch (IOException e) { - // continue with other files - } - }); - } - } catch (IOException e) { - // source might not exist - } - } -} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppInboxMessageController.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppInboxMessageController.java index 19f2a0a..fed5401 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppInboxMessageController.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppInboxMessageController.java @@ -4,9 +4,11 @@ import com.flexcodelabs.flextuma.core.dtos.Pagination; import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppInboxMessage; import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppConversationDTO; +import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppTenantStorageUsageDTO; import com.flexcodelabs.flextuma.modules.whatsapp.services.WhatsAppInboxMessageService; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.UUID; @@ -41,6 +43,11 @@ public ResponseEntity media(@PathVariable UUID id) { return ResponseEntity.ok().contentType(contentType).body(media.bytes()); } + @GetMapping("/storage-usage") + public List storageUsage() { + return service.storageUsage(); + } + @GetMapping("/conversations") public Map conversations( @RequestParam(defaultValue = "0") int page, diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookController.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookController.java index d6d141d..c9d2e93 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookController.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookController.java @@ -235,7 +235,10 @@ private void attachMedia(WhatsAppInboxMessage message, WhatsAppWebhookConfig con message.setMediaId(mediaId.toString()); message.setMimeType(mimeType != null ? mimeType.toString() : null); message.setCaption(caption != null ? caption.toString() : null); - mediaService.download(config, mediaId.toString()).ifPresent(message::setMediaPath); + mediaService.download(config, mediaId.toString()).ifPresent(downloaded -> { + message.setMediaPath(downloaded.path()); + message.setMediaSize(downloaded.size()); + }); } private String extractInboundContent(Map raw, String type) { diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/dtos/WhatsAppTenantStorageUsageDTO.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/dtos/WhatsAppTenantStorageUsageDTO.java new file mode 100644 index 0000000..71c9ba7 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/dtos/WhatsAppTenantStorageUsageDTO.java @@ -0,0 +1,15 @@ +package com.flexcodelabs.flextuma.modules.whatsapp.dtos; + +import java.util.UUID; + +import lombok.Builder; + +/** Total WhatsApp media storage for one tenant: an organisation, or an individual user for + * org-less accounts. */ +@Builder +public record WhatsAppTenantStorageUsageDTO( + UUID tenantId, + String tenantLabel, + long totalBytes, + long mediaCount) { +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppInboxMessageService.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppInboxMessageService.java index 9f65615..360c4e1 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppInboxMessageService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppInboxMessageService.java @@ -1,10 +1,15 @@ package com.flexcodelabs.flextuma.modules.whatsapp.services; import com.flexcodelabs.flextuma.core.dtos.Pagination; +import com.flexcodelabs.flextuma.core.entities.auth.Organisation; +import com.flexcodelabs.flextuma.core.entities.auth.User; import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppInboxMessage; +import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; import com.flexcodelabs.flextuma.core.repositories.WhatsAppInboxMessageRepository; +import com.flexcodelabs.flextuma.core.security.SecurityUtils; import com.flexcodelabs.flextuma.core.services.BaseService; import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppConversationDTO; +import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppTenantStorageUsageDTO; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; @@ -23,8 +28,11 @@ @Service @RequiredArgsConstructor public class WhatsAppInboxMessageService extends BaseService { + private static final String SUPER_ADMIN = "SUPER_ADMIN"; + private final WhatsAppInboxMessageRepository repository; private final WhatsAppMediaService mediaService; + private final CurrentUserResolver currentUserResolver; public record MediaContent(byte[] bytes, String mimeType) {} @@ -58,6 +66,25 @@ public MediaContent getMedia(UUID id) { return new MediaContent(bytes, message.getMimeType()); } + /** WhatsApp media storage usage. SUPER_ADMIN sees the breakdown across every tenant + * (organisation, or org-less user); everyone else sees only their own tenant's usage. */ + public List storageUsage() { + if (SecurityUtils.getCurrentUserAuthorities().contains(SUPER_ADMIN)) { + return repository.findStorageUsageByTenant(); + } + + User currentUser = currentUserResolver.getCurrentUser() + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "No authenticated user")); + Organisation organisation = currentUser.getOrganisation(); + + return List.of(WhatsAppTenantStorageUsageDTO.builder() + .tenantId(organisation != null ? organisation.getId() : currentUser.getId()) + .tenantLabel(organisation != null ? organisation.getName() : currentUser.getUsername()) + .totalBytes(repository.sumMediaStorageBytes(organisation, currentUser)) + .mediaCount(repository.countMediaForTenant(organisation, currentUser)) + .build()); + } + @Transactional public WhatsAppInboxMessage markAsRead(UUID id) { WhatsAppInboxMessage message = findAccessibleById(id); diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppMediaService.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppMediaService.java index b9b4da2..06b5223 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppMediaService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppMediaService.java @@ -1,5 +1,6 @@ package com.flexcodelabs.flextuma.modules.whatsapp.services; +import com.flexcodelabs.flextuma.core.entities.auth.User; import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector; import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppWebhookConfig; import com.flexcodelabs.flextuma.core.repositories.SmsConnectorRepository; @@ -21,9 +22,11 @@ /** * Fetches inbound WhatsApp media (images, documents, audio, video, stickers) from Meta's - * Graph API and stores it on local disk. Meta's webhook payload only carries a media id, not - * the bytes: the id must first be exchanged for a short-lived CDN url (GET /{media-id}), which - * is then downloaded with the same bearer token. + * Graph API and stores it on local disk, under a per-tenant subfolder so storage usage can be + * attributed to the organisation (or, for org-less accounts, the individual user) that owns the + * WhatsApp connector the message came through. Meta's webhook payload only carries a media id, + * not the bytes: the id must first be exchanged for a short-lived CDN url (GET /{media-id}), + * which is then downloaded with the same bearer token. */ @Slf4j @Service @@ -35,15 +38,20 @@ public class WhatsAppMediaService { private final SmsConnectorRepository smsConnectorRepository; private final RestTemplate restTemplate; - @Value("${flextuma.whatsapp.media.directory:/tmp/whatsapp-media}") + @Value("${flextuma.whatsapp.media.directory:client/media/whatsapp}") private String mediaDirectory; + /** Result of a successful download: the path (tenant folder + filename) to persist as + * {@code WhatsAppInboxMessage.mediaPath}, and the byte size to persist as {@code mediaSize} + * for per-tenant storage accounting. */ + public record DownloadedMedia(String path, long size) {} + /** - * Best-effort download: returns the stored filename, or empty if no usable WhatsApp + * Best-effort download: returns the stored path, or empty if no usable WhatsApp * connector (needed for the access token) can be found or the download otherwise fails. * Never throws -- a missing/failed media download must not block ingesting the message. */ - public Optional download(WhatsAppWebhookConfig config, String mediaId) { + public Optional download(WhatsAppWebhookConfig config, String mediaId) { try { SmsConnector connector = resolveConnector(config); if (connector == null || connector.getKey() == null || connector.getUrl() == null) { @@ -73,17 +81,29 @@ public Optional download(WhatsAppWebhookConfig config, String mediaId) { return Optional.empty(); } + String tenantFolder = tenantFolder(config.getCreatedBy()); String filename = sanitize(mediaId); - Path directory = Paths.get(mediaDirectory); + Path directory = Paths.get(mediaDirectory, tenantFolder); Files.createDirectories(directory); Files.write(directory.resolve(filename), bytes); - return Optional.of(filename); + return Optional.of(new DownloadedMedia(tenantFolder + "/" + filename, bytes.length)); } catch (Exception e) { log.warn("Failed to download WhatsApp media [{}]: {}", mediaId, e.getMessage()); return Optional.empty(); } } + /** Mirrors {@code TenantAwareSpecification}'s notion of a tenant: the owning organisation, + * falling back to the individual user for org-less accounts. */ + private String tenantFolder(User owner) { + if (owner == null) { + return "unassigned"; + } + return owner.getOrganisation() != null + ? "org-" + owner.getOrganisation().getId() + : "user-" + owner.getId(); + } + /** Prefers the webhook config's explicitly linked connector; falls back to the owner's * first active WhatsApp connector for configs that predate that link (ambiguous if the * owner has more than one). */ @@ -96,20 +116,35 @@ private SmsConnector resolveConnector(WhatsAppWebhookConfig config) { .orElse(null); } - /** Reads back previously downloaded media bytes. Empty if the file is missing. */ - public Optional read(String filename) { + /** Reads back previously downloaded media bytes. {@code storedPath} is either a bare + * filename (pre-tenant-scoping messages) or a "tenantFolder/filename" path. Empty if the + * file is missing. */ + public Optional read(String storedPath) { try { - Path path = Paths.get(mediaDirectory).resolve(sanitize(filename)); + Path path = resolveStoredPath(storedPath); if (!Files.exists(path)) { return Optional.empty(); } return Optional.of(Files.readAllBytes(path)); } catch (IOException e) { - log.warn("Failed to read stored WhatsApp media [{}]: {}", filename, e.getMessage()); + log.warn("Failed to read stored WhatsApp media [{}]: {}", storedPath, e.getMessage()); return Optional.empty(); } } + /** Sanitizes each path segment independently so a "tenantFolder/filename" path resolves to + * the matching nested file rather than allowing traversal outside the media directory. */ + private Path resolveStoredPath(String storedPath) { + Path resolved = Paths.get(mediaDirectory); + for (String segment : storedPath.split("/")) { + if (segment.isEmpty()) { + continue; + } + resolved = resolved.resolve(sanitize(segment)); + } + return resolved; + } + private String sanitize(String value) { return value.replaceAll("[^A-Za-z0-9_-]", "_"); } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 2b87ac0..c309828 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -64,14 +64,16 @@ flextuma.connector-secrets.encryption-key=${FLEXTUMA_CONNECTOR_ENCRYPTION_KEY:} # Set to 0 only for a deliberately unlimited plan. This cap applies before a shared system-connector message is charged. flextuma.system-connectors.daily-message-limit-per-user=${FLEXTUMA_SYSTEM_CONNECTORS_DAILY_MESSAGE_LIMIT_PER_USER:1000} -# App Upload Directories -flextuma.app.upload.directory=${APP_UPLOAD_DIRECTORY:/tmp/apps} +# Frontend (SSR-hosted separately; this directory only backs FrontendController's legacy static fallback) flextuma.app.frontend.directory=${APP_FRONTEND_DIRECTORY:/tmp/frontend} -# Where downloaded WhatsApp inbound media (images, documents, etc.) is stored on disk. -flextuma.whatsapp.media.directory=${FLEXTUMA_WHATSAPP_MEDIA_DIRECTORY:/tmp/whatsapp-media} +# Where downloaded WhatsApp inbound media (images, documents, etc.) is stored on disk, under a +# per-tenant subfolder (organisation id, or user id for org-less accounts). Defaults to a +# directory inside the frontend client folder so media storage rides along with the same +# persistent volume the deployed client assets use. +flextuma.whatsapp.media.directory=${FLEXTUMA_WHATSAPP_MEDIA_DIRECTORY:client/media/whatsapp} -# App ZIP uploads. These can be overridden per deployment through environment variables. +# Upload size limits. These can be overridden per deployment through environment variables. spring.servlet.multipart.max-file-size=${MAX_UPLOAD_FILE_SIZE:100MB} spring.servlet.multipart.max-request-size=${MAX_UPLOAD_REQUEST_SIZE:100MB} diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookControllerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookControllerTest.java index 0026394..0872a0b 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookControllerTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookControllerTest.java @@ -199,7 +199,8 @@ void receive_shouldSaveInboundMessage_whenPayloadHasMessages() { void receive_shouldCaptureMediaMetadataAndDownload_whenMessageIsImage() { WhatsAppWebhookConfig config = activeConfig(); when(configRepository.findByPhoneNumberIdAndActiveTrue("104725069208652")).thenReturn(Optional.of(config)); - when(mediaService.download(config, "media-123")).thenReturn(Optional.of("stored-media-123")); + when(mediaService.download(config, "media-123")) + .thenReturn(Optional.of(new WhatsAppMediaService.DownloadedMedia("stored-media-123", 4L))); String payload = "{\"entry\":[{\"changes\":[{\"value\":{" + "\"metadata\":{\"phone_number_id\":\"104725069208652\"}," @@ -218,6 +219,7 @@ void receive_shouldCaptureMediaMetadataAndDownload_whenMessageIsImage() { assertEquals("Check this out", saved.getCaption()); assertEquals("Check this out", saved.getContent()); assertEquals("stored-media-123", saved.getMediaPath()); + assertEquals(4L, saved.getMediaSize()); } @Test diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppInboxMessageServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppInboxMessageServiceTest.java index 0de5584..2084374 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppInboxMessageServiceTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppInboxMessageServiceTest.java @@ -1,10 +1,15 @@ package com.flexcodelabs.flextuma.modules.whatsapp.services; import com.flexcodelabs.flextuma.core.dtos.Pagination; +import com.flexcodelabs.flextuma.core.entities.auth.Organisation; +import com.flexcodelabs.flextuma.core.entities.auth.User; import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppInboxMessage; import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppWebhookConfig; +import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; import com.flexcodelabs.flextuma.core.repositories.WhatsAppInboxMessageRepository; import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppConversationDTO; +import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppTenantStorageUsageDTO; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -13,6 +18,11 @@ import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.domain.Specification; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.server.ResponseStatusException; import java.time.LocalDateTime; @@ -24,6 +34,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -35,9 +47,30 @@ class WhatsAppInboxMessageServiceTest { @Mock private WhatsAppMediaService mediaService; + @Mock + private CurrentUserResolver currentUserResolver; + @InjectMocks private WhatsAppInboxMessageService service; + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + private void authenticateAs(String... authorities) { + SecurityContext securityContext = mock(SecurityContext.class); + Authentication auth = mock(Authentication.class); + when(auth.isAuthenticated()).thenReturn(true); + List granted = List.of(authorities).stream() + .map(SimpleGrantedAuthority::new) + .map(GrantedAuthority.class::cast) + .toList(); + doReturn(granted).when(auth).getAuthorities(); + when(securityContext.getAuthentication()).thenReturn(auth); + SecurityContextHolder.setContext(securityContext); + } + private WhatsAppInboxMessage message(WhatsAppWebhookConfig config, String from, String content, LocalDateTime receivedAt, boolean read) { WhatsAppInboxMessage message = new WhatsAppInboxMessage(); message.setId(UUID.randomUUID()); @@ -160,4 +193,65 @@ void listConversations_shouldReturnEmptyPage_whenNoMessages() { assertEquals(0, result.getTotal()); assertEquals(0, result.getData().size()); } + + @Test + void storageUsage_shouldReturnFullTenantBreakdown_forSuperAdmin() { + authenticateAs("SUPER_ADMIN"); + List breakdown = List.of( + WhatsAppTenantStorageUsageDTO.builder().tenantId(UUID.randomUUID()).tenantLabel("Acme") + .totalBytes(2048L).mediaCount(3L).build()); + when(repository.findStorageUsageByTenant()).thenReturn(breakdown); + + List result = service.storageUsage(); + + assertEquals(breakdown, result); + } + + @Test + void storageUsage_shouldScopeByOrganisation_forNonAdminOrgMember() { + authenticateAs("USER"); + Organisation organisation = new Organisation(); + organisation.setId(UUID.randomUUID()); + organisation.setName("Acme"); + User user = new User(); + user.setId(UUID.randomUUID()); + user.setOrganisation(organisation); + when(currentUserResolver.getCurrentUser()).thenReturn(Optional.of(user)); + when(repository.sumMediaStorageBytes(organisation, user)).thenReturn(4096L); + when(repository.countMediaForTenant(organisation, user)).thenReturn(5L); + + List result = service.storageUsage(); + + assertEquals(1, result.size()); + assertEquals(organisation.getId(), result.get(0).tenantId()); + assertEquals("Acme", result.get(0).tenantLabel()); + assertEquals(4096L, result.get(0).totalBytes()); + assertEquals(5L, result.get(0).mediaCount()); + } + + @Test + void storageUsage_shouldFallBackToUser_forNonAdminWithoutOrganisation() { + authenticateAs("USER"); + User user = new User(); + user.setId(UUID.randomUUID()); + user.setUsername("solo"); + when(currentUserResolver.getCurrentUser()).thenReturn(Optional.of(user)); + when(repository.sumMediaStorageBytes(null, user)).thenReturn(512L); + when(repository.countMediaForTenant(null, user)).thenReturn(1L); + + List result = service.storageUsage(); + + assertEquals(1, result.size()); + assertEquals(user.getId(), result.get(0).tenantId()); + assertEquals("solo", result.get(0).tenantLabel()); + assertEquals(512L, result.get(0).totalBytes()); + } + + @Test + void storageUsage_shouldThrowUnauthorized_whenNoCurrentUser() { + authenticateAs("USER"); + when(currentUserResolver.getCurrentUser()).thenReturn(Optional.empty()); + + assertThrows(ResponseStatusException.class, () -> service.storageUsage()); + } } diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppMediaServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppMediaServiceTest.java index 9a8a2c9..586a2cd 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppMediaServiceTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppMediaServiceTest.java @@ -1,9 +1,11 @@ package com.flexcodelabs.flextuma.modules.whatsapp.services; +import com.flexcodelabs.flextuma.core.entities.auth.Organisation; import com.flexcodelabs.flextuma.core.entities.auth.User; import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector; import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppWebhookConfig; import com.flexcodelabs.flextuma.core.repositories.SmsConnectorRepository; +import com.flexcodelabs.flextuma.modules.whatsapp.services.WhatsAppMediaService.DownloadedMedia; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -79,14 +81,37 @@ void download_shouldFetchMetadataThenBytesAndWriteToDisk_viaOwnerFallback() { any(HttpEntity.class), eq(byte[].class))) .thenReturn(ResponseEntity.ok(fileBytes)); - Optional stored = service.download(config, "media-123"); + Optional stored = service.download(config, "media-123"); assertTrue(stored.isPresent()); - Path storedFile = tempDir.resolve(stored.get()); + assertEquals("user-" + owner.getId(), stored.get().path().split("/")[0]); + assertEquals(fileBytes.length, stored.get().size()); + Path storedFile = tempDir.resolve(stored.get().path()); assertTrue(Files.exists(storedFile)); assertArrayEquals(fileBytes, readAllBytes(storedFile)); } + @Test + void download_shouldScopeByOrganisation_whenOwnerBelongsToOne() { + Organisation organisation = new Organisation(); + organisation.setId(UUID.randomUUID()); + owner.setOrganisation(organisation); + when(smsConnectorRepository.findByCreatedByAndProviderAndActiveTrue(owner, "WHATSAPP")) + .thenReturn(Optional.of(connector)); + when(restTemplate.exchange(eq("https://graph.facebook.com/v21.0/media-123"), eq(HttpMethod.GET), + any(HttpEntity.class), eq(Map.class))) + .thenReturn(ResponseEntity.ok(Map.of("url", "https://cdn.example.com/blob"))); + byte[] fileBytes = { 1, 2, 3 }; + when(restTemplate.exchange(eq("https://cdn.example.com/blob"), eq(HttpMethod.GET), + any(HttpEntity.class), eq(byte[].class))) + .thenReturn(ResponseEntity.ok(fileBytes)); + + Optional stored = service.download(config, "media-123"); + + assertTrue(stored.isPresent()); + assertEquals("org-" + organisation.getId(), stored.get().path().split("/")[0]); + } + @Test void download_shouldUseConfigsLinkedConnector_withoutConsultingOwnerLookup() { config.setConnector(connector); @@ -98,7 +123,7 @@ void download_shouldUseConfigsLinkedConnector_withoutConsultingOwnerLookup() { any(HttpEntity.class), eq(byte[].class))) .thenReturn(ResponseEntity.ok(fileBytes)); - Optional stored = service.download(config, "media-123"); + Optional stored = service.download(config, "media-123"); assertTrue(stored.isPresent()); verify(smsConnectorRepository, never()).findByCreatedByAndProviderAndActiveTrue(any(), any()); @@ -109,7 +134,7 @@ void download_shouldReturnEmpty_whenOwnerHasNoActiveWhatsAppConnector() { when(smsConnectorRepository.findByCreatedByAndProviderAndActiveTrue(owner, "WHATSAPP")) .thenReturn(Optional.empty()); - Optional stored = service.download(config, "media-123"); + Optional stored = service.download(config, "media-123"); assertEquals(Optional.empty(), stored); } @@ -122,7 +147,7 @@ void download_shouldReturnEmpty_whenMetadataHasNoUrl() { any(HttpEntity.class), eq(Map.class))) .thenReturn(ResponseEntity.ok(Map.of())); - Optional stored = service.download(config, "media-123"); + Optional stored = service.download(config, "media-123"); assertEquals(Optional.empty(), stored); } @@ -134,13 +159,24 @@ void download_shouldReturnEmpty_whenHttpCallThrows() { when(restTemplate.exchange(any(String.class), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) .thenThrow(new RuntimeException("network error")); - Optional stored = service.download(config, "media-123"); + Optional stored = service.download(config, "media-123"); assertEquals(Optional.empty(), stored); } @Test - void read_shouldReturnStoredBytes() { + void read_shouldReturnStoredBytes_forTenantScopedPath() { + ReflectionTestUtils.setField(service, "mediaDirectory", tempDir.toString()); + writeFile(tempDir.resolve("user-abc").resolve("some-file"), new byte[] { 9, 8, 7 }); + + Optional bytes = service.read("user-abc/some-file"); + + assertTrue(bytes.isPresent()); + assertArrayEquals(new byte[] { 9, 8, 7 }, bytes.get()); + } + + @Test + void read_shouldReturnStoredBytes_forLegacyBareFilename() { ReflectionTestUtils.setField(service, "mediaDirectory", tempDir.toString()); writeFile(tempDir.resolve("some-file"), new byte[] { 9, 8, 7 }); @@ -167,6 +203,7 @@ private byte[] readAllBytes(Path path) { private void writeFile(Path path, byte[] content) { try { + Files.createDirectories(path.getParent()); Files.write(path, content); } catch (Exception e) { throw new RuntimeException(e); From eb1c5634bfa0eaa2333970b910c65292a53a7cc7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 10 Sep 2026 10:32:42 +0000 Subject: [PATCH 2/2] Release v0.0.64 [skip ci] --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 43a1db1..213a62b 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'com.flexcodelabs' -version = '0.0.63' +version = '0.0.64' description = 'Flextuma App' java {