From 12b3779c154b6bea61ead8edeff02ee5b1ad07ff Mon Sep 17 00:00:00 2001 From: golemcore1 Date: Thu, 9 Apr 2026 01:51:02 +0000 Subject: [PATCH] feat(golemcore/s3): add MinIO-backed S3 file plugin --- README.md | 1 + golemcore/s3/plugin.yaml | 12 + golemcore/s3/pom.xml | 110 ++++ .../plugins/golemcore/s3/S3FilesService.java | 597 ++++++++++++++++++ .../golemcore/s3/S3FilesToolProvider.java | 153 +++++ .../golemcore/s3/S3PluginBootstrap.java | 22 + .../plugins/golemcore/s3/S3PluginConfig.java | 143 +++++ .../golemcore/s3/S3PluginConfigService.java | 33 + .../golemcore/s3/S3PluginConfiguration.java | 9 + .../s3/S3PluginSettingsContributor.java | 253 ++++++++ .../golemcore/s3/support/S3MimeTypes.java | 66 ++ .../golemcore/s3/support/S3MinioClient.java | 508 +++++++++++++++ .../golemcore/s3/support/S3ObjectContent.java | 5 + .../golemcore/s3/support/S3ObjectInfo.java | 5 + .../golemcore/s3/support/S3PathValidator.java | 51 ++ .../s3/support/S3StorageException.java | 14 + .../golemcore/s3/S3FilesServiceTest.java | 224 +++++++ .../golemcore/s3/S3FilesToolProviderTest.java | 111 ++++ .../golemcore/s3/S3PluginBootstrapTest.java | 21 + .../golemcore/s3/S3PluginConfigTest.java | 46 ++ .../s3/S3PluginSettingsContributorTest.java | 161 +++++ .../golemcore/s3/S3SpringWiringTest.java | 36 ++ .../s3/support/S3MinioClientTest.java | 217 +++++++ pom.xml | 1 + registry/golemcore/s3/index.yaml | 7 + registry/golemcore/s3/versions/1.0.0.yaml | 12 + 26 files changed, 2818 insertions(+) create mode 100644 golemcore/s3/plugin.yaml create mode 100644 golemcore/s3/pom.xml create mode 100644 golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3FilesService.java create mode 100644 golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3FilesToolProvider.java create mode 100644 golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3PluginBootstrap.java create mode 100644 golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3PluginConfig.java create mode 100644 golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3PluginConfigService.java create mode 100644 golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3PluginConfiguration.java create mode 100644 golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3PluginSettingsContributor.java create mode 100644 golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3MimeTypes.java create mode 100644 golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3MinioClient.java create mode 100644 golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3ObjectContent.java create mode 100644 golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3ObjectInfo.java create mode 100644 golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3PathValidator.java create mode 100644 golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3StorageException.java create mode 100644 golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3FilesServiceTest.java create mode 100644 golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3FilesToolProviderTest.java create mode 100644 golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3PluginBootstrapTest.java create mode 100644 golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3PluginConfigTest.java create mode 100644 golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3PluginSettingsContributorTest.java create mode 100644 golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3SpringWiringTest.java create mode 100644 golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/support/S3MinioClientTest.java create mode 100644 registry/golemcore/s3/index.yaml create mode 100644 registry/golemcore/s3/versions/1.0.0.yaml diff --git a/README.md b/README.md index fa2bebf..d34bbcb 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ The current `golemcore/*` modules in this repository are: | `golemcore/obsidian` | Obsidian vault plugin backed by obsidian-local-rest-api. | | `golemcore/perplexity-sonar` | Perplexity Sonar grounded-answer plugin with configurable model selection and synchronous completions. | | `golemcore/pinchtab` | PinchTab browser automation plugin for navigation, snapshots, actions, text extraction, and screenshots. | +| `golemcore/s3` | S3-compatible object storage plugin backed by the MinIO Java SDK. | | `golemcore/tavily-search` | Tavily-backed web search tool plugin with configurable search depth and answer generation. | | `golemcore/telegram` | Telegram channel, invite onboarding, confirmations, and plan approval integration. | | `golemcore/slack` | Slack Socket Mode channel plugin with thread follow-ups, confirmations, and plan approval UI. | diff --git a/golemcore/s3/plugin.yaml b/golemcore/s3/plugin.yaml new file mode 100644 index 0000000..302364e --- /dev/null +++ b/golemcore/s3/plugin.yaml @@ -0,0 +1,12 @@ +id: golemcore/s3 +provider: golemcore +name: s3 +version: 1.0.0 +pluginApiVersion: 1 +engineVersion: ">=0.0.0 <1.0.0" +entrypoint: me.golemcore.plugins.golemcore.s3.S3PluginBootstrap +description: S3 object plugin backed by the MinIO Java SDK. +sourceUrl: https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/s3 +license: Apache-2.0 +maintainers: + - alexk-dev diff --git a/golemcore/s3/pom.xml b/golemcore/s3/pom.xml new file mode 100644 index 0000000..053693b --- /dev/null +++ b/golemcore/s3/pom.xml @@ -0,0 +1,110 @@ + + + 4.0.0 + + + me.golemcore.plugins + golemcore-plugins + 1.0.0 + ../../pom.xml + + + 1.0.0 + golemcore-s3-plugin + golemcore/s3 + S3 object plugin for GolemCore backed by the MinIO Java SDK + + + golemcore + s3 + ../../misc/formatter_eclipse.xml + 9.0.0 + + + + + me.golemcore.plugins + golemcore-plugin-extension-api + provided + + + me.golemcore.plugins + golemcore-plugin-runtime-api + provided + + + org.projectlombok + lombok + provided + + + org.springframework + spring-context + + + com.fasterxml.jackson.core + jackson-databind + + + io.minio + minio + ${minio.version} + + + com.squareup.okhttp3 + okhttp-jvm + ${okhttp.version} + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + src/main/resources + + + ${project.basedir} + + plugin.yaml + + META-INF/golemcore + + + + + net.revelc.code.formatter + formatter-maven-plugin + + + maven-shade-plugin + + + maven-antrun-plugin + + + copy-plugin-artifact + package + + + + + + + + run + + + + + + + diff --git a/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3FilesService.java b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3FilesService.java new file mode 100644 index 0000000..be58e12 --- /dev/null +++ b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3FilesService.java @@ -0,0 +1,597 @@ +package me.golemcore.plugins.golemcore.s3; + +import me.golemcore.plugin.api.extension.model.Attachment; +import me.golemcore.plugin.api.extension.model.ToolFailureKind; +import me.golemcore.plugin.api.extension.model.ToolResult; +import me.golemcore.plugins.golemcore.s3.support.S3MimeTypes; +import me.golemcore.plugins.golemcore.s3.support.S3MinioClient; +import me.golemcore.plugins.golemcore.s3.support.S3ObjectContent; +import me.golemcore.plugins.golemcore.s3.support.S3ObjectInfo; +import me.golemcore.plugins.golemcore.s3.support.S3PathValidator; +import me.golemcore.plugins.golemcore.s3.support.S3StorageException; +import org.springframework.stereotype.Service; + +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.Charset; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +@Service +public class S3FilesService { + + private static final long BYTES_PER_KB = 1024; + private static final Set TEXT_MIME_TYPES = Set.of( + "application/json", + "application/ld+json", + "application/xml", + "application/x-yaml", + "application/yaml", + "application/javascript", + "application/x-javascript", + "image/svg+xml"); + private static final Set TEXT_FILE_EXTENSIONS = Set.of( + "txt", + "md", + "markdown", + "json", + "xml", + "yaml", + "yml", + "csv", + "tsv", + "log", + "properties", + "java", + "kt", + "groovy", + "js", + "ts", + "jsx", + "tsx", + "py", + "rb", + "go", + "rs", + "c", + "cc", + "cpp", + "h", + "hpp", + "html", + "css", + "scss", + "sql", + "sh", + "bash", + "zsh", + "ini", + "toml"); + + private final S3MinioClient client; + private final S3PluginConfigService configService; + private final S3PathValidator pathValidator = new S3PathValidator(); + + public S3FilesService(S3MinioClient client, S3PluginConfigService configService) { + this.client = client; + this.configService = configService; + } + + public S3PluginConfig getConfig() { + return configService.getConfig(); + } + + public ToolResult listDirectory(String path) { + try { + String normalizedPath = pathValidator.normalizeOptionalPath(path); + S3ObjectInfo object = client.findObject(normalizedPath); + if (object != null) { + return executionFailure("Not a directory: " + displayPath(normalizedPath)); + } + if (!normalizedPath.isBlank() && !client.directoryExists(normalizedPath)) { + return executionFailure("Directory not found: " + normalizedPath); + } + List entries = client.listDirectory(normalizedPath); + StringBuilder output = new StringBuilder(); + output.append("Directory: ").append(displayPath(normalizedPath)).append('\n'); + output.append("Entries: ").append(entries.size()).append("\n\n"); + for (S3ObjectInfo entry : entries) { + if (entry.directory()) { + output.append("[DIR] ").append(entry.name()).append("/\n"); + } else { + output.append("[FILE] ").append(entry.name()).append(" (") + .append(formatSize(entry.size() != null ? entry.size() : 0L)) + .append(")\n"); + } + } + Map data = new LinkedHashMap<>(); + data.put("path", normalizedPath); + data.put("entries", entries.stream().map(this::toObjectInfo).toList()); + return ToolResult.success(output.toString(), data); + } catch (IllegalArgumentException | S3StorageException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult fileInfo(String path) { + try { + String normalizedPath = pathValidator.normalizeRequiredPath(path); + S3ObjectInfo object = client.findObject(normalizedPath); + if (object != null) { + return buildObjectInfoResult(object); + } + if (!client.directoryExists(normalizedPath)) { + return executionFailure("Path not found: " + normalizedPath); + } + S3ObjectInfo marker = client.findDirectoryMarker(normalizedPath); + S3ObjectInfo directory = marker != null + ? marker + : new S3ObjectInfo( + getConfig().getBucket(), + normalizedPath, + leafName(normalizedPath), + true, + null, + null, + null, + null); + return buildObjectInfoResult(directory); + } catch (IllegalArgumentException | S3StorageException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult readFile(String path) { + try { + String normalizedPath = pathValidator.normalizeRequiredPath(path); + S3ObjectInfo object = requireObject(normalizedPath); + long size = object.size() != null ? object.size() : 0L; + if (size > getConfig().getMaxDownloadBytes()) { + return executionFailure("File too large to read directly: " + normalizedPath + " (" + formatSize(size) + + ")"); + } + S3ObjectContent content = client.readObject(normalizedPath); + boolean textFile = isTextFile(normalizedPath, content.contentType(), content.bytes()); + if (textFile) { + return buildTextReadResult(content); + } + return buildBinaryReadResult(content, size); + } catch (IllegalArgumentException | S3StorageException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult writeFile(String path, String content, String contentBase64, boolean append) { + if (!Boolean.TRUE.equals(getConfig().getAllowWrite())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, "S3 write is disabled in plugin settings"); + } + try { + String normalizedPath = pathValidator.normalizeRequiredPath(path); + byte[] payload = resolveWritePayload(content, contentBase64); + byte[] finalPayload = append ? appendToExistingContent(normalizedPath, payload) : payload; + client.writeObject(normalizedPath, finalPayload, + resolveContentType(normalizedPath, content, contentBase64)); + Map data = new LinkedHashMap<>(); + data.put("path", normalizedPath); + data.put("size", finalPayload.length); + data.put("operation", append ? "append" : "write"); + return ToolResult.success( + "Successfully " + (append ? "appended to" : "written") + " object: " + normalizedPath, + data); + } catch (IllegalArgumentException | S3StorageException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult createDirectory(String path) { + if (!Boolean.TRUE.equals(getConfig().getAllowWrite())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, "S3 write is disabled in plugin settings"); + } + try { + String normalizedPath = pathValidator.normalizeRequiredPath(path); + S3ObjectInfo object = client.findObject(normalizedPath); + if (object != null) { + return executionFailure("Path exists but is not a directory: " + normalizedPath); + } + if (client.directoryExists(normalizedPath)) { + return ToolResult.success("Directory already exists: " + normalizedPath, + Map.of("path", normalizedPath)); + } + client.createDirectoryMarker(normalizedPath); + return ToolResult.success("Created directory: " + normalizedPath, Map.of("path", normalizedPath)); + } catch (IllegalArgumentException | S3StorageException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult delete(String path) { + if (!Boolean.TRUE.equals(getConfig().getAllowDelete())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, "S3 delete is disabled in plugin settings"); + } + try { + String normalizedPath = pathValidator.normalizeRequiredPath(path); + S3ObjectInfo object = client.findObject(normalizedPath); + if (object != null) { + client.deleteObject(normalizedPath); + return ToolResult.success("Deleted: " + normalizedPath, Map.of("path", normalizedPath)); + } + if (!client.directoryExists(normalizedPath)) { + return executionFailure("Path not found: " + normalizedPath); + } + deleteDirectory(normalizedPath); + return ToolResult.success("Deleted: " + normalizedPath, Map.of("path", normalizedPath)); + } catch (IllegalArgumentException | S3StorageException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult move(String path, String targetPath) { + if (!Boolean.TRUE.equals(getConfig().getAllowMove())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, "S3 move is disabled in plugin settings"); + } + try { + String normalizedPath = pathValidator.normalizeRequiredPath(path); + String normalizedTargetPath = pathValidator.normalizeRequiredPath(targetPath); + if (normalizedPath.equals(normalizedTargetPath)) { + return executionFailure("Source and target paths must differ"); + } + ensureTargetAbsent(normalizedTargetPath); + S3ObjectInfo object = client.findObject(normalizedPath); + if (object != null) { + client.copyObject(normalizedPath, normalizedTargetPath); + client.deleteObject(normalizedPath); + } else { + copyDirectory(normalizedPath, normalizedTargetPath); + deleteDirectory(normalizedPath); + } + Map data = new LinkedHashMap<>(); + data.put("path", normalizedPath); + data.put("target_path", normalizedTargetPath); + return ToolResult.success("Moved: " + normalizedPath + " -> " + normalizedTargetPath, data); + } catch (IllegalArgumentException | S3StorageException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult copy(String path, String targetPath) { + if (!Boolean.TRUE.equals(getConfig().getAllowCopy())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, "S3 copy is disabled in plugin settings"); + } + try { + String normalizedPath = pathValidator.normalizeRequiredPath(path); + String normalizedTargetPath = pathValidator.normalizeRequiredPath(targetPath); + if (normalizedPath.equals(normalizedTargetPath)) { + return executionFailure("Source and target paths must differ"); + } + ensureTargetAbsent(normalizedTargetPath); + S3ObjectInfo object = client.findObject(normalizedPath); + if (object != null) { + client.copyObject(normalizedPath, normalizedTargetPath); + } else { + copyDirectory(normalizedPath, normalizedTargetPath); + } + Map data = new LinkedHashMap<>(); + data.put("path", normalizedPath); + data.put("target_path", normalizedTargetPath); + return ToolResult.success("Copied: " + normalizedPath + " -> " + normalizedTargetPath, data); + } catch (IllegalArgumentException | S3StorageException ex) { + return executionFailure(ex.getMessage()); + } + } + + private ToolResult buildObjectInfoResult(S3ObjectInfo info) { + Map data = toObjectInfo(info); + StringBuilder output = new StringBuilder(); + output.append("Path: ").append(displayPath(info.key())).append('\n'); + output.append("Type: ").append(info.directory() ? "Directory" : "File").append('\n'); + if (info.size() != null) { + output.append("Size: ").append(formatSize(info.size())).append('\n'); + } + if (info.contentType() != null && !info.contentType().isBlank()) { + output.append("Content-Type: ").append(info.contentType()).append('\n'); + } + if (info.lastModified() != null) { + output.append("Modified: ").append(info.lastModified()).append('\n'); + } + if (info.eTag() != null && !info.eTag().isBlank()) { + output.append("ETag: ").append(info.eTag()).append('\n'); + } + return ToolResult.success(output.toString().trim(), data); + } + + private ToolResult buildTextReadResult(S3ObjectContent content) { + String text = decodeText(content.bytes(), content.contentType()); + boolean truncated = text.length() > getConfig().getMaxInlineTextChars(); + String visibleText = truncated ? text.substring(0, getConfig().getMaxInlineTextChars()) : text; + Map data = baseReadData(content, true); + data.put("content", visibleText); + data.put("truncated", truncated); + if (truncated) { + data.put("originalLength", text.length()); + data.put("attachment", buildAttachment(content, content.bytes())); + } + String output = truncated + ? visibleText + "\n\n[TRUNCATED: " + text.length() + " chars total. Full object attached.]" + : visibleText; + return ToolResult.success(output, data); + } + + private ToolResult buildBinaryReadResult(S3ObjectContent content, long size) { + Map data = baseReadData(content, false); + Attachment attachment = buildAttachment(content, content.bytes()); + data.put("attachment", attachment); + data.put("filename", attachment.getFilename()); + StringBuilder output = new StringBuilder(); + output.append("Read binary object: ").append(content.key()).append('\n'); + output.append("Size: ").append(formatSize(size)).append('\n'); + if (content.contentType() != null && !content.contentType().isBlank()) { + output.append("Content-Type: ").append(content.contentType()); + } + return ToolResult.success(output.toString().trim(), data); + } + + private Map baseReadData(S3ObjectContent content, boolean textFile) { + Map data = new LinkedHashMap<>(); + data.put("bucket", content.bucket()); + data.put("path", content.key()); + data.put("size", content.bytes().length); + data.put("etag", content.eTag()); + data.put("content_type", content.contentType()); + data.put("modified", content.lastModified() != null ? content.lastModified().toString() : null); + data.put("is_text", textFile); + return data; + } + + private Attachment buildAttachment(S3ObjectContent content, byte[] bytes) { + String mimeType = content.contentType() != null && !content.contentType().isBlank() + ? stripMimeParameters(content.contentType()) + : S3MimeTypes.detect(content.key()); + Attachment.Type attachmentType = mimeType.startsWith("image/") + ? Attachment.Type.IMAGE + : Attachment.Type.DOCUMENT; + return Attachment.builder() + .type(attachmentType) + .data(bytes) + .filename(leafName(content.key())) + .mimeType(mimeType) + .caption("S3 object: " + content.key()) + .build(); + } + + private void ensureTargetAbsent(String normalizedTargetPath) { + if (client.findObject(normalizedTargetPath) != null || client.directoryExists(normalizedTargetPath)) { + throw new IllegalArgumentException("Target already exists: " + normalizedTargetPath); + } + } + + private S3ObjectInfo requireObject(String normalizedPath) { + S3ObjectInfo object = client.findObject(normalizedPath); + if (object == null) { + if (client.directoryExists(normalizedPath)) { + throw new IllegalArgumentException("Not a file: " + normalizedPath); + } + throw new IllegalArgumentException("File not found: " + normalizedPath); + } + return object; + } + + private void copyDirectory(String sourcePath, String targetPath) { + if (!client.directoryExists(sourcePath)) { + throw new IllegalArgumentException("Directory not found: " + sourcePath); + } + List entries = collectDirectoryEntries(sourcePath); + for (S3ObjectInfo entry : entries) { + String suffix = relativeSuffix(sourcePath, entry.key()); + String destinationPath = suffix.isBlank() ? targetPath : targetPath + "/" + suffix; + if (entry.directory()) { + client.createDirectoryMarker(destinationPath); + } else { + client.copyObject(entry.key(), destinationPath); + } + } + } + + private void deleteDirectory(String path) { + List entries = collectDirectoryEntries(path); + if (entries.isEmpty()) { + throw new IllegalArgumentException("Directory not found: " + path); + } + for (S3ObjectInfo entry : entries) { + if (entry.directory()) { + client.deleteDirectoryMarker(entry.key()); + } else { + client.deleteObject(entry.key()); + } + } + } + + private List collectDirectoryEntries(String path) { + Set seen = new LinkedHashSet<>(); + List collected = new ArrayList<>(); + S3ObjectInfo marker = client.findDirectoryMarker(path); + if (marker != null && seen.add(marker.key() + "/dir")) { + collected.add(marker); + } + for (S3ObjectInfo entry : client.listTree(path)) { + String identity = entry.key() + (entry.directory() ? "/dir" : "/file"); + if (seen.add(identity)) { + collected.add(entry); + } + } + collected.sort((left, right) -> Integer.compare(right.key().length(), left.key().length())); + return List.copyOf(collected); + } + + private String relativeSuffix(String sourcePath, String entryPath) { + if (entryPath.equals(sourcePath)) { + return ""; + } + if (entryPath.startsWith(sourcePath + "/")) { + return entryPath.substring(sourcePath.length() + 1); + } + throw new IllegalArgumentException("Entry path " + entryPath + " is not under " + sourcePath); + } + + private byte[] appendToExistingContent(String normalizedPath, byte[] payload) { + try { + S3ObjectInfo existing = requireObject(normalizedPath); + long size = existing.size() != null ? existing.size() : 0L; + if (size > getConfig().getMaxDownloadBytes()) { + throw new IllegalArgumentException("Existing object is too large to append safely: " + normalizedPath); + } + S3ObjectContent existingContent = client.readObject(normalizedPath); + byte[] combined = new byte[existingContent.bytes().length + payload.length]; + System.arraycopy(existingContent.bytes(), 0, combined, 0, existingContent.bytes().length); + System.arraycopy(payload, 0, combined, existingContent.bytes().length, payload.length); + return combined; + } catch (IllegalArgumentException ex) { + if (ex.getMessage() != null && ex.getMessage().startsWith("File not found:")) { + return payload; + } + throw ex; + } + } + + private byte[] resolveWritePayload(String content, String contentBase64) { + if (content != null && !content.isBlank() && contentBase64 != null && !contentBase64.isBlank()) { + throw new IllegalArgumentException("Provide either content or content_base64, not both"); + } + if (contentBase64 != null && !contentBase64.isBlank()) { + try { + return Base64.getDecoder().decode(contentBase64); + } catch (IllegalArgumentException ex) { + throw new IllegalArgumentException("content_base64 must be valid base64"); + } + } + if (content != null) { + return content.getBytes(StandardCharsets.UTF_8); + } + throw new IllegalArgumentException("content or content_base64 is required"); + } + + private String resolveContentType(String normalizedPath, String content, String contentBase64) { + if (contentBase64 != null && !contentBase64.isBlank()) { + return S3MimeTypes.detect(normalizedPath); + } + if (content != null) { + return S3MimeTypes.detect(normalizedPath); + } + return "application/octet-stream"; + } + + private ToolResult executionFailure(String message) { + return ToolResult.failure(ToolFailureKind.EXECUTION_FAILED, message); + } + + private boolean isTextFile(String path, String contentType, byte[] bytes) { + String normalizedMimeType = stripMimeParameters(contentType).toLowerCase(Locale.ROOT); + if (normalizedMimeType.startsWith("text/") || TEXT_MIME_TYPES.contains(normalizedMimeType)) { + return true; + } + if (normalizedMimeType.startsWith("image/") || normalizedMimeType.startsWith("audio/") + || normalizedMimeType.startsWith("video/") || "application/pdf".equals(normalizedMimeType) + || "application/zip".equals(normalizedMimeType) + || "application/octet-stream".equals(normalizedMimeType)) { + return false; + } + String extension = fileExtension(path); + if (TEXT_FILE_EXTENSIONS.contains(extension)) { + return true; + } + int limit = Math.min(bytes.length, 512); + for (int index = 0; index < limit; index++) { + if (bytes[index] == 0) { + return false; + } + } + return true; + } + + private String decodeText(byte[] bytes, String contentType) { + Charset charset = StandardCharsets.UTF_8; + String lowerMimeType = contentType != null ? contentType.toLowerCase(Locale.ROOT) : ""; + int charsetIndex = lowerMimeType.indexOf("charset="); + if (charsetIndex >= 0) { + String charsetName = lowerMimeType.substring(charsetIndex + 8).trim(); + int separatorIndex = charsetName.indexOf(';'); + if (separatorIndex >= 0) { + charsetName = charsetName.substring(0, separatorIndex).trim(); + } + try { + charset = Charset.forName(charsetName.replace('"', ' ').trim()); + } catch (RuntimeException ignored) { + charset = StandardCharsets.UTF_8; + } + } + try { + return charset.newDecoder() + .onMalformedInput(CodingErrorAction.REPLACE) + .onUnmappableCharacter(CodingErrorAction.REPLACE) + .decode(ByteBuffer.wrap(bytes)) + .toString(); + } catch (CharacterCodingException ignored) { + return new String(bytes, StandardCharsets.UTF_8); + } + } + + private Map toObjectInfo(S3ObjectInfo info) { + Map data = new LinkedHashMap<>(); + data.put("bucket", info.bucket()); + data.put("path", info.key()); + data.put("name", info.name()); + data.put("type", info.directory() ? "directory" : "file"); + data.put("size", info.size()); + data.put("etag", info.eTag()); + data.put("content_type", info.contentType()); + data.put("modified", info.lastModified() != null ? info.lastModified().toString() : null); + return data; + } + + private String displayPath(String normalizedPath) { + return normalizedPath == null || normalizedPath.isBlank() ? "/" : normalizedPath; + } + + private String leafName(String path) { + String normalized = path; + if (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + int separatorIndex = normalized.lastIndexOf('/'); + return separatorIndex >= 0 ? normalized.substring(separatorIndex + 1) : normalized; + } + + private String fileExtension(String path) { + String currentName = leafName(path).toLowerCase(Locale.ROOT); + int dotIndex = currentName.lastIndexOf('.'); + return dotIndex >= 0 ? currentName.substring(dotIndex + 1) : ""; + } + + private String stripMimeParameters(String contentType) { + if (contentType == null) { + return ""; + } + int separatorIndex = contentType.indexOf(';'); + return separatorIndex >= 0 ? contentType.substring(0, separatorIndex).trim() : contentType.trim(); + } + + private String formatSize(long bytes) { + if (bytes < BYTES_PER_KB) { + return bytes + " B"; + } + if (bytes < BYTES_PER_KB * BYTES_PER_KB) { + return String.format(Locale.ROOT, "%.1f KB", bytes / (double) BYTES_PER_KB); + } + if (bytes < BYTES_PER_KB * BYTES_PER_KB * BYTES_PER_KB) { + return String.format(Locale.ROOT, "%.1f MB", bytes / (double) (BYTES_PER_KB * BYTES_PER_KB)); + } + return String.format(Locale.ROOT, "%.1f GB", bytes / (double) (BYTES_PER_KB * BYTES_PER_KB * BYTES_PER_KB)); + } +} diff --git a/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3FilesToolProvider.java b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3FilesToolProvider.java new file mode 100644 index 0000000..2092695 --- /dev/null +++ b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3FilesToolProvider.java @@ -0,0 +1,153 @@ +package me.golemcore.plugins.golemcore.s3; + +import me.golemcore.plugin.api.extension.model.ToolDefinition; +import me.golemcore.plugin.api.extension.model.ToolFailureKind; +import me.golemcore.plugin.api.extension.model.ToolResult; +import me.golemcore.plugin.api.extension.spi.ToolProvider; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +@Component +public class S3FilesToolProvider implements ToolProvider { + + private static final String TYPE = "type"; + private static final String TYPE_OBJECT = "object"; + private static final String TYPE_STRING = "string"; + private static final String TYPE_BOOLEAN = "boolean"; + private static final String PROPERTIES = "properties"; + private static final String REQUIRED = "required"; + private static final String PARAM_OPERATION = "operation"; + private static final String PARAM_PATH = "path"; + private static final String PARAM_CONTENT = "content"; + private static final String PARAM_CONTENT_BASE64 = "content_base64"; + private static final String PARAM_APPEND = "append"; + private static final String PARAM_TARGET_PATH = "target_path"; + + private final S3FilesService service; + + public S3FilesToolProvider(S3FilesService service) { + this.service = service; + } + + @Override + public ToolDefinition getDefinition() { + return ToolDefinition.builder() + .name("s3_files") + .description("Work with S3-compatible object storage through the MinIO Java SDK.") + .inputSchema(Map.of( + TYPE, TYPE_OBJECT, + PROPERTIES, Map.of( + PARAM_OPERATION, Map.of( + TYPE, TYPE_STRING, + "enum", List.of( + "list_directory", + "read_file", + "write_file", + "create_directory", + "delete", + "move", + "copy", + "file_info")), + PARAM_PATH, Map.of( + TYPE, TYPE_STRING, + "description", "Path relative to the configured S3 root prefix."), + PARAM_CONTENT, Map.of( + TYPE, TYPE_STRING, + "description", "UTF-8 text content for write_file."), + PARAM_CONTENT_BASE64, Map.of( + TYPE, TYPE_STRING, + "description", "Base64-encoded bytes for write_file."), + PARAM_APPEND, Map.of( + TYPE, TYPE_BOOLEAN, + "description", "Append to an existing object when writing."), + PARAM_TARGET_PATH, Map.of( + TYPE, TYPE_STRING, + "description", "Target path for move or copy.")), + REQUIRED, List.of(PARAM_OPERATION), + "allOf", List.of( + requiredWhen("read_file", List.of(PARAM_PATH)), + requiredWhen("write_file", List.of(PARAM_PATH)), + requiredWhen("create_directory", List.of(PARAM_PATH)), + requiredWhen("delete", List.of(PARAM_PATH)), + requiredWhen("move", List.of(PARAM_PATH, PARAM_TARGET_PATH)), + requiredWhen("copy", List.of(PARAM_PATH, PARAM_TARGET_PATH)), + requiredWhen("file_info", List.of(PARAM_PATH))))) + .build(); + } + + @Override + public boolean isEnabled() { + S3PluginConfig config = service.getConfig(); + return Boolean.TRUE.equals(config.getEnabled()) + && hasText(config.getEndpoint()) + && hasText(config.getAccessKey()) + && hasText(config.getSecretKey()) + && hasText(config.getBucket()); + } + + @Override + public CompletableFuture execute(Map parameters) { + return CompletableFuture.supplyAsync(() -> executeOperation(parameters)); + } + + private ToolResult executeOperation(Map parameters) { + String operation = readString(parameters.get(PARAM_OPERATION)); + if (operation == null || operation.isBlank()) { + return ToolResult.failure(ToolFailureKind.EXECUTION_FAILED, "operation is required"); + } + + return switch (operation) { + case "list_directory" -> service.listDirectory(readString(parameters.get(PARAM_PATH))); + case "read_file" -> service.readFile(readString(parameters.get(PARAM_PATH))); + case "write_file" -> service.writeFile( + readString(parameters.get(PARAM_PATH)), + readString(parameters.get(PARAM_CONTENT)), + readString(parameters.get(PARAM_CONTENT_BASE64)), + readBoolean(parameters.get(PARAM_APPEND))); + case "create_directory" -> service.createDirectory(readString(parameters.get(PARAM_PATH))); + case "delete" -> service.delete(readString(parameters.get(PARAM_PATH))); + case "move" -> service.move( + readString(parameters.get(PARAM_PATH)), + readString(parameters.get(PARAM_TARGET_PATH))); + case "copy" -> service.copy( + readString(parameters.get(PARAM_PATH)), + readString(parameters.get(PARAM_TARGET_PATH))); + case "file_info" -> service.fileInfo(readString(parameters.get(PARAM_PATH))); + default -> ToolResult.failure(ToolFailureKind.EXECUTION_FAILED, + "Unsupported s3_files operation: " + operation); + }; + } + + private Map requiredWhen(String operation, List requiredFields) { + return Map.of( + "if", Map.of( + PROPERTIES, Map.of( + PARAM_OPERATION, Map.of("const", operation))), + "then", Map.of( + REQUIRED, requiredFields)); + } + + private String readString(Object value) { + if (value instanceof String text) { + return text; + } + return null; + } + + private boolean readBoolean(Object value) { + if (value instanceof Boolean bool) { + return bool; + } + if (value instanceof String text && !text.isBlank()) { + return Boolean.parseBoolean(text); + } + return false; + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3PluginBootstrap.java b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3PluginBootstrap.java new file mode 100644 index 0000000..ad9f8a9 --- /dev/null +++ b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3PluginBootstrap.java @@ -0,0 +1,22 @@ +package me.golemcore.plugins.golemcore.s3; + +import me.golemcore.plugin.api.extension.spi.PluginBootstrap; +import me.golemcore.plugin.api.extension.spi.PluginDescriptor; + +public class S3PluginBootstrap implements PluginBootstrap { + + @Override + public PluginDescriptor descriptor() { + return PluginDescriptor.builder() + .id("golemcore/s3") + .provider("golemcore") + .name("s3") + .entrypoint(getClass().getName()) + .build(); + } + + @Override + public Class configurationClass() { + return S3PluginConfiguration.class; + } +} diff --git a/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3PluginConfig.java b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3PluginConfig.java new file mode 100644 index 0000000..1915916 --- /dev/null +++ b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3PluginConfig.java @@ -0,0 +1,143 @@ +package me.golemcore.plugins.golemcore.s3; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class S3PluginConfig { + + static final String DEFAULT_ENDPOINT = "https://play.min.io"; + static final String DEFAULT_REGION = "us-east-1"; + static final int DEFAULT_TIMEOUT_MS = 30_000; + static final int DEFAULT_MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024; + static final int DEFAULT_MAX_INLINE_TEXT_CHARS = 12_000; + + @Builder.Default + private Boolean enabled = false; + + @Builder.Default + private String endpoint = DEFAULT_ENDPOINT; + + @Builder.Default + private String region = DEFAULT_REGION; + + private String accessKey; + private String secretKey; + + @Builder.Default + private String bucket = ""; + + @Builder.Default + private String rootPrefix = ""; + + @Builder.Default + private Integer timeoutMs = DEFAULT_TIMEOUT_MS; + + @Builder.Default + private Boolean allowInsecureTls = false; + + @Builder.Default + private Integer maxDownloadBytes = DEFAULT_MAX_DOWNLOAD_BYTES; + + @Builder.Default + private Integer maxInlineTextChars = DEFAULT_MAX_INLINE_TEXT_CHARS; + + @Builder.Default + private Boolean autoCreateBucket = false; + + @Builder.Default + private Boolean allowWrite = false; + + @Builder.Default + private Boolean allowDelete = false; + + @Builder.Default + private Boolean allowMove = false; + + @Builder.Default + private Boolean allowCopy = false; + + public void normalize() { + if (enabled == null) { + enabled = false; + } + endpoint = normalizeEndpoint(endpoint, DEFAULT_ENDPOINT); + region = normalizeText(region, DEFAULT_REGION); + bucket = normalizeText(bucket, ""); + rootPrefix = normalizePath(rootPrefix); + if (timeoutMs == null || timeoutMs <= 0) { + timeoutMs = DEFAULT_TIMEOUT_MS; + } + if (allowInsecureTls == null) { + allowInsecureTls = false; + } + if (maxDownloadBytes == null || maxDownloadBytes <= 0) { + maxDownloadBytes = DEFAULT_MAX_DOWNLOAD_BYTES; + } + if (maxInlineTextChars == null || maxInlineTextChars <= 0) { + maxInlineTextChars = DEFAULT_MAX_INLINE_TEXT_CHARS; + } + if (autoCreateBucket == null) { + autoCreateBucket = false; + } + if (allowWrite == null) { + allowWrite = false; + } + if (allowDelete == null) { + allowDelete = false; + } + if (allowMove == null) { + allowMove = false; + } + if (allowCopy == null) { + allowCopy = false; + } + } + + private String normalizeEndpoint(String value, String defaultValue) { + String trimmed = normalizeText(value, defaultValue); + while (trimmed.endsWith("/")) { + trimmed = trimmed.substring(0, trimmed.length() - 1); + } + return trimmed.isBlank() ? defaultValue : trimmed; + } + + private String normalizePath(String value) { + String candidate = normalizeText(value, "").replace('\\', '/'); + while (candidate.startsWith("/")) { + candidate = candidate.substring(1); + } + while (candidate.endsWith("/")) { + candidate = candidate.substring(0, candidate.length() - 1); + } + String[] rawSegments = candidate.isBlank() ? new String[0] : candidate.split("/"); + StringBuilder normalized = new StringBuilder(); + for (String rawSegment : rawSegments) { + String segment = rawSegment.trim(); + if (segment.isEmpty() || ".".equals(segment)) { + continue; + } + if ("..".equals(segment)) { + throw new IllegalArgumentException("rootPrefix must stay within the configured bucket root"); + } + if (!normalized.isEmpty()) { + normalized.append('/'); + } + normalized.append(segment); + } + return normalized.toString(); + } + + private String normalizeText(String value, String defaultValue) { + if (value == null) { + return defaultValue; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? defaultValue : trimmed; + } +} diff --git a/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3PluginConfigService.java b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3PluginConfigService.java new file mode 100644 index 0000000..30272a0 --- /dev/null +++ b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3PluginConfigService.java @@ -0,0 +1,33 @@ +package me.golemcore.plugins.golemcore.s3; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import me.golemcore.plugin.api.runtime.PluginConfigurationService; +import org.springframework.stereotype.Service; + +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class S3PluginConfigService { + + static final String PLUGIN_ID = "golemcore/s3"; + + private final PluginConfigurationService pluginConfigurationService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public S3PluginConfig getConfig() { + Map raw = pluginConfigurationService.getPluginConfig(PLUGIN_ID); + S3PluginConfig config = raw.isEmpty() + ? S3PluginConfig.builder().build() + : objectMapper.convertValue(raw, S3PluginConfig.class); + config.normalize(); + return config; + } + + @SuppressWarnings("unchecked") + public void save(S3PluginConfig config) { + config.normalize(); + pluginConfigurationService.savePluginConfig(PLUGIN_ID, objectMapper.convertValue(config, Map.class)); + } +} diff --git a/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3PluginConfiguration.java b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3PluginConfiguration.java new file mode 100644 index 0000000..850a17d --- /dev/null +++ b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3PluginConfiguration.java @@ -0,0 +1,9 @@ +package me.golemcore.plugins.golemcore.s3; + +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +@ComponentScan(basePackageClasses = S3PluginConfiguration.class) +public class S3PluginConfiguration { +} diff --git a/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3PluginSettingsContributor.java b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3PluginSettingsContributor.java new file mode 100644 index 0000000..adf10c5 --- /dev/null +++ b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/S3PluginSettingsContributor.java @@ -0,0 +1,253 @@ +package me.golemcore.plugins.golemcore.s3; + +import lombok.RequiredArgsConstructor; +import me.golemcore.plugin.api.extension.spi.PluginActionResult; +import me.golemcore.plugin.api.extension.spi.PluginSettingsAction; +import me.golemcore.plugin.api.extension.spi.PluginSettingsCatalogItem; +import me.golemcore.plugin.api.extension.spi.PluginSettingsContributor; +import me.golemcore.plugin.api.extension.spi.PluginSettingsField; +import me.golemcore.plugin.api.extension.spi.PluginSettingsSection; +import me.golemcore.plugins.golemcore.s3.support.S3MinioClient; +import me.golemcore.plugins.golemcore.s3.support.S3StorageException; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class S3PluginSettingsContributor implements PluginSettingsContributor { + + private static final String SECTION_KEY = "main"; + private static final String ACTION_TEST_CONNECTION = "test-connection"; + + private final S3PluginConfigService configService; + private final S3MinioClient client; + + @Override + public String getPluginId() { + return S3PluginConfigService.PLUGIN_ID; + } + + @Override + public List getCatalogItems() { + return List.of(PluginSettingsCatalogItem.builder() + .pluginId(S3PluginConfigService.PLUGIN_ID) + .pluginName("s3") + .provider("golemcore") + .sectionKey(SECTION_KEY) + .title("S3") + .description("S3-compatible object storage connection, root sandbox, and file policy.") + .blockKey("tools") + .blockTitle("Tools") + .blockDescription("Tool-specific runtime behavior and integrations") + .order(39) + .build()); + } + + @Override + public PluginSettingsSection getSection(String sectionKey) { + requireSection(sectionKey); + S3PluginConfig config = configService.getConfig(); + Map values = new LinkedHashMap<>(); + values.put("enabled", Boolean.TRUE.equals(config.getEnabled())); + values.put("endpoint", config.getEndpoint()); + values.put("region", config.getRegion()); + values.put("accessKey", config.getAccessKey()); + values.put("secretKey", ""); + values.put("bucket", config.getBucket()); + values.put("rootPrefix", config.getRootPrefix().isBlank() ? "/" : "/" + config.getRootPrefix()); + values.put("timeoutMs", config.getTimeoutMs()); + values.put("allowInsecureTls", Boolean.TRUE.equals(config.getAllowInsecureTls())); + values.put("maxDownloadBytes", config.getMaxDownloadBytes()); + values.put("maxInlineTextChars", config.getMaxInlineTextChars()); + values.put("autoCreateBucket", Boolean.TRUE.equals(config.getAutoCreateBucket())); + values.put("allowWrite", Boolean.TRUE.equals(config.getAllowWrite())); + values.put("allowDelete", Boolean.TRUE.equals(config.getAllowDelete())); + values.put("allowMove", Boolean.TRUE.equals(config.getAllowMove())); + values.put("allowCopy", Boolean.TRUE.equals(config.getAllowCopy())); + + return PluginSettingsSection.builder() + .title("S3") + .description( + "Configure the S3-compatible object storage connection, root prefix sandbox, and conservative file policy.") + .fields(List.of( + booleanField("enabled", "Enable S3", "Allow tools to use the S3 integration."), + textField("endpoint", "Endpoint", "S3-compatible HTTPS or HTTP endpoint.", + "https://play.min.io"), + textField("region", "Region", "Default region passed to the MinIO client.", "us-east-1"), + textField("accessKey", "Access Key", "Access key ID used for S3 authentication.", + "minioadmin"), + PluginSettingsField.builder() + .key("secretKey") + .type("secret") + .label("Secret Key") + .description("Leave blank to keep the current secret.") + .placeholder("Enter secret key") + .build(), + textField("bucket", "Bucket", "Bucket used as the tool root namespace.", "my-bucket"), + textField("rootPrefix", "Root Prefix", "Sandbox all tool paths under this prefix.", "/AI"), + numberField("timeoutMs", "Request Timeout (ms)", "Timeout for MinIO S3 requests.", + 1000.0, 300000.0, 1000.0), + booleanField("allowInsecureTls", "Allow Insecure TLS", + "Allow self-signed TLS certificates when connecting to S3."), + numberField("maxDownloadBytes", "Max Download Bytes", + "Maximum object size that read_file downloads into memory.", 1.0, null, 1024.0), + numberField("maxInlineTextChars", "Max Inline Text Chars", + "Maximum text characters returned inline before attachment fallback.", 1.0, null, 1.0), + booleanField("autoCreateBucket", "Auto Create Bucket", + "Create the configured bucket automatically if it does not exist."), + booleanField("allowWrite", "Allow Write", + "Permit tools to create directories or write objects."), + booleanField("allowDelete", "Allow Delete", + "Permit tools to delete objects or prefixes."), + booleanField("allowMove", "Allow Move", + "Permit tools to move objects or prefixes."), + booleanField("allowCopy", "Allow Copy", + "Permit tools to copy objects or prefixes."))) + .values(values) + .actions(List.of(PluginSettingsAction.builder() + .actionId(ACTION_TEST_CONNECTION) + .label("Test Connection") + .variant("secondary") + .build())) + .build(); + } + + @Override + public PluginSettingsSection saveSection(String sectionKey, Map values) { + requireSection(sectionKey); + S3PluginConfig config = configService.getConfig(); + config.setEnabled(readBoolean(values, "enabled", false)); + config.setEndpoint(readString(values, "endpoint", config.getEndpoint())); + config.setRegion(readString(values, "region", config.getRegion())); + config.setAccessKey(readString(values, "accessKey", config.getAccessKey())); + String secretKey = readString(values, "secretKey", null); + if (secretKey != null && !secretKey.isBlank()) { + config.setSecretKey(secretKey); + } + config.setBucket(readString(values, "bucket", config.getBucket())); + config.setRootPrefix(readString(values, "rootPrefix", config.getRootPrefix())); + config.setTimeoutMs(readInteger(values, "timeoutMs", config.getTimeoutMs())); + config.setAllowInsecureTls(readBoolean(values, "allowInsecureTls", false)); + config.setMaxDownloadBytes(readInteger(values, "maxDownloadBytes", config.getMaxDownloadBytes())); + config.setMaxInlineTextChars(readInteger(values, "maxInlineTextChars", config.getMaxInlineTextChars())); + config.setAutoCreateBucket(readBoolean(values, "autoCreateBucket", false)); + config.setAllowWrite(readBoolean(values, "allowWrite", false)); + config.setAllowDelete(readBoolean(values, "allowDelete", false)); + config.setAllowMove(readBoolean(values, "allowMove", false)); + config.setAllowCopy(readBoolean(values, "allowCopy", false)); + configService.save(config); + return getSection(sectionKey); + } + + @Override + public PluginActionResult executeAction(String sectionKey, String actionId, Map payload) { + requireSection(sectionKey); + if (!ACTION_TEST_CONNECTION.equals(actionId)) { + throw new IllegalArgumentException("Unknown S3 action: " + actionId); + } + return testConnection(); + } + + private PluginActionResult testConnection() { + S3PluginConfig config = configService.getConfig(); + if (!hasText(config.getEndpoint()) || !hasText(config.getAccessKey()) || !hasText(config.getSecretKey()) + || !hasText(config.getBucket())) { + return PluginActionResult.builder() + .status("error") + .message("S3 endpoint, access key, secret key, and bucket must be configured.") + .build(); + } + try { + client.ensureBucketReady(); + int count = client.listDirectory("").size(); + return PluginActionResult.builder() + .status("ok") + .message("Connected to S3 bucket " + config.getBucket() + ". Root returned " + count + + " item(s).") + .build(); + } catch (IllegalArgumentException | IllegalStateException | S3StorageException ex) { + return PluginActionResult.builder() + .status("error") + .message("Connection failed: " + ex.getMessage()) + .build(); + } + } + + private void requireSection(String sectionKey) { + if (!SECTION_KEY.equals(sectionKey)) { + throw new IllegalArgumentException("Unknown S3 settings section: " + sectionKey); + } + } + + private PluginSettingsField booleanField(String key, String label, String description) { + return PluginSettingsField.builder() + .key(key) + .type("boolean") + .label(label) + .description(description) + .build(); + } + + private PluginSettingsField textField(String key, String label, String description, String placeholder) { + return PluginSettingsField.builder() + .key(key) + .type("text") + .label(label) + .description(description) + .placeholder(placeholder) + .build(); + } + + private PluginSettingsField numberField( + String key, + String label, + String description, + Double min, + Double max, + Double step) { + return PluginSettingsField.builder() + .key(key) + .type("number") + .label(label) + .description(description) + .min(min) + .max(max) + .step(step) + .build(); + } + + private boolean readBoolean(Map values, String key, boolean defaultValue) { + Object value = values.get(key); + return value instanceof Boolean bool ? bool : defaultValue; + } + + private int readInteger(Map values, String key, int defaultValue) { + Object value = values.get(key); + if (value instanceof Number number) { + return number.intValue(); + } + if (value instanceof String text && !text.isBlank()) { + try { + return Integer.parseInt(text); + } catch (NumberFormatException ignored) { + return defaultValue; + } + } + return defaultValue; + } + + private String readString(Map values, String key, String defaultValue) { + Object value = values.get(key); + if (value instanceof String text) { + return text; + } + return defaultValue; + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3MimeTypes.java b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3MimeTypes.java new file mode 100644 index 0000000..27a5ced --- /dev/null +++ b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3MimeTypes.java @@ -0,0 +1,66 @@ +package me.golemcore.plugins.golemcore.s3.support; + +import java.util.Locale; + +public final class S3MimeTypes { + + private S3MimeTypes() { + } + + public static String detect(String path) { + String lowerPath = path == null ? "" : path.toLowerCase(Locale.ROOT); + if (lowerPath.endsWith(".txt") || lowerPath.endsWith(".log") || lowerPath.endsWith(".ini") + || lowerPath.endsWith(".toml") || lowerPath.endsWith(".properties")) { + return "text/plain"; + } + if (lowerPath.endsWith(".md") || lowerPath.endsWith(".markdown")) { + return "text/markdown"; + } + if (lowerPath.endsWith(".json")) { + return "application/json"; + } + if (lowerPath.endsWith(".xml")) { + return "application/xml"; + } + if (lowerPath.endsWith(".yaml") || lowerPath.endsWith(".yml")) { + return "application/yaml"; + } + if (lowerPath.endsWith(".csv")) { + return "text/csv"; + } + if (lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")) { + return "text/html"; + } + if (lowerPath.endsWith(".css")) { + return "text/css"; + } + if (lowerPath.endsWith(".js")) { + return "application/javascript"; + } + if (lowerPath.endsWith(".ts")) { + return "text/typescript"; + } + if (lowerPath.endsWith(".svg")) { + return "image/svg+xml"; + } + if (lowerPath.endsWith(".png")) { + return "image/png"; + } + if (lowerPath.endsWith(".jpg") || lowerPath.endsWith(".jpeg")) { + return "image/jpeg"; + } + if (lowerPath.endsWith(".gif")) { + return "image/gif"; + } + if (lowerPath.endsWith(".webp")) { + return "image/webp"; + } + if (lowerPath.endsWith(".pdf")) { + return "application/pdf"; + } + if (lowerPath.endsWith(".zip")) { + return "application/zip"; + } + return "application/octet-stream"; + } +} diff --git a/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3MinioClient.java b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3MinioClient.java new file mode 100644 index 0000000..fbc76e2 --- /dev/null +++ b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3MinioClient.java @@ -0,0 +1,508 @@ +package me.golemcore.plugins.golemcore.s3.support; + +import io.minio.BucketExistsArgs; +import io.minio.CopyObjectArgs; +import io.minio.GetObjectArgs; +import io.minio.GetObjectResponse; +import io.minio.ListObjectsArgs; +import io.minio.MakeBucketArgs; +import io.minio.MinioClient; +import io.minio.PutObjectArgs; +import io.minio.RemoveObjectArgs; +import io.minio.Result; +import io.minio.SourceObject; +import io.minio.StatObjectArgs; +import io.minio.StatObjectResponse; +import io.minio.errors.ErrorResponseException; +import io.minio.errors.MinioException; +import io.minio.messages.Item; +import me.golemcore.plugins.golemcore.s3.S3PluginConfig; +import me.golemcore.plugins.golemcore.s3.S3PluginConfigService; +import okhttp3.OkHttpClient; +import org.springframework.stereotype.Component; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +@Component +public class S3MinioClient { + + private static final String DIRECTORY_CONTENT_TYPE = "application/x-directory"; + + private final S3PluginConfigService configService; + + public S3MinioClient(S3PluginConfigService configService) { + this.configService = configService; + } + + public void ensureBucketReady() { + S3PluginConfig config = getConfig(); + requireText(config.getEndpoint(), "S3 endpoint is not configured."); + requireText(config.getAccessKey(), "S3 access key is not configured."); + requireText(config.getSecretKey(), "S3 secret key is not configured."); + requireText(config.getBucket(), "S3 bucket is not configured."); + + if (bucketExistsInternal(config.getBucket())) { + return; + } + if (!Boolean.TRUE.equals(config.getAutoCreateBucket())) { + throw new IllegalArgumentException("S3 bucket does not exist: " + config.getBucket()); + } + makeBucketInternal(config.getBucket(), config.getRegion()); + } + + public S3ObjectInfo findObject(String relativePath) { + ensureBucketReady(); + String resolvedKey = resolveKey(relativePath); + if (resolvedKey.isBlank()) { + return null; + } + return findObjectByResolvedKey(resolvedKey, false); + } + + public S3ObjectInfo findDirectoryMarker(String relativePath) { + ensureBucketReady(); + String normalizedPath = normalizeRelativePath(relativePath); + if (normalizedPath.isBlank()) { + return null; + } + String markerKey = resolveDirectoryMarkerKey(normalizedPath); + return findObjectByResolvedKey(markerKey, true); + } + + public boolean directoryExists(String relativePath) { + ensureBucketReady(); + String normalizedPath = normalizeRelativePath(relativePath); + if (normalizedPath.isBlank()) { + return true; + } + if (findDirectoryMarker(normalizedPath) != null) { + return true; + } + return !listDirectory(normalizedPath).isEmpty(); + } + + public List listDirectory(String relativePath) { + ensureBucketReady(); + String normalizedPath = normalizeRelativePath(relativePath); + String prefix = resolveListPrefix(normalizedPath); + List rawEntries = listObjectsInternal(getBucket(), prefix, false); + List entries = new ArrayList<>(); + for (S3ObjectInfo rawEntry : rawEntries) { + S3ObjectInfo mapped = toRelativeInfo(rawEntry, rawEntry.directory()); + if (mapped == null) { + continue; + } + if (!normalizedPath.isBlank() && normalizedPath.equals(mapped.key())) { + continue; + } + if (entries.stream().noneMatch(existing -> existing.key().equals(mapped.key()))) { + entries.add(mapped); + } + } + return List.copyOf(entries); + } + + public List listTree(String relativePath) { + ensureBucketReady(); + String normalizedPath = normalizeRelativePath(relativePath); + String prefix = resolveListPrefix(normalizedPath); + List rawEntries = listObjectsInternal(getBucket(), prefix, true); + List entries = new ArrayList<>(); + for (S3ObjectInfo rawEntry : rawEntries) { + S3ObjectInfo mapped = toRelativeInfo(rawEntry, rawEntry.directory()); + if (mapped != null) { + entries.add(mapped); + } + } + return List.copyOf(entries); + } + + public S3ObjectContent readObject(String relativePath) { + ensureBucketReady(); + String resolvedKey = resolveKey(relativePath); + S3ObjectInfo metadata = requireObjectByResolvedKey(resolvedKey, false); + return getObjectInternal(getBucket(), resolvedKey, metadata); + } + + public void writeObject(String relativePath, byte[] bytes, String contentType) { + ensureBucketReady(); + String resolvedKey = resolveKey(relativePath); + putObjectInternal(getBucket(), resolvedKey, bytes, contentType); + } + + public void createDirectoryMarker(String relativePath) { + ensureBucketReady(); + String resolvedKey = resolveDirectoryMarkerKey(relativePath); + putObjectInternal(getBucket(), resolvedKey, new byte[0], DIRECTORY_CONTENT_TYPE); + } + + public void copyObject(String sourceRelativePath, String targetRelativePath) { + ensureBucketReady(); + String sourceKey = resolveKey(sourceRelativePath); + String targetKey = resolveKey(targetRelativePath); + S3ObjectInfo sourceInfo = requireObjectByResolvedKey(sourceKey, false); + copyObjectInternal(getBucket(), sourceKey, targetKey, sourceInfo); + } + + public void deleteObject(String relativePath) { + ensureBucketReady(); + deleteObjectByResolvedKey(resolveKey(relativePath)); + } + + public void deleteDirectoryMarker(String relativePath) { + ensureBucketReady(); + deleteObjectByResolvedKey(resolveDirectoryMarkerKey(relativePath)); + } + + protected boolean bucketExistsInternal(String bucket) { + try (MinioClient client = createClient()) { + return client.bucketExists(BucketExistsArgs.builder().bucket(bucket).build()); + } catch (Exception ex) { + throw toStorageException("bucket existence check failed", ex); + } + } + + protected void makeBucketInternal(String bucket, String region) { + try (MinioClient client = createClient()) { + client.makeBucket(MakeBucketArgs.builder().bucket(bucket).region(region).build()); + } catch (Exception ex) { + throw toStorageException("bucket creation failed", ex); + } + } + + protected List listObjectsInternal(String bucket, String prefix, boolean recursive) { + try (MinioClient client = createClient()) { + Iterable> results = client.listObjects(ListObjectsArgs.builder() + .bucket(bucket) + .prefix(prefix) + .recursive(recursive) + .build()); + List objects = new ArrayList<>(); + for (Result result : results) { + Item item = result.get(); + objects.add(new S3ObjectInfo( + bucket, + item.objectName(), + rawName(item.objectName()), + item.isDir(), + item.isDir() ? null : item.size(), + item.isDir() ? null : item.etag(), + null, + item.lastModified())); + } + return List.copyOf(objects); + } catch (Exception ex) { + throw toStorageException("object listing failed", ex); + } + } + + protected S3ObjectInfo statObjectInternal(String bucket, String key) { + try (MinioClient client = createClient()) { + StatObjectResponse response = client.statObject(StatObjectArgs.builder() + .bucket(bucket) + .object(key) + .build()); + return new S3ObjectInfo( + bucket, + key, + rawName(key), + false, + response.size(), + response.etag(), + response.contentType(), + response.lastModified()); + } catch (Exception ex) { + throw toStorageException("object stat failed", ex); + } + } + + protected S3ObjectContent getObjectInternal(String bucket, String key, S3ObjectInfo metadata) { + try (MinioClient client = createClient(); + GetObjectResponse response = client.getObject(GetObjectArgs.builder() + .bucket(bucket) + .object(key) + .build())) { + return new S3ObjectContent( + bucket, + key, + response.readAllBytes(), + metadata.eTag(), + metadata.contentType(), + metadata.lastModified()); + } catch (Exception ex) { + throw toStorageException("object read failed", ex); + } + } + + protected void putObjectInternal(String bucket, String key, byte[] bytes, String contentType) { + try (MinioClient client = createClient()) { + PutObjectArgs.Builder builder = PutObjectArgs.builder() + .bucket(bucket) + .object(key) + .data(bytes, bytes.length); + if (contentType != null && !contentType.isBlank()) { + builder.contentType(contentType); + } + client.putObject(builder.build()); + } catch (Exception ex) { + throw toStorageException("object write failed", ex); + } + } + + protected void copyObjectInternal(String bucket, String sourceKey, String targetKey, S3ObjectInfo sourceInfo) { + try (MinioClient client = createClient()) { + SourceObject source = new SourceObject( + SourceObject.builder().bucket(bucket).object(sourceKey).build(), + sourceInfo.size() != null ? sourceInfo.size() : 0L, + sourceInfo.eTag()); + client.copyObject(CopyObjectArgs.builder() + .bucket(bucket) + .object(targetKey) + .source(source) + .build()); + } catch (Exception ex) { + throw toStorageException("object copy failed", ex); + } + } + + protected void removeObjectInternal(String bucket, String key) { + try (MinioClient client = createClient()) { + client.removeObject(RemoveObjectArgs.builder().bucket(bucket).object(key).build()); + } catch (Exception ex) { + throw toStorageException("object delete failed", ex); + } + } + + private MinioClient createClient() { + S3PluginConfig config = getConfig(); + OkHttpClient httpClient = buildHttpClient(config); + return MinioClient.builder() + .endpoint(config.getEndpoint()) + .region(config.getRegion()) + .credentials(config.getAccessKey(), config.getSecretKey()) + .httpClient(httpClient, true) + .build(); + } + + private OkHttpClient buildHttpClient(S3PluginConfig config) { + OkHttpClient.Builder builder = new OkHttpClient.Builder() + .callTimeout(Duration.ofMillis(config.getTimeoutMs())) + .connectTimeout(Duration.ofMillis(config.getTimeoutMs())) + .readTimeout(Duration.ofMillis(config.getTimeoutMs())) + .writeTimeout(Duration.ofMillis(config.getTimeoutMs())); + if (Boolean.TRUE.equals(config.getAllowInsecureTls()) && config.getEndpoint().startsWith("https://")) { + try { + TrustManager[] trustManagers = new TrustManager[] { new X509TrustManager() { + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) { + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) { + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + } }; + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, trustManagers, new SecureRandom()); + SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory(); + X509TrustManager trustManager = (X509TrustManager) trustManagers[0]; + HostnameVerifier hostnameVerifier = (hostname, session) -> true; + builder.sslSocketFactory(sslSocketFactory, trustManager); + builder.hostnameVerifier(hostnameVerifier); + } catch (GeneralSecurityException ex) { + throw new IllegalStateException("Unable to configure insecure TLS for S3", ex); + } + } + return builder.build(); + } + + private S3ObjectInfo findObjectByResolvedKey(String resolvedKey, boolean directory) { + try { + S3ObjectInfo rawInfo = statObjectInternal(getBucket(), resolvedKey); + return toRelativeInfo(rawInfo, directory); + } catch (S3StorageException ex) { + if (isNotFound(ex)) { + return null; + } + throw ex; + } + } + + private S3ObjectInfo requireObjectByResolvedKey(String resolvedKey, boolean directory) { + S3ObjectInfo info = findObjectByResolvedKey(resolvedKey, directory); + if (info == null) { + throw new IllegalArgumentException("Object does not exist: " + stripRootPrefix(resolvedKey, directory)); + } + return info; + } + + private void deleteObjectByResolvedKey(String resolvedKey) { + try { + removeObjectInternal(getBucket(), resolvedKey); + } catch (S3StorageException ex) { + if (isNotFound(ex)) { + throw new IllegalArgumentException( + "Object does not exist: " + stripRootPrefix(resolvedKey, resolvedKey.endsWith("/"))); + } + throw ex; + } + } + + private S3ObjectInfo toRelativeInfo(S3ObjectInfo rawInfo, boolean forceDirectory) { + if (rawInfo == null) { + return null; + } + boolean directory = forceDirectory || rawInfo.directory(); + String relativeKey = stripRootPrefix(rawInfo.key(), directory); + if (relativeKey == null) { + return null; + } + if (relativeKey.isBlank() && !directory) { + return null; + } + String name = relativeKey.isBlank() ? "/" : rawName(relativeKey); + return new S3ObjectInfo( + rawInfo.bucket(), + relativeKey, + name, + directory, + directory ? null : rawInfo.size(), + rawInfo.eTag(), + rawInfo.contentType(), + rawInfo.lastModified()); + } + + private boolean isNotFound(S3StorageException ex) { + Throwable cause = ex.getCause(); + if (cause instanceof ErrorResponseException errorResponseException) { + String code = errorResponseException.errorResponse().code(); + return Objects.equals(code, "NoSuchKey") + || Objects.equals(code, "NoSuchBucket") + || Objects.equals(code, "NoSuchObject") + || Objects.equals(code, "NotFound"); + } + String message = ex.getMessage(); + return message != null + && (message.contains("NoSuchKey") + || message.contains("NoSuchBucket") + || message.contains("NoSuchObject") + || message.contains("NotFound")); + } + + private S3StorageException toStorageException(String operation, Exception ex) { + if (ex instanceof S3StorageException storageException) { + return storageException; + } + if (ex instanceof ErrorResponseException errorResponseException) { + String code = errorResponseException.errorResponse().code(); + String message = errorResponseException.errorResponse().message(); + return new S3StorageException("S3 " + operation + " failed: " + code + + (message != null && !message.isBlank() ? " - " + message : ""), ex); + } + if (ex instanceof MinioException) { + return new S3StorageException("S3 " + operation + " failed: " + ex.getMessage(), ex); + } + if (ex instanceof IOException) { + return new S3StorageException("S3 transport failed: " + ex.getMessage(), ex); + } + if (ex instanceof RuntimeException runtimeException) { + return new S3StorageException("S3 " + operation + " failed: " + runtimeException.getMessage(), ex); + } + return new S3StorageException("S3 " + operation + " failed", ex); + } + + private String resolveKey(String relativePath) { + String normalized = normalizeRelativePath(relativePath); + String rootPrefix = getConfig().getRootPrefix(); + if (rootPrefix.isBlank()) { + return normalized; + } + if (normalized.isBlank()) { + return rootPrefix; + } + return rootPrefix + "/" + normalized; + } + + private String resolveDirectoryMarkerKey(String relativePath) { + String normalized = normalizeRelativePath(relativePath); + String baseKey = resolveKey(normalized); + if (baseKey.isBlank()) { + return ""; + } + return baseKey.endsWith("/") ? baseKey : baseKey + "/"; + } + + private String resolveListPrefix(String relativePath) { + String normalized = normalizeRelativePath(relativePath); + if (normalized.isBlank()) { + String rootPrefix = getConfig().getRootPrefix(); + return rootPrefix.isBlank() ? "" : rootPrefix + "/"; + } + return resolveDirectoryMarkerKey(normalized); + } + + private String stripRootPrefix(String rawKey, boolean directory) { + String rootPrefix = getConfig().getRootPrefix(); + String normalizedKey = rawKey; + if (directory && normalizedKey.endsWith("/")) { + normalizedKey = normalizedKey.substring(0, normalizedKey.length() - 1); + } + if (rootPrefix.isBlank()) { + return normalizedKey; + } + if (normalizedKey.equals(rootPrefix)) { + return ""; + } + String prefix = rootPrefix + "/"; + if (!normalizedKey.startsWith(prefix)) { + return null; + } + return normalizedKey.substring(prefix.length()); + } + + private String normalizeRelativePath(String relativePath) { + if (relativePath == null) { + return ""; + } + return relativePath; + } + + private String rawName(String key) { + String normalized = key; + if (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + int separatorIndex = normalized.lastIndexOf('/'); + return separatorIndex >= 0 ? normalized.substring(separatorIndex + 1) : normalized; + } + + private void requireText(String value, String message) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(message); + } + } + + private String getBucket() { + return getConfig().getBucket(); + } + + private S3PluginConfig getConfig() { + return configService.getConfig(); + } +} diff --git a/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3ObjectContent.java b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3ObjectContent.java new file mode 100644 index 0000000..c91f44a --- /dev/null +++ b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3ObjectContent.java @@ -0,0 +1,5 @@ +package me.golemcore.plugins.golemcore.s3.support; + +import java.time.ZonedDateTime; + +public record S3ObjectContent(String bucket,String key,byte[]bytes,String eTag,String contentType,ZonedDateTime lastModified){} diff --git a/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3ObjectInfo.java b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3ObjectInfo.java new file mode 100644 index 0000000..8d272d0 --- /dev/null +++ b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3ObjectInfo.java @@ -0,0 +1,5 @@ +package me.golemcore.plugins.golemcore.s3.support; + +import java.time.ZonedDateTime; + +public record S3ObjectInfo(String bucket,String key,String name,boolean directory,Long size,String eTag,String contentType,ZonedDateTime lastModified){} diff --git a/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3PathValidator.java b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3PathValidator.java new file mode 100644 index 0000000..c979579 --- /dev/null +++ b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3PathValidator.java @@ -0,0 +1,51 @@ +package me.golemcore.plugins.golemcore.s3.support; + +import java.util.ArrayDeque; +import java.util.Deque; + +public final class S3PathValidator { + + public String normalizeOptionalPath(String path) { + if (path == null || path.isBlank()) { + return ""; + } + String candidate = path.trim().replace('\\', '/'); + while (candidate.startsWith("/")) { + candidate = candidate.substring(1); + } + while (candidate.endsWith("/")) { + candidate = candidate.substring(0, candidate.length() - 1); + } + + Deque segments = new ArrayDeque<>(); + for (String rawSegment : candidate.split("/")) { + String segment = rawSegment.trim(); + if (segment.isEmpty() || ".".equals(segment)) { + continue; + } + if ("..".equals(segment)) { + if (segments.isEmpty()) { + throw new IllegalArgumentException("Path must stay within the configured S3 root prefix"); + } + segments.removeLast(); + continue; + } + segments.addLast(segment); + } + return String.join("/", segments); + } + + public String normalizeRequiredPath(String path) { + String normalized = normalizeOptionalPath(path); + if (normalized.isBlank()) { + throw new IllegalArgumentException("Path is required"); + } + return normalized; + } + + public String parentPath(String normalizedPath) { + String normalized = normalizeOptionalPath(normalizedPath); + int separatorIndex = normalized.lastIndexOf('/'); + return separatorIndex >= 0 ? normalized.substring(0, separatorIndex) : ""; + } +} diff --git a/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3StorageException.java b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3StorageException.java new file mode 100644 index 0000000..856ffee --- /dev/null +++ b/golemcore/s3/src/main/java/me/golemcore/plugins/golemcore/s3/support/S3StorageException.java @@ -0,0 +1,14 @@ +package me.golemcore.plugins.golemcore.s3.support; + +public class S3StorageException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public S3StorageException(String message, Throwable cause) { + super(message, cause); + } + + public S3StorageException(String message) { + super(message); + } +} diff --git a/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3FilesServiceTest.java b/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3FilesServiceTest.java new file mode 100644 index 0000000..e351567 --- /dev/null +++ b/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3FilesServiceTest.java @@ -0,0 +1,224 @@ +package me.golemcore.plugins.golemcore.s3; + +import me.golemcore.plugin.api.extension.model.Attachment; +import me.golemcore.plugin.api.extension.model.ToolFailureKind; +import me.golemcore.plugin.api.extension.model.ToolResult; +import me.golemcore.plugins.golemcore.s3.support.S3MinioClient; +import me.golemcore.plugins.golemcore.s3.support.S3ObjectContent; +import me.golemcore.plugins.golemcore.s3.support.S3ObjectInfo; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class S3FilesServiceTest { + + private S3PluginConfigService configService; + private S3MinioClient client; + private S3FilesService service; + + @BeforeEach + void setUp() { + configService = mock(S3PluginConfigService.class); + client = mock(S3MinioClient.class); + when(configService.getConfig()).thenReturn(defaultConfig()); + service = new S3FilesService(client, configService); + } + + @Test + void shouldListRootDirectory() { + when(client.listDirectory("")).thenReturn(List.of( + new S3ObjectInfo("files", "docs", "docs", true, null, null, null, null), + new S3ObjectInfo("files", "notes.md", "notes.md", false, 123L, "etag", "text/markdown", null))); + + ToolResult result = service.listDirectory(""); + + assertTrue(result.isSuccess()); + assertTrue(result.getOutput().contains("Directory: /")); + assertTrue(result.getOutput().contains("[DIR] docs/")); + assertTrue(result.getOutput().contains("[FILE] notes.md")); + } + + @Test + void shouldReturnInlineTextWhenReadFileIsText() { + when(client.findObject("docs/readme.md")).thenReturn(file("docs/readme.md", 5L, "text/plain")); + when(client.readObject("docs/readme.md")).thenReturn(new S3ObjectContent( + "files", + "docs/readme.md", + "hello".getBytes(), + "etag", + "text/plain; charset=utf-8", + ZonedDateTime.parse("2026-04-08T12:00:00Z"))); + + ToolResult result = service.readFile("docs/readme.md"); + + assertTrue(result.isSuccess()); + assertEquals("hello", result.getOutput()); + Map data = assertInstanceOf(Map.class, result.getData()); + assertEquals("docs/readme.md", data.get("path")); + assertEquals(true, data.get("is_text")); + assertEquals(false, data.get("truncated")); + } + + @Test + void shouldAttachBinaryObjectsOnRead() { + when(client.findObject("docs/archive.zip")).thenReturn(file("docs/archive.zip", 3L, "application/zip")); + when(client.readObject("docs/archive.zip")).thenReturn(new S3ObjectContent( + "files", + "docs/archive.zip", + new byte[] { 1, 2, 3 }, + "etag", + "application/zip", + null)); + + ToolResult result = service.readFile("docs/archive.zip"); + + assertTrue(result.isSuccess()); + Map data = assertInstanceOf(Map.class, result.getData()); + Attachment attachment = assertInstanceOf(Attachment.class, data.get("attachment")); + assertEquals("archive.zip", attachment.getFilename()); + assertEquals("application/zip", attachment.getMimeType()); + } + + @Test + void shouldWriteBase64BinaryContent() { + ToolResult result = service.writeFile("docs/file.bin", null, "AQID", false); + + assertTrue(result.isSuccess()); + verify(client).writeObject("docs/file.bin", new byte[] { 1, 2, 3 }, "application/octet-stream"); + } + + @Test + void shouldAppendExistingObjectContent() { + when(client.findObject("docs/file.txt")).thenReturn(file("docs/file.txt", 3L, "text/plain")); + when(client.readObject("docs/file.txt")).thenReturn(new S3ObjectContent( + "files", + "docs/file.txt", + "abc".getBytes(), + "etag", + "text/plain", + null)); + + ToolResult result = service.writeFile("docs/file.txt", "def", null, true); + + assertTrue(result.isSuccess()); + verify(client).writeObject("docs/file.txt", "abcdef".getBytes(), "text/plain"); + } + + @Test + void shouldCreateDirectoryMarkers() { + when(client.findObject("docs/archive")).thenReturn(null); + when(client.directoryExists("docs/archive")).thenReturn(false); + + ToolResult result = service.createDirectory("docs/archive"); + + assertTrue(result.isSuccess()); + verify(client).createDirectoryMarker("docs/archive"); + } + + @Test + void shouldDeleteObjects() { + when(client.findObject("docs/file.txt")).thenReturn(file("docs/file.txt", 1L, "text/plain")); + + ToolResult result = service.delete("docs/file.txt"); + + assertTrue(result.isSuccess()); + verify(client).deleteObject("docs/file.txt"); + } + + @Test + void shouldDeleteDirectoriesRecursively() { + when(client.findObject("docs/archive")).thenReturn(null); + when(client.directoryExists("docs/archive")).thenReturn(true); + when(client.findDirectoryMarker("docs/archive")).thenReturn(new S3ObjectInfo( + "files", + "docs/archive", + "archive", + true, + null, + null, + null, + null)); + when(client.listTree("docs/archive")).thenReturn(List.of( + new S3ObjectInfo("files", "docs/archive/file.txt", "file.txt", false, 1L, "etag", "text/plain", null))); + + ToolResult result = service.delete("docs/archive"); + + assertTrue(result.isSuccess()); + verify(client).deleteObject("docs/archive/file.txt"); + verify(client).deleteDirectoryMarker("docs/archive"); + } + + @Test + void shouldMoveObjects() { + when(client.findObject("docs/file.txt")).thenReturn(file("docs/file.txt", 1L, "text/plain")); + when(client.findObject("archive/file.txt")).thenReturn(null); + when(client.directoryExists("archive/file.txt")).thenReturn(false); + + ToolResult result = service.move("docs/file.txt", "archive/file.txt"); + + assertTrue(result.isSuccess()); + verify(client).copyObject("docs/file.txt", "archive/file.txt"); + verify(client).deleteObject("docs/file.txt"); + } + + @Test + void shouldCopyObjects() { + when(client.findObject("docs/file.txt")).thenReturn(file("docs/file.txt", 1L, "text/plain")); + when(client.findObject("archive/file.txt")).thenReturn(null); + when(client.directoryExists("archive/file.txt")).thenReturn(false); + + ToolResult result = service.copy("docs/file.txt", "archive/file.txt"); + + assertTrue(result.isSuccess()); + verify(client).copyObject("docs/file.txt", "archive/file.txt"); + } + + @Test + void shouldDenyWriteWhenDisabled() { + when(configService.getConfig()).thenReturn(S3PluginConfig.builder() + .enabled(true) + .endpoint("https://storage.example.com") + .accessKey("minio") + .secretKey("secret") + .bucket("files") + .allowWrite(false) + .build()); + + ToolResult result = service.writeFile("docs/file.txt", "hello", null, false); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.POLICY_DENIED, result.getFailureKind()); + } + + private S3ObjectInfo file(String key, Long size, String contentType) { + return new S3ObjectInfo("files", key, key.substring(key.lastIndexOf('/') + 1), false, size, "etag", contentType, + ZonedDateTime.parse("2026-04-08T12:00:00Z")); + } + + private S3PluginConfig defaultConfig() { + return S3PluginConfig.builder() + .enabled(true) + .endpoint("https://storage.example.com") + .accessKey("minio") + .secretKey("secret") + .bucket("files") + .allowWrite(true) + .allowDelete(true) + .allowMove(true) + .allowCopy(true) + .maxDownloadBytes(1024) + .maxInlineTextChars(100) + .build(); + } +} diff --git a/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3FilesToolProviderTest.java b/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3FilesToolProviderTest.java new file mode 100644 index 0000000..f31b6d8 --- /dev/null +++ b/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3FilesToolProviderTest.java @@ -0,0 +1,111 @@ +package me.golemcore.plugins.golemcore.s3; + +import me.golemcore.plugin.api.extension.model.ToolDefinition; +import me.golemcore.plugin.api.extension.model.ToolFailureKind; +import me.golemcore.plugin.api.extension.model.ToolResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class S3FilesToolProviderTest { + + private S3FilesService service; + private S3FilesToolProvider provider; + + @BeforeEach + void setUp() { + service = mock(S3FilesService.class); + provider = new S3FilesToolProvider(service); + } + + @Test + void shouldExposeAllSupportedOperations() { + ToolDefinition definition = provider.getDefinition(); + + Map schema = definition.getInputSchema(); + Map properties = (Map) schema.get("properties"); + Map operation = (Map) properties.get("operation"); + + assertEquals("s3_files", definition.getName()); + assertEquals(List.of( + "list_directory", + "read_file", + "write_file", + "create_directory", + "delete", + "move", + "copy", + "file_info"), operation.get("enum")); + } + + @Test + void shouldBeEnabledWhenConfigHasCredentialsAndBucket() { + when(service.getConfig()).thenReturn(S3PluginConfig.builder() + .enabled(true) + .endpoint("https://storage.example.com") + .accessKey("minio") + .secretKey("secret") + .bucket("files") + .build()); + + boolean enabled = provider.isEnabled(); + + assertTrue(enabled); + } + + @Test + void shouldRejectMissingOperation() { + ToolResult result = provider.execute(Map.of()).join(); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind()); + assertTrue(result.getError().contains("operation is required")); + } + + @Test + void shouldDispatchWriteToService() { + when(service.writeFile("docs/file.bin", null, "AQID", true)) + .thenReturn(ToolResult.success("written")); + + ToolResult result = provider.execute(Map.of( + "operation", "write_file", + "path", "docs/file.bin", + "content_base64", "AQID", + "append", true)).join(); + + assertTrue(result.isSuccess()); + verify(service).writeFile("docs/file.bin", null, "AQID", true); + } + + @Test + void shouldDispatchCopyToService() { + when(service.copy("docs/a.txt", "archive/a.txt")) + .thenReturn(ToolResult.success("copied")); + + ToolResult result = provider.execute(Map.of( + "operation", "copy", + "path", "docs/a.txt", + "target_path", "archive/a.txt")).join(); + + assertTrue(result.isSuccess()); + verify(service).copy("docs/a.txt", "archive/a.txt"); + } + + @Test + void shouldRejectUnsupportedOperation() { + ToolResult result = provider.execute(Map.of("operation", "unknown")).join(); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind()); + assertTrue(result.getError().contains("Unsupported")); + } +} diff --git a/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3PluginBootstrapTest.java b/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3PluginBootstrapTest.java new file mode 100644 index 0000000..cda43b1 --- /dev/null +++ b/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3PluginBootstrapTest.java @@ -0,0 +1,21 @@ +package me.golemcore.plugins.golemcore.s3; + +import me.golemcore.plugin.api.extension.spi.PluginDescriptor; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class S3PluginBootstrapTest { + + @Test + void shouldDescribeS3Plugin() { + S3PluginBootstrap bootstrap = new S3PluginBootstrap(); + + PluginDescriptor descriptor = bootstrap.descriptor(); + + assertEquals("golemcore/s3", descriptor.getId()); + assertEquals("golemcore", descriptor.getProvider()); + assertEquals("s3", descriptor.getName()); + assertEquals(S3PluginConfiguration.class, bootstrap.configurationClass()); + } +} diff --git a/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3PluginConfigTest.java b/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3PluginConfigTest.java new file mode 100644 index 0000000..262359b --- /dev/null +++ b/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3PluginConfigTest.java @@ -0,0 +1,46 @@ +package me.golemcore.plugins.golemcore.s3; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +class S3PluginConfigTest { + + @Test + void shouldNormalizeSafeDefaultsAndRootPrefix() { + S3PluginConfig config = S3PluginConfig.builder() + .enabled(null) + .endpoint(" https://storage.example.com/ ") + .region(" ") + .bucket(" files ") + .rootPrefix(" /AI/Uploads/ ") + .timeoutMs(0) + .allowInsecureTls(null) + .maxDownloadBytes(0) + .maxInlineTextChars(0) + .autoCreateBucket(null) + .allowWrite(null) + .allowDelete(null) + .allowMove(null) + .allowCopy(null) + .build(); + + config.normalize(); + + assertFalse(config.getEnabled()); + assertEquals("https://storage.example.com", config.getEndpoint()); + assertEquals("us-east-1", config.getRegion()); + assertEquals("files", config.getBucket()); + assertEquals("AI/Uploads", config.getRootPrefix()); + assertEquals(30_000, config.getTimeoutMs()); + assertFalse(config.getAllowInsecureTls()); + assertEquals(50 * 1024 * 1024, config.getMaxDownloadBytes()); + assertEquals(12_000, config.getMaxInlineTextChars()); + assertFalse(config.getAutoCreateBucket()); + assertFalse(config.getAllowWrite()); + assertFalse(config.getAllowDelete()); + assertFalse(config.getAllowMove()); + assertFalse(config.getAllowCopy()); + } +} diff --git a/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3PluginSettingsContributorTest.java b/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3PluginSettingsContributorTest.java new file mode 100644 index 0000000..8d19e45 --- /dev/null +++ b/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3PluginSettingsContributorTest.java @@ -0,0 +1,161 @@ +package me.golemcore.plugins.golemcore.s3; + +import me.golemcore.plugin.api.extension.spi.PluginActionResult; +import me.golemcore.plugin.api.extension.spi.PluginSettingsSection; +import me.golemcore.plugins.golemcore.s3.support.S3MinioClient; +import me.golemcore.plugins.golemcore.s3.support.S3StorageException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class S3PluginSettingsContributorTest { + + private S3PluginConfigService configService; + private S3MinioClient client; + private S3PluginSettingsContributor contributor; + private S3PluginConfig config; + + @BeforeEach + void setUp() { + configService = mock(S3PluginConfigService.class); + client = mock(S3MinioClient.class); + contributor = new S3PluginSettingsContributor(configService, client); + config = S3PluginConfig.builder().build(); + config.normalize(); + when(configService.getConfig()).thenReturn(config); + } + + @Test + void shouldExposeSectionWithBlankSecretAndSafeDefaults() { + PluginSettingsSection section = contributor.getSection("main"); + + assertEquals(false, section.getValues().get("enabled")); + assertEquals("https://play.min.io", section.getValues().get("endpoint")); + assertEquals("us-east-1", section.getValues().get("region")); + assertEquals("", section.getValues().get("secretKey")); + assertEquals("/", section.getValues().get("rootPrefix")); + assertEquals(30_000, section.getValues().get("timeoutMs")); + assertEquals(false, section.getValues().get("allowInsecureTls")); + assertEquals(50 * 1024 * 1024, section.getValues().get("maxDownloadBytes")); + assertEquals(12_000, section.getValues().get("maxInlineTextChars")); + assertFalse((Boolean) section.getValues().get("autoCreateBucket")); + assertFalse((Boolean) section.getValues().get("allowWrite")); + assertFalse((Boolean) section.getValues().get("allowDelete")); + assertFalse((Boolean) section.getValues().get("allowMove")); + assertFalse((Boolean) section.getValues().get("allowCopy")); + } + + @Test + void shouldRoundTripSavedPolicyFlagsThroughGetSection() { + S3PluginConfig initialConfig = S3PluginConfig.builder() + .secretKey("existing-secret") + .build(); + initialConfig.normalize(); + S3PluginConfig persistedConfig = S3PluginConfig.builder() + .enabled(true) + .endpoint("https://storage.example.com") + .region("eu-west-1") + .accessKey("minio") + .secretKey("existing-secret") + .bucket("files") + .rootPrefix("AI") + .timeoutMs(45_000) + .allowInsecureTls(true) + .maxDownloadBytes(4096) + .maxInlineTextChars(2048) + .autoCreateBucket(true) + .allowWrite(true) + .allowDelete(false) + .allowMove(true) + .allowCopy(false) + .build(); + persistedConfig.normalize(); + when(configService.getConfig()).thenReturn(initialConfig, persistedConfig); + + Map values = new LinkedHashMap<>(); + values.put("enabled", true); + values.put("endpoint", "https://storage.example.com"); + values.put("region", "eu-west-1"); + values.put("accessKey", "minio"); + values.put("secretKey", ""); + values.put("bucket", "files"); + values.put("rootPrefix", "/AI"); + values.put("timeoutMs", 45_000); + values.put("allowInsecureTls", true); + values.put("maxDownloadBytes", 4096); + values.put("maxInlineTextChars", 2048); + values.put("autoCreateBucket", true); + values.put("allowWrite", true); + values.put("allowDelete", false); + values.put("allowMove", true); + values.put("allowCopy", false); + + PluginSettingsSection section = contributor.saveSection("main", values); + + ArgumentCaptor captor = ArgumentCaptor.forClass(S3PluginConfig.class); + verify(configService).save(captor.capture()); + S3PluginConfig saved = captor.getValue(); + assertEquals("existing-secret", saved.getSecretKey()); + assertTrue(saved.getAutoCreateBucket()); + assertTrue(saved.getAllowWrite()); + assertFalse(saved.getAllowDelete()); + assertTrue(saved.getAllowMove()); + assertFalse(saved.getAllowCopy()); + + assertEquals("", section.getValues().get("secretKey")); + assertEquals("/AI", section.getValues().get("rootPrefix")); + assertTrue((Boolean) section.getValues().get("autoCreateBucket")); + assertTrue((Boolean) section.getValues().get("allowWrite")); + assertFalse((Boolean) section.getValues().get("allowDelete")); + assertTrue((Boolean) section.getValues().get("allowMove")); + assertFalse((Boolean) section.getValues().get("allowCopy")); + } + + @Test + void shouldReturnOkWhenConnectionTestSucceeds() { + config.setEndpoint("https://storage.example.com"); + config.setAccessKey("minio"); + config.setSecretKey("secret"); + config.setBucket("files"); + when(client.listDirectory("")).thenReturn(java.util.List.of()); + + PluginActionResult result = contributor.executeAction("main", "test-connection", Map.of()); + + assertEquals("ok", result.getStatus()); + assertEquals("Connected to S3 bucket files. Root returned 0 item(s).", result.getMessage()); + } + + @Test + void shouldReturnErrorWhenRequiredSettingsAreMissing() { + PluginActionResult result = contributor.executeAction("main", "test-connection", Map.of()); + + assertEquals("error", result.getStatus()); + assertEquals("S3 endpoint, access key, secret key, and bucket must be configured.", result.getMessage()); + } + + @Test + void shouldReturnErrorWhenConnectionTestFails() { + config.setEndpoint("https://storage.example.com"); + config.setAccessKey("minio"); + config.setSecretKey("secret"); + config.setBucket("files"); + doThrow(new S3StorageException("S3 object listing failed: AccessDenied")) + .when(client).ensureBucketReady(); + + PluginActionResult result = contributor.executeAction("main", "test-connection", Map.of()); + + assertEquals("error", result.getStatus()); + assertEquals("Connection failed: S3 object listing failed: AccessDenied", result.getMessage()); + } +} diff --git a/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3SpringWiringTest.java b/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3SpringWiringTest.java new file mode 100644 index 0000000..194795b --- /dev/null +++ b/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/S3SpringWiringTest.java @@ -0,0 +1,36 @@ +package me.golemcore.plugins.golemcore.s3; + +import me.golemcore.plugin.api.runtime.PluginConfigurationService; +import me.golemcore.plugins.golemcore.s3.support.S3MinioClient; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; + +class S3SpringWiringTest { + + @Test + void shouldCreateS3BeansWithoutDefaultConstructor() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + context.register(S3PluginConfiguration.class, TestConfig.class); + context.refresh(); + + assertNotNull(context.getBean(S3MinioClient.class)); + assertNotNull(context.getBean(S3PluginSettingsContributor.class)); + assertNotNull(context.getBean(S3FilesToolProvider.class)); + } + } + + @Configuration(proxyBeanMethods = false) + @SuppressWarnings("PMD.TestClassWithoutTestCases") + static class TestConfig { + + @Bean + PluginConfigurationService pluginConfigurationService() { + return mock(PluginConfigurationService.class); + } + } +} diff --git a/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/support/S3MinioClientTest.java b/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/support/S3MinioClientTest.java new file mode 100644 index 0000000..b5c45b1 --- /dev/null +++ b/golemcore/s3/src/test/java/me/golemcore/plugins/golemcore/s3/support/S3MinioClientTest.java @@ -0,0 +1,217 @@ +package me.golemcore.plugins.golemcore.s3.support; + +import me.golemcore.plugins.golemcore.s3.S3PluginConfig; +import me.golemcore.plugins.golemcore.s3.S3PluginConfigService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.ZonedDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class S3MinioClientTest { + + private S3PluginConfigService configService; + private StubS3MinioClient client; + + @BeforeEach + void setUp() { + configService = mock(S3PluginConfigService.class); + when(configService.getConfig()).thenReturn(S3PluginConfig.builder() + .enabled(true) + .endpoint("https://storage.example.com") + .region("us-east-1") + .accessKey("minio") + .secretKey("secret") + .bucket("files") + .rootPrefix("AI") + .build()); + client = new StubS3MinioClient(configService); + } + + @Test + void shouldEnsureBucketExistsWithoutCreatingWhenPresent() { + client.bucketExists = true; + + client.ensureBucketReady(); + + assertFalse(client.makeBucketCalled); + } + + @Test + void shouldCreateBucketWhenMissingAndAutoCreateEnabled() { + when(configService.getConfig()).thenReturn(S3PluginConfig.builder() + .enabled(true) + .endpoint("https://storage.example.com") + .region("eu-west-1") + .accessKey("minio") + .secretKey("secret") + .bucket("files") + .rootPrefix("AI") + .autoCreateBucket(true) + .build()); + client.bucketExists = false; + + client.ensureBucketReady(); + + assertTrue(client.makeBucketCalled); + assertEquals("files", client.lastBucket); + assertEquals("eu-west-1", client.lastRegion); + } + + @Test + void shouldListDirectoryAndStripRootPrefix() { + client.bucketExists = true; + client.listObjectsResult = List.of( + new S3ObjectInfo("files", "AI/docs/readme.md", "readme.md", false, 5L, "etag", "text/plain", null), + new S3ObjectInfo("files", "AI/docs/archive/", "archive", true, null, null, null, null)); + + List entries = client.listDirectory("docs"); + + assertEquals(2, entries.size()); + assertEquals("docs/readme.md", entries.get(0).key()); + assertEquals("docs/archive", entries.get(1).key()); + } + + @Test + void shouldReadObjectContent() { + client.bucketExists = true; + client.statObject = new S3ObjectInfo("files", "AI/docs/readme.md", "readme.md", false, 5L, "etag", + "text/plain", ZonedDateTime.parse("2026-04-08T12:00:00Z")); + client.objectContent = new S3ObjectContent("files", "AI/docs/readme.md", "hello".getBytes(), "etag", + "text/plain", ZonedDateTime.parse("2026-04-08T12:00:00Z")); + + S3ObjectContent content = client.readObject("docs/readme.md"); + + assertEquals("AI/docs/readme.md", client.lastKey); + assertEquals("hello", new String(content.bytes())); + } + + @Test + void shouldWriteObjectsThroughResolvedRootPrefix() { + client.bucketExists = true; + + client.writeObject("docs/readme.md", "hello".getBytes(), "text/plain"); + + assertEquals("AI/docs/readme.md", client.lastKey); + assertEquals("text/plain", client.lastContentType); + } + + @Test + void shouldCopyAndDeleteObjectsThroughResolvedRootPrefix() { + client.bucketExists = true; + client.statObject = new S3ObjectInfo("files", "AI/docs/readme.md", "readme.md", false, 5L, "etag", + "text/plain", null); + + client.copyObject("docs/readme.md", "archive/readme.md"); + client.deleteObject("docs/readme.md"); + + assertEquals("AI/archive/readme.md", client.lastTargetKey); + assertEquals("AI/docs/readme.md", client.lastDeletedKey); + } + + @Test + void shouldExposeDirectoryMarkersSeparately() { + client.bucketExists = true; + client.directoryMarker = new S3ObjectInfo("files", "AI/docs/", "docs", true, null, null, null, null); + + S3ObjectInfo marker = client.findDirectoryMarker("docs"); + + assertNotNull(marker); + assertTrue(marker.directory()); + assertEquals("docs", marker.key()); + } + + @Test + void shouldReturnNullWhenObjectIsMissing() { + client.bucketExists = true; + client.notFound = true; + + S3ObjectInfo result = client.findObject("docs/missing.txt"); + + assertEquals(null, result); + } + + private static final class StubS3MinioClient extends S3MinioClient { + + private boolean bucketExists; + private boolean makeBucketCalled; + private boolean notFound; + private String lastBucket; + private String lastRegion; + private String lastKey; + private String lastTargetKey; + private String lastDeletedKey; + private String lastContentType; + private List listObjectsResult = List.of(); + private S3ObjectInfo statObject; + private S3ObjectInfo directoryMarker; + private S3ObjectContent objectContent; + + private StubS3MinioClient(S3PluginConfigService configService) { + super(configService); + } + + @Override + protected boolean bucketExistsInternal(String bucket) { + lastBucket = bucket; + return bucketExists; + } + + @Override + protected void makeBucketInternal(String bucket, String region) { + makeBucketCalled = true; + lastBucket = bucket; + lastRegion = region; + } + + @Override + protected List listObjectsInternal(String bucket, String prefix, boolean recursive) { + return listObjectsResult; + } + + @Override + protected S3ObjectInfo statObjectInternal(String bucket, String key) { + lastKey = key; + if (notFound) { + throw new S3StorageException("S3 object stat failed: NotFound"); + } + if (directoryMarker != null && key.endsWith("/")) { + return directoryMarker; + } + return statObject; + } + + @Override + protected S3ObjectContent getObjectInternal(String bucket, String key, S3ObjectInfo metadata) { + lastKey = key; + return objectContent; + } + + @Override + protected void putObjectInternal(String bucket, String key, byte[] bytes, String contentType) { + lastBucket = bucket; + lastKey = key; + lastContentType = contentType; + } + + @Override + protected void copyObjectInternal(String bucket, String sourceKey, String targetKey, S3ObjectInfo sourceInfo) { + lastBucket = bucket; + lastKey = sourceKey; + lastTargetKey = targetKey; + } + + @Override + protected void removeObjectInternal(String bucket, String key) { + lastBucket = bucket; + lastDeletedKey = key; + } + } +} diff --git a/pom.xml b/pom.xml index 272ad83..b7550a8 100644 --- a/pom.xml +++ b/pom.xml @@ -77,6 +77,7 @@ golemcore/browserless golemcore/obsidian golemcore/notion + golemcore/s3 golemcore/tavily-search golemcore/firecrawl golemcore/perplexity-sonar diff --git a/registry/golemcore/s3/index.yaml b/registry/golemcore/s3/index.yaml new file mode 100644 index 0000000..f882ce5 --- /dev/null +++ b/registry/golemcore/s3/index.yaml @@ -0,0 +1,7 @@ +id: golemcore/s3 +owner: golemcore +name: s3 +latest: 1.0.0 +versions: + - 1.0.0 +source: "https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/s3" diff --git a/registry/golemcore/s3/versions/1.0.0.yaml b/registry/golemcore/s3/versions/1.0.0.yaml new file mode 100644 index 0000000..5074cdf --- /dev/null +++ b/registry/golemcore/s3/versions/1.0.0.yaml @@ -0,0 +1,12 @@ +id: golemcore/s3 +version: 1.0.0 +pluginApiVersion: 1 +engineVersion: ">=0.0.0 <1.0.0" +artifactUrl: "dist/golemcore/s3/1.0.0/golemcore-s3-plugin-1.0.0.jar" +publishedAt: "2026-04-08T15:05:00Z" +sourceCommit: "0172b8e13a2b49a7bd7bdb3a0bf48e76371ab458" +entrypoint: me.golemcore.plugins.golemcore.s3.S3PluginBootstrap +sourceUrl: "https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/s3" +license: "Apache-2.0" +maintainers: + - alexk-dev