diff --git a/README.md b/README.md index fa2bebf..cd0f39c 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ The current `golemcore/*` modules in this repository are: | `golemcore/firecrawl` | Firecrawl-backed page scraping plugin for markdown, summary, HTML, and link extraction. | | `golemcore/lightrag` | LightRAG-backed retrieval provider plugin for prompt augmentation and indexing. | | `golemcore/mail` | IMAP read and SMTP send tool plugin for mailbox integrations. | +| `golemcore/nextcloud` | Nextcloud WebDAV file plugin for sandboxed file operations across any file type. | | `golemcore/notion` | Notion vault plugin backed by the official Notion HTTP API. | | `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. | diff --git a/golemcore/nextcloud/plugin.yaml b/golemcore/nextcloud/plugin.yaml new file mode 100644 index 0000000..55572d8 --- /dev/null +++ b/golemcore/nextcloud/plugin.yaml @@ -0,0 +1,12 @@ +id: golemcore/nextcloud +provider: golemcore +name: nextcloud +version: 1.0.0 +pluginApiVersion: 1 +engineVersion: ">=0.0.0 <1.0.0" +entrypoint: me.golemcore.plugins.golemcore.nextcloud.NextcloudPluginBootstrap +description: Nextcloud WebDAV file plugin backed by the standard files endpoint. +sourceUrl: https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/nextcloud +license: Apache-2.0 +maintainers: + - alexk-dev diff --git a/golemcore/nextcloud/pom.xml b/golemcore/nextcloud/pom.xml new file mode 100644 index 0000000..5a22736 --- /dev/null +++ b/golemcore/nextcloud/pom.xml @@ -0,0 +1,104 @@ + + + 4.0.0 + + + me.golemcore.plugins + golemcore-plugins + 1.0.0 + ../../pom.xml + + + 1.0.0 + golemcore-nextcloud-plugin + golemcore/nextcloud + Nextcloud WebDAV file plugin for GolemCore + + + golemcore + nextcloud + ../../misc/formatter_eclipse.xml + + + + + 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 + + + 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/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudFilesService.java b/golemcore/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudFilesService.java new file mode 100644 index 0000000..e43f4db --- /dev/null +++ b/golemcore/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudFilesService.java @@ -0,0 +1,497 @@ +package me.golemcore.plugins.golemcore.nextcloud; + +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.nextcloud.support.NextcloudApiException; +import me.golemcore.plugins.golemcore.nextcloud.support.NextcloudFileContent; +import me.golemcore.plugins.golemcore.nextcloud.support.NextcloudPathValidator; +import me.golemcore.plugins.golemcore.nextcloud.support.NextcloudResource; +import me.golemcore.plugins.golemcore.nextcloud.support.NextcloudTransportException; +import me.golemcore.plugins.golemcore.nextcloud.support.NextcloudWebDavClient; +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.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +@Service +public class NextcloudFilesService { + + 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 NextcloudWebDavClient client; + private final NextcloudPluginConfigService configService; + private final NextcloudPathValidator pathValidator = new NextcloudPathValidator(); + + public NextcloudFilesService(NextcloudWebDavClient client, NextcloudPluginConfigService configService) { + this.client = client; + this.configService = configService; + } + + public NextcloudPluginConfig getConfig() { + return configService.getConfig(); + } + + public ToolResult listDirectory(String path) { + try { + String normalizedPath = pathValidator.normalizeOptionalPath(path); + 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 (NextcloudResource 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::resourceToMap).toList()); + return ToolResult.success(output.toString(), data); + } catch (IllegalArgumentException | NextcloudApiException | NextcloudTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult fileInfo(String path) { + try { + String normalizedPath = pathValidator.normalizeOptionalPath(path); + NextcloudResource resource = client.fileInfo(normalizedPath); + Map data = resourceToMap(resource); + StringBuilder output = new StringBuilder(); + output.append("Path: ").append(displayPath(resource.path())).append('\n'); + output.append("Type: ").append(resource.directory() ? "Directory" : "File").append('\n'); + if (resource.size() != null) { + output.append("Size: ").append(formatSize(resource.size())).append('\n'); + } + if (hasText(resource.mimeType())) { + output.append("MIME Type: ").append(resource.mimeType()).append('\n'); + } + if (hasText(resource.lastModified())) { + output.append("Modified: ").append(resource.lastModified()).append('\n'); + } + if (hasText(resource.etag())) { + output.append("ETag: ").append(resource.etag()).append('\n'); + } + return ToolResult.success(output.toString().trim(), data); + } catch (IllegalArgumentException | NextcloudApiException | NextcloudTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult readFile(String path) { + try { + String normalizedPath = pathValidator.normalizeRequiredPath(path); + NextcloudFileContent content = client.readFile(normalizedPath); + NextcloudPluginConfig config = configService.getConfig(); + long size = content.size() != null ? content.size() : content.bytes().length; + if (size > config.getMaxDownloadBytes()) { + return executionFailure("File too large to read directly: " + displayPath(normalizedPath) + + " (" + formatSize(size) + ")"); + } + + boolean textFile = isTextFile(normalizedPath, content.mimeType(), content.bytes()); + if (textFile) { + return buildTextReadResult(content, config); + } + return buildBinaryReadResult(content, size); + } catch (IllegalArgumentException | NextcloudApiException | NextcloudTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult writeFile(String path, String content, String contentBase64, boolean append) { + if (!Boolean.TRUE.equals(configService.getConfig().getAllowWrite())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, + "Nextcloud write is disabled in plugin settings"); + } + + try { + String normalizedPath = pathValidator.normalizeRequiredPath(path); + byte[] payload = resolveWritePayload(content, contentBase64); + byte[] finalPayload = payload; + if (append) { + finalPayload = appendToExistingContent(normalizedPath, payload); + } + client.createDirectoryRecursive(pathValidator.parentPath(normalizedPath)); + client.writeFile(normalizedPath, finalPayload); + + 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") + " file: " + normalizedPath, + data); + } catch (IllegalArgumentException | NextcloudApiException | NextcloudTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult createDirectory(String path) { + if (!Boolean.TRUE.equals(configService.getConfig().getAllowWrite())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, + "Nextcloud write is disabled in plugin settings"); + } + + try { + String normalizedPath = pathValidator.normalizeRequiredPath(path); + client.createDirectoryRecursive(normalizedPath); + return ToolResult.success("Created directory: " + normalizedPath, Map.of("path", normalizedPath)); + } catch (IllegalArgumentException | NextcloudApiException | NextcloudTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult delete(String path) { + if (!Boolean.TRUE.equals(configService.getConfig().getAllowDelete())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, + "Nextcloud delete is disabled in plugin settings"); + } + + try { + String normalizedPath = pathValidator.normalizeRequiredPath(path); + client.delete(normalizedPath); + return ToolResult.success("Deleted: " + normalizedPath, Map.of("path", normalizedPath)); + } catch (IllegalArgumentException | NextcloudApiException | NextcloudTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult move(String path, String targetPath) { + if (!Boolean.TRUE.equals(configService.getConfig().getAllowMove())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, + "Nextcloud 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"); + } + client.createDirectoryRecursive(pathValidator.parentPath(normalizedTargetPath)); + client.move(normalizedPath, normalizedTargetPath); + Map data = new LinkedHashMap<>(); + data.put("path", normalizedPath); + data.put("target_path", normalizedTargetPath); + return ToolResult.success("Moved: " + normalizedPath + " -> " + normalizedTargetPath, data); + } catch (IllegalArgumentException | NextcloudApiException | NextcloudTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult copy(String path, String targetPath) { + if (!Boolean.TRUE.equals(configService.getConfig().getAllowCopy())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, + "Nextcloud 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"); + } + client.createDirectoryRecursive(pathValidator.parentPath(normalizedTargetPath)); + client.copy(normalizedPath, normalizedTargetPath); + Map data = new LinkedHashMap<>(); + data.put("path", normalizedPath); + data.put("target_path", normalizedTargetPath); + return ToolResult.success("Copied: " + normalizedPath + " -> " + normalizedTargetPath, data); + } catch (IllegalArgumentException | NextcloudApiException | NextcloudTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + private ToolResult buildTextReadResult(NextcloudFileContent content, NextcloudPluginConfig config) { + String text = decodeText(content.bytes(), content.mimeType()); + boolean truncated = text.length() > config.getMaxInlineTextChars(); + String visibleText = truncated ? text.substring(0, config.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 file attached.]" + : visibleText; + return ToolResult.success(output, data); + } + + private ToolResult buildBinaryReadResult(NextcloudFileContent 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 file: ").append(content.path()).append('\n'); + output.append("Size: ").append(formatSize(size)).append('\n'); + if (hasText(content.mimeType())) { + output.append("MIME Type: ").append(content.mimeType()); + } + return ToolResult.success(output.toString().trim(), data); + } + + private Map baseReadData(NextcloudFileContent content, boolean textFile) { + Map data = new LinkedHashMap<>(); + data.put("path", content.path()); + data.put("size", content.size() != null ? content.size() : content.bytes().length); + data.put("mime_type", content.mimeType()); + data.put("etag", content.etag()); + data.put("modified", content.lastModified()); + data.put("is_text", textFile); + return data; + } + + private Attachment buildAttachment(NextcloudFileContent content, byte[] bytes) { + String mimeType = hasText(content.mimeType()) ? stripMimeParameters(content.mimeType()) + : detectMimeType(content.path()); + Attachment.Type attachmentType = mimeType.startsWith("image/") + ? Attachment.Type.IMAGE + : Attachment.Type.DOCUMENT; + return Attachment.builder() + .type(attachmentType) + .data(bytes) + .filename(fileName(content.path())) + .mimeType(mimeType) + .caption("Nextcloud file: " + content.path()) + .build(); + } + + private byte[] appendToExistingContent(String normalizedPath, byte[] payload) { + try { + NextcloudResource existing = client.fileInfo(normalizedPath); + if (existing.directory()) { + throw new IllegalArgumentException("Cannot append to a directory: " + normalizedPath); + } + long size = existing.size() != null ? existing.size() : 0L; + if (size > configService.getConfig().getMaxDownloadBytes()) { + throw new IllegalArgumentException("Existing file is too large to append safely: " + normalizedPath); + } + NextcloudFileContent existingContent = client.readFile(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 (NextcloudApiException ex) { + if (ex.getStatusCode() == 404) { + return payload; + } + throw ex; + } + } + + private byte[] resolveWritePayload(String content, String contentBase64) { + if (hasText(content) && hasText(contentBase64)) { + 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 ToolResult executionFailure(String message) { + return ToolResult.failure(ToolFailureKind.EXECUTION_FAILED, message); + } + + private boolean isTextFile(String path, String mimeType, byte[] bytes) { + String normalizedMimeType = stripMimeParameters(mimeType).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 mimeType) { + Charset charset = StandardCharsets.UTF_8; + String lowerMimeType = mimeType != null ? mimeType.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 resourceToMap(NextcloudResource resource) { + Map data = new LinkedHashMap<>(); + data.put("path", resource.path()); + data.put("name", resource.name()); + data.put("type", resource.directory() ? "directory" : "file"); + data.put("size", resource.size()); + data.put("mime_type", resource.mimeType()); + data.put("modified", resource.lastModified()); + data.put("etag", resource.etag()); + return data; + } + + private String displayPath(String normalizedPath) { + return normalizedPath == null || normalizedPath.isBlank() ? "/" : normalizedPath; + } + + private String fileName(String path) { + int separatorIndex = path.lastIndexOf('/'); + return separatorIndex >= 0 ? path.substring(separatorIndex + 1) : path; + } + + private String fileExtension(String path) { + String currentFileName = fileName(path).toLowerCase(Locale.ROOT); + int dotIndex = currentFileName.lastIndexOf('.'); + return dotIndex >= 0 ? currentFileName.substring(dotIndex + 1) : ""; + } + + private String stripMimeParameters(String mimeType) { + if (mimeType == null) { + return ""; + } + int separatorIndex = mimeType.indexOf(';'); + return separatorIndex >= 0 ? mimeType.substring(0, separatorIndex).trim() : mimeType.trim(); + } + + private String detectMimeType(String path) { + String extension = fileExtension(path); + return switch (extension) { + case "txt" -> "text/plain"; + case "md", "markdown" -> "text/markdown"; + case "json" -> "application/json"; + case "xml" -> "application/xml"; + case "yaml", "yml" -> "application/yaml"; + case "csv" -> "text/csv"; + case "html" -> "text/html"; + case "css" -> "text/css"; + case "js" -> "application/javascript"; + case "ts" -> "text/typescript"; + case "svg" -> "image/svg+xml"; + case "png" -> "image/png"; + case "jpg", "jpeg" -> "image/jpeg"; + case "gif" -> "image/gif"; + case "webp" -> "image/webp"; + case "pdf" -> "application/pdf"; + case "zip" -> "application/zip"; + default -> "application/octet-stream"; + }; + } + + 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)); + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/golemcore/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudFilesToolProvider.java b/golemcore/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudFilesToolProvider.java new file mode 100644 index 0000000..85ab94c --- /dev/null +++ b/golemcore/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudFilesToolProvider.java @@ -0,0 +1,151 @@ +package me.golemcore.plugins.golemcore.nextcloud; + +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 NextcloudFilesToolProvider 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 NextcloudFilesService service; + + public NextcloudFilesToolProvider(NextcloudFilesService service) { + this.service = service; + } + + @Override + public ToolDefinition getDefinition() { + return ToolDefinition.builder() + .name("nextcloud_files") + .description("Work with Nextcloud files through the standard WebDAV layer.") + .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 Nextcloud root."), + 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 file 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() { + NextcloudPluginConfig config = service.getConfig(); + return Boolean.TRUE.equals(config.getEnabled()) + && hasText(config.getUsername()) + && hasText(config.getAppPassword()); + } + + @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 nextcloud_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/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudPluginBootstrap.java b/golemcore/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudPluginBootstrap.java new file mode 100644 index 0000000..8ab963a --- /dev/null +++ b/golemcore/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudPluginBootstrap.java @@ -0,0 +1,22 @@ +package me.golemcore.plugins.golemcore.nextcloud; + +import me.golemcore.plugin.api.extension.spi.PluginBootstrap; +import me.golemcore.plugin.api.extension.spi.PluginDescriptor; + +public class NextcloudPluginBootstrap implements PluginBootstrap { + + @Override + public PluginDescriptor descriptor() { + return PluginDescriptor.builder() + .id("golemcore/nextcloud") + .provider("golemcore") + .name("nextcloud") + .entrypoint(getClass().getName()) + .build(); + } + + @Override + public Class configurationClass() { + return NextcloudPluginConfiguration.class; + } +} diff --git a/golemcore/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudPluginConfig.java b/golemcore/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudPluginConfig.java new file mode 100644 index 0000000..256488a --- /dev/null +++ b/golemcore/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudPluginConfig.java @@ -0,0 +1,136 @@ +package me.golemcore.plugins.golemcore.nextcloud; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.ArrayDeque; +import java.util.Deque; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class NextcloudPluginConfig { + + static final String DEFAULT_BASE_URL = "https://nextcloud.example.com"; + 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 baseUrl = DEFAULT_BASE_URL; + + private String username; + private String appPassword; + + @Builder.Default + private String rootPath = ""; + + @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 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; + } + baseUrl = normalizeUrl(baseUrl, DEFAULT_BASE_URL); + username = normalizeText(username, ""); + rootPath = normalizeRootPath(rootPath); + 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 (allowWrite == null) { + allowWrite = false; + } + if (allowDelete == null) { + allowDelete = false; + } + if (allowMove == null) { + allowMove = false; + } + if (allowCopy == null) { + allowCopy = false; + } + } + + private String normalizeUrl(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 normalizeRootPath(String value) { + String candidate = normalizeText(value, "").replace('\\', '/'); + while (candidate.startsWith("/")) { + candidate = candidate.substring(1); + } + while (candidate.endsWith("/")) { + candidate = candidate.substring(0, candidate.length() - 1); + } + if (candidate.isBlank()) { + return ""; + } + + 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("rootPath must stay within the Nextcloud files root"); + } + segments.removeLast(); + continue; + } + segments.addLast(segment); + } + return String.join("/", segments); + } + + 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/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudPluginConfigService.java b/golemcore/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudPluginConfigService.java new file mode 100644 index 0000000..b551b4d --- /dev/null +++ b/golemcore/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudPluginConfigService.java @@ -0,0 +1,33 @@ +package me.golemcore.plugins.golemcore.nextcloud; + +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 NextcloudPluginConfigService { + + static final String PLUGIN_ID = "golemcore/nextcloud"; + + private final PluginConfigurationService pluginConfigurationService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public NextcloudPluginConfig getConfig() { + Map raw = pluginConfigurationService.getPluginConfig(PLUGIN_ID); + NextcloudPluginConfig config = raw.isEmpty() + ? NextcloudPluginConfig.builder().build() + : objectMapper.convertValue(raw, NextcloudPluginConfig.class); + config.normalize(); + return config; + } + + @SuppressWarnings("unchecked") + public void save(NextcloudPluginConfig config) { + config.normalize(); + pluginConfigurationService.savePluginConfig(PLUGIN_ID, objectMapper.convertValue(config, Map.class)); + } +} diff --git a/golemcore/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudPluginConfiguration.java b/golemcore/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudPluginConfiguration.java new file mode 100644 index 0000000..ff75570 --- /dev/null +++ b/golemcore/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudPluginConfiguration.java @@ -0,0 +1,9 @@ +package me.golemcore.plugins.golemcore.nextcloud; + +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +@ComponentScan(basePackageClasses = NextcloudPluginConfiguration.class) +public class NextcloudPluginConfiguration { +} diff --git a/golemcore/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudPluginSettingsContributor.java b/golemcore/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudPluginSettingsContributor.java new file mode 100644 index 0000000..0a9bc38 --- /dev/null +++ b/golemcore/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudPluginSettingsContributor.java @@ -0,0 +1,265 @@ +package me.golemcore.plugins.golemcore.nextcloud; + +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.nextcloud.support.NextcloudApiException; +import me.golemcore.plugins.golemcore.nextcloud.support.NextcloudTransportException; +import me.golemcore.plugins.golemcore.nextcloud.support.NextcloudWebDavClient; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class NextcloudPluginSettingsContributor implements PluginSettingsContributor { + + private static final String SECTION_KEY = "main"; + private static final String ACTION_TEST_CONNECTION = "test-connection"; + + private final NextcloudPluginConfigService configService; + private final NextcloudWebDavClient client; + + @Override + public String getPluginId() { + return NextcloudPluginConfigService.PLUGIN_ID; + } + + @Override + public List getCatalogItems() { + return List.of(PluginSettingsCatalogItem.builder() + .pluginId(NextcloudPluginConfigService.PLUGIN_ID) + .pluginName("nextcloud") + .provider("golemcore") + .sectionKey(SECTION_KEY) + .title("Nextcloud") + .description("Nextcloud WebDAV connection, root sandbox, and file operation policy.") + .blockKey("tools") + .blockTitle("Tools") + .blockDescription("Tool-specific runtime behavior and integrations") + .order(39) + .build()); + } + + @Override + public PluginSettingsSection getSection(String sectionKey) { + requireSection(sectionKey); + NextcloudPluginConfig config = configService.getConfig(); + Map values = new LinkedHashMap<>(); + values.put("enabled", Boolean.TRUE.equals(config.getEnabled())); + values.put("baseUrl", config.getBaseUrl()); + values.put("username", config.getUsername()); + values.put("appPassword", ""); + values.put("rootPath", config.getRootPath().isBlank() ? "/" : "/" + config.getRootPath()); + 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("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("Nextcloud") + .description("Configure the Nextcloud WebDAV connection, root sandbox, and conservative file policy.") + .fields(List.of( + PluginSettingsField.builder() + .key("enabled") + .type("boolean") + .label("Enable Nextcloud") + .description("Allow tools to use the Nextcloud file integration.") + .build(), + PluginSettingsField.builder() + .key("baseUrl") + .type("text") + .label("Base URL") + .description("Nextcloud base URL without the WebDAV suffix.") + .placeholder("https://nextcloud.example.com") + .build(), + PluginSettingsField.builder() + .key("username") + .type("text") + .label("Username") + .description("Nextcloud username used for the standard files WebDAV endpoint.") + .placeholder("alex") + .build(), + PluginSettingsField.builder() + .key("appPassword") + .type("secret") + .label("App Password") + .description("Leave blank to keep the current secret.") + .placeholder("Enter app password") + .build(), + PluginSettingsField.builder() + .key("rootPath") + .type("text") + .label("Root Path") + .description("Sandbox all tool paths under this Nextcloud directory.") + .placeholder("/AI") + .build(), + PluginSettingsField.builder() + .key("timeoutMs") + .type("number") + .label("Request Timeout (ms)") + .description("Timeout for WebDAV requests.") + .min(1000.0) + .max(300000.0) + .step(1000.0) + .build(), + PluginSettingsField.builder() + .key("allowInsecureTls") + .type("boolean") + .label("Allow Insecure TLS") + .description("Allow self-signed TLS certificates when connecting to Nextcloud.") + .build(), + PluginSettingsField.builder() + .key("maxDownloadBytes") + .type("number") + .label("Max Download Bytes") + .description("Maximum file size that read_file downloads into memory.") + .min(1.0) + .step(1024.0) + .build(), + PluginSettingsField.builder() + .key("maxInlineTextChars") + .type("number") + .label("Max Inline Text Chars") + .description( + "Maximum number of text characters returned inline before attachment fallback.") + .min(1.0) + .step(1.0) + .build(), + PluginSettingsField.builder() + .key("allowWrite") + .type("boolean") + .label("Allow Write") + .description("Permit tools to create directories or write files.") + .build(), + PluginSettingsField.builder() + .key("allowDelete") + .type("boolean") + .label("Allow Delete") + .description("Permit tools to delete files or directories.") + .build(), + PluginSettingsField.builder() + .key("allowMove") + .type("boolean") + .label("Allow Move") + .description("Permit tools to move or rename files and directories.") + .build(), + PluginSettingsField.builder() + .key("allowCopy") + .type("boolean") + .label("Allow Copy") + .description("Permit tools to copy files and directories.") + .build())) + .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); + NextcloudPluginConfig config = configService.getConfig(); + config.setEnabled(readBoolean(values, "enabled", false)); + config.setBaseUrl(readString(values, "baseUrl", config.getBaseUrl())); + config.setUsername(readString(values, "username", config.getUsername())); + String appPassword = readString(values, "appPassword", null); + if (appPassword != null && !appPassword.isBlank()) { + config.setAppPassword(appPassword); + } + config.setRootPath(readString(values, "rootPath", config.getRootPath())); + 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.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 Nextcloud action: " + actionId); + } + return testConnection(); + } + + private PluginActionResult testConnection() { + NextcloudPluginConfig config = configService.getConfig(); + if (!hasText(config.getUsername()) || !hasText(config.getAppPassword())) { + return PluginActionResult.builder() + .status("error") + .message("Nextcloud username and app password must be configured.") + .build(); + } + try { + int count = client.listDirectory("").size(); + return PluginActionResult.builder() + .status("ok") + .message("Connected to Nextcloud. Root returned " + count + " item(s).") + .build(); + } catch (IllegalArgumentException | IllegalStateException | NextcloudApiException + | NextcloudTransportException 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 Nextcloud settings section: " + sectionKey); + } + } + + 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/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/support/NextcloudApiException.java b/golemcore/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/support/NextcloudApiException.java new file mode 100644 index 0000000..e4af6ca --- /dev/null +++ b/golemcore/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/support/NextcloudApiException.java @@ -0,0 +1,17 @@ +package me.golemcore.plugins.golemcore.nextcloud.support; + +public class NextcloudApiException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final int statusCode; + + public NextcloudApiException(int statusCode, String message) { + super(message); + this.statusCode = statusCode; + } + + public int getStatusCode() { + return statusCode; + } +} diff --git a/golemcore/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/support/NextcloudFileContent.java b/golemcore/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/support/NextcloudFileContent.java new file mode 100644 index 0000000..1b850da --- /dev/null +++ b/golemcore/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/support/NextcloudFileContent.java @@ -0,0 +1,3 @@ +package me.golemcore.plugins.golemcore.nextcloud.support; + +public record NextcloudFileContent(String path,byte[]bytes,String mimeType,Long size,String etag,String lastModified){} diff --git a/golemcore/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/support/NextcloudPathValidator.java b/golemcore/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/support/NextcloudPathValidator.java new file mode 100644 index 0000000..603814d --- /dev/null +++ b/golemcore/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/support/NextcloudPathValidator.java @@ -0,0 +1,51 @@ +package me.golemcore.plugins.golemcore.nextcloud.support; + +import java.util.ArrayDeque; +import java.util.Deque; + +public final class NextcloudPathValidator { + + 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 Nextcloud root"); + } + 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/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/support/NextcloudResource.java b/golemcore/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/support/NextcloudResource.java new file mode 100644 index 0000000..7f763ca --- /dev/null +++ b/golemcore/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/support/NextcloudResource.java @@ -0,0 +1,3 @@ +package me.golemcore.plugins.golemcore.nextcloud.support; + +public record NextcloudResource(String path,String name,boolean directory,Long size,String mimeType,String etag,String lastModified){} diff --git a/golemcore/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/support/NextcloudTransportException.java b/golemcore/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/support/NextcloudTransportException.java new file mode 100644 index 0000000..6942d58 --- /dev/null +++ b/golemcore/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/support/NextcloudTransportException.java @@ -0,0 +1,10 @@ +package me.golemcore.plugins.golemcore.nextcloud.support; + +public class NextcloudTransportException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public NextcloudTransportException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/golemcore/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/support/NextcloudWebDavClient.java b/golemcore/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/support/NextcloudWebDavClient.java new file mode 100644 index 0000000..2889ace --- /dev/null +++ b/golemcore/nextcloud/src/main/java/me/golemcore/plugins/golemcore/nextcloud/support/NextcloudWebDavClient.java @@ -0,0 +1,534 @@ +package me.golemcore.plugins.golemcore.nextcloud.support; + +import me.golemcore.plugins.golemcore.nextcloud.NextcloudPluginConfig; +import me.golemcore.plugins.golemcore.nextcloud.NextcloudPluginConfigService; +import okhttp3.Credentials; +import okhttp3.HttpUrl; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.springframework.stereotype.Component; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; + +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 javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import java.io.IOException; +import java.io.StringReader; +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +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.Locale; +import java.util.Objects; + +@Component +public class NextcloudWebDavClient { + + private static final String REMOTE_PHP_SEGMENT = "remote.php"; + private static final String DAV_SEGMENT = "dav"; + private static final String FILES_SEGMENT = "files"; + private static final MediaType APPLICATION_XML = MediaType.get("application/xml; charset=utf-8"); + private static final MediaType TEXT_PLAIN = MediaType.get("text/plain; charset=utf-8"); + private static final String PROPFIND_BODY = """ + + + + + + + + + + + """; + + private final NextcloudPluginConfigService configService; + + public NextcloudWebDavClient(NextcloudPluginConfigService configService) { + this.configService = configService; + } + + public List listDirectory(String relativePath) { + String normalizedPath = normalizeRelativePath(relativePath); + List resources = propfind(normalizedPath, 1, true); + List children = new ArrayList<>(); + for (NextcloudResource resource : resources) { + if (!normalizedPath.equals(resource.path())) { + children.add(resource); + } + } + return List.copyOf(children); + } + + public NextcloudResource fileInfo(String relativePath) { + String normalizedPath = normalizeRelativePath(relativePath); + List resources = propfind(normalizedPath, 0, normalizedPath.isBlank()); + for (NextcloudResource resource : resources) { + if (normalizedPath.equals(resource.path())) { + return resource; + } + } + if (!resources.isEmpty()) { + return resources.getFirst(); + } + throw new NextcloudApiException(404, "Path not found: " + displayPath(normalizedPath)); + } + + public NextcloudFileContent readFile(String relativePath) { + String normalizedPath = normalizeRelativePath(relativePath); + Request request = new Request.Builder() + .url(buildResourceUrl(normalizedPath, false)) + .header("Authorization", authorizationHeader()) + .get() + .build(); + + try (Response response = openResponse(request)) { + int statusCode = response.code(); + String mimeType = firstText(response.header("Content-Type"), detectMimeType(normalizedPath)); + String etag = response.header("ETag"); + String lastModified = response.header("Last-Modified"); + byte[] bytes = readResponseBytes(response); + ensureSuccessful(statusCode, bytes); + Long size = (long) bytes.length; + return new NextcloudFileContent(normalizedPath, bytes, mimeType, size, etag, lastModified); + } + } + + public void writeFile(String relativePath, byte[] bytes) { + String normalizedPath = normalizeRelativePath(relativePath); + String mimeType = detectMimeType(normalizedPath); + Request request = new Request.Builder() + .url(buildResourceUrl(normalizedPath, false)) + .header("Authorization", authorizationHeader()) + .header("Content-Type", mimeType) + .put(RequestBody.create(bytes, MediaType.get(mimeType))) + .build(); + + executeVoid(request); + } + + public void createDirectoryRecursive(String relativeDirectoryPath) { + String normalizedPath = normalizeRelativePath(relativeDirectoryPath); + if (normalizedPath.isBlank()) { + return; + } + StringBuilder currentPath = new StringBuilder(); + for (String segment : splitPath(normalizedPath)) { + if (!currentPath.isEmpty()) { + currentPath.append('/'); + } + currentPath.append(segment); + Request request = new Request.Builder() + .url(buildResourceUrl(currentPath.toString(), true)) + .header("Authorization", authorizationHeader()) + .method("MKCOL", RequestBody.create("", TEXT_PLAIN)) + .build(); + try (Response response = openResponse(request)) { + int statusCode = response.code(); + byte[] body = readResponseBytes(response); + if (statusCode == 201 || statusCode == 405) { + continue; + } + ensureSuccessful(statusCode, body); + } + } + } + + public void delete(String relativePath) { + String normalizedPath = normalizeRelativePath(relativePath); + Request request = new Request.Builder() + .url(buildResourceUrl(normalizedPath, false)) + .header("Authorization", authorizationHeader()) + .delete() + .build(); + executeVoid(request); + } + + public void move(String relativePath, String targetPath) { + executeCopyOrMove("MOVE", relativePath, targetPath); + } + + public void copy(String relativePath, String targetPath) { + executeCopyOrMove("COPY", relativePath, targetPath); + } + + protected Response executeRequest(Request request) throws IOException { + return buildHttpClient(getConfig()).newCall(request).execute(); + } + + private List propfind(String relativePath, int depth, boolean directoryUrl) { + Request request = new Request.Builder() + .url(buildResourceUrl(relativePath, directoryUrl)) + .header("Authorization", authorizationHeader()) + .header("Depth", String.valueOf(depth)) + .method("PROPFIND", RequestBody.create(PROPFIND_BODY, APPLICATION_XML)) + .build(); + + try (Response response = openResponse(request)) { + int statusCode = response.code(); + byte[] body = readResponseBytes(response); + ensureSuccessful(statusCode, body); + if (body.length == 0) { + return List.of(); + } + return parseResources(new String(body, StandardCharsets.UTF_8)); + } + } + + private void executeCopyOrMove(String method, String relativePath, String targetPath) { + String normalizedPath = normalizeRelativePath(relativePath); + String normalizedTargetPath = normalizeRelativePath(targetPath); + Request request = new Request.Builder() + .url(buildResourceUrl(normalizedPath, false)) + .header("Authorization", authorizationHeader()) + .header("Destination", buildResourceUrl(normalizedTargetPath, false).toString()) + .header("Overwrite", "F") + .method(method, RequestBody.create("", TEXT_PLAIN)) + .build(); + executeVoid(request); + } + + private void executeVoid(Request request) { + try (Response response = openResponse(request)) { + int statusCode = response.code(); + byte[] body = readResponseBytes(response); + ensureSuccessful(statusCode, body); + } + } + + private Response openResponse(Request request) { + try { + return executeRequest(request); + } catch (IOException ex) { + throw new NextcloudTransportException(transportMessage(ex), ex); + } + } + + private byte[] readResponseBytes(Response response) { + try (ResponseBody body = response.body()) { + return body != null ? body.bytes() : new byte[0]; + } catch (IOException ex) { + throw new NextcloudTransportException(transportMessage(ex), ex); + } + } + + private List parseResources(String xmlBody) { + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setExpandEntityReferences(false); + DocumentBuilder documentBuilder = factory.newDocumentBuilder(); + Document document = documentBuilder.parse(new InputSource(new StringReader(xmlBody))); + NodeList responseNodes = document.getElementsByTagNameNS("*", "response"); + List resources = new ArrayList<>(responseNodes.getLength()); + for (int index = 0; index < responseNodes.getLength(); index++) { + Element response = (Element) responseNodes.item(index); + String href = textOfFirstChild(response, "href"); + Element prop = findSuccessfulProp(response); + if (!hasText(href) || prop == null) { + continue; + } + String path = extractRelativePath(href); + boolean directory = isDirectory(prop); + String name = path.isBlank() ? displayNameForRoot() : fileName(path); + Long size = parseLong(textOfFirstChild(prop, "getcontentlength")); + String mimeType = textOfFirstChild(prop, "getcontenttype"); + String etag = textOfFirstChild(prop, "getetag"); + String lastModified = textOfFirstChild(prop, "getlastmodified"); + resources.add(new NextcloudResource(path, name, directory, size, mimeType, etag, lastModified)); + } + return List.copyOf(resources); + } catch (ParserConfigurationException | IOException | SAXException ex) { + throw new NextcloudApiException(500, "Invalid WebDAV XML response: " + ex.getMessage()); + } + } + + private Element findSuccessfulProp(Element response) { + NodeList propstatNodes = response.getElementsByTagNameNS("*", "propstat"); + for (int index = 0; index < propstatNodes.getLength(); index++) { + Element propstat = (Element) propstatNodes.item(index); + String status = textOfFirstChild(propstat, "status"); + if (status != null && status.contains(" 200 ")) { + NodeList propNodes = propstat.getElementsByTagNameNS("*", "prop"); + if (propNodes.getLength() > 0) { + return (Element) propNodes.item(0); + } + } + } + return null; + } + + private boolean isDirectory(Element prop) { + NodeList resourceTypes = prop.getElementsByTagNameNS("*", "resourcetype"); + if (resourceTypes.getLength() == 0) { + return false; + } + Element resourceType = (Element) resourceTypes.item(0); + return resourceType.getElementsByTagNameNS("*", "collection").getLength() > 0; + } + + private String textOfFirstChild(Element parent, String localName) { + NodeList nodes = parent.getElementsByTagNameNS("*", localName); + if (nodes.getLength() == 0) { + return null; + } + String textContent = nodes.item(0).getTextContent(); + return hasText(textContent) ? textContent.trim() : null; + } + + private void ensureSuccessful(int statusCode, byte[] body) { + if (statusCode >= 200 && statusCode < 300) { + return; + } + String responseBody = new String(body, StandardCharsets.UTF_8).trim(); + String message = hasText(responseBody) ? responseBody : "HTTP " + statusCode; + throw new NextcloudApiException(statusCode, message); + } + + private String extractRelativePath(String href) { + URI uri = URI.create(href); + String rawPath = firstText(uri.getRawPath(), href); + String prefixWithSlash = buildResourceUrl("", true).encodedPath(); + String prefixWithoutSlash = buildResourceUrl("", false).encodedPath(); + String rawRelativePath; + if (rawPath.equals(prefixWithoutSlash) || rawPath.equals(prefixWithSlash)) { + return ""; + } + if (rawPath.startsWith(prefixWithSlash)) { + rawRelativePath = rawPath.substring(prefixWithSlash.length()); + } else if (rawPath.startsWith(prefixWithoutSlash + "/")) { + rawRelativePath = rawPath.substring(prefixWithoutSlash.length() + 1); + } else if (rawPath.startsWith(prefixWithoutSlash)) { + rawRelativePath = rawPath.substring(prefixWithoutSlash.length()); + } else { + throw new NextcloudApiException(500, "Unexpected WebDAV href: " + href); + } + while (rawRelativePath.startsWith("/")) { + rawRelativePath = rawRelativePath.substring(1); + } + while (rawRelativePath.endsWith("/")) { + rawRelativePath = rawRelativePath.substring(0, rawRelativePath.length() - 1); + } + return decodePath(rawRelativePath); + } + + private String decodePath(String rawRelativePath) { + if (!hasText(rawRelativePath)) { + return ""; + } + List decodedSegments = new ArrayList<>(); + for (String segment : rawRelativePath.split("/")) { + if (!segment.isBlank()) { + decodedSegments.add(URLDecoder.decode(segment, StandardCharsets.UTF_8)); + } + } + return String.join("/", decodedSegments); + } + + private OkHttpClient buildHttpClient(NextcloudPluginConfig 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())); + HttpUrl baseUrl = parseBaseUrl(config.getBaseUrl()); + if (Boolean.TRUE.equals(config.getAllowInsecureTls()) && "https".equalsIgnoreCase(baseUrl.scheme())) { + 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 Nextcloud", ex); + } + } + return builder.build(); + } + + private HttpUrl buildResourceUrl(String relativePath, boolean directory) { + NextcloudPluginConfig config = getConfig(); + HttpUrl baseUrl = parseBaseUrl(config.getBaseUrl()); + HttpUrl.Builder builder = Objects.requireNonNull(baseUrl).newBuilder(); + builder.addPathSegment(REMOTE_PHP_SEGMENT); + builder.addPathSegment(DAV_SEGMENT); + builder.addPathSegment(FILES_SEGMENT); + builder.addPathSegment(config.getUsername()); + for (String segment : splitPath(config.getRootPath())) { + builder.addPathSegment(segment); + } + for (String segment : splitPath(relativePath)) { + builder.addPathSegment(segment); + } + if (directory) { + builder.addPathSegment(""); + } + return builder.build(); + } + + private HttpUrl parseBaseUrl(String baseUrl) { + HttpUrl parsed = HttpUrl.parse(baseUrl); + if (parsed == null) { + throw new IllegalStateException("Invalid Nextcloud base URL: " + baseUrl); + } + return parsed; + } + + private List splitPath(String value) { + if (!hasText(value)) { + return List.of(); + } + String normalized = value; + while (normalized.startsWith("/")) { + normalized = normalized.substring(1); + } + while (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + if (!hasText(normalized)) { + return List.of(); + } + String[] rawSegments = normalized.split("/"); + List segments = new ArrayList<>(rawSegments.length); + for (String segment : rawSegments) { + if (hasText(segment)) { + segments.add(segment); + } + } + return List.copyOf(segments); + } + + private NextcloudPluginConfig getConfig() { + return configService.getConfig(); + } + + private String authorizationHeader() { + NextcloudPluginConfig config = getConfig(); + return Credentials.basic(config.getUsername(), config.getAppPassword(), StandardCharsets.UTF_8); + } + + private String transportMessage(IOException ex) { + String message = ex.getMessage(); + return hasText(message) ? "Nextcloud transport failed: " + message : "Nextcloud transport failed"; + } + + private Long parseLong(String value) { + if (!hasText(value)) { + return null; + } + try { + return Long.parseLong(value); + } catch (NumberFormatException ignored) { + return null; + } + } + + private String displayPath(String path) { + return hasText(path) ? path : "/"; + } + + private String displayNameForRoot() { + String rootPath = getConfig().getRootPath(); + if (!hasText(rootPath)) { + return "/"; + } + int separatorIndex = rootPath.lastIndexOf('/'); + return separatorIndex >= 0 ? rootPath.substring(separatorIndex + 1) : rootPath; + } + + private String fileName(String path) { + int separatorIndex = path.lastIndexOf('/'); + return separatorIndex >= 0 ? path.substring(separatorIndex + 1) : path; + } + + private String detectMimeType(String path) { + String lowerPath = path.toLowerCase(Locale.ROOT); + if (lowerPath.endsWith(".txt")) { + 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(".png")) { + return "image/png"; + } + if (lowerPath.endsWith(".jpg") || lowerPath.endsWith(".jpeg")) { + return "image/jpeg"; + } + if (lowerPath.endsWith(".gif")) { + return "image/gif"; + } + if (lowerPath.endsWith(".pdf")) { + return "application/pdf"; + } + if (lowerPath.endsWith(".zip")) { + return "application/zip"; + } + return "application/octet-stream"; + } + + private String normalizeRelativePath(String value) { + return value == null ? "" : value; + } + + private String firstText(String... values) { + for (String value : values) { + if (hasText(value)) { + return value; + } + } + return ""; + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/golemcore/nextcloud/src/test/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudFilesServiceTest.java b/golemcore/nextcloud/src/test/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudFilesServiceTest.java new file mode 100644 index 0000000..0608b4d --- /dev/null +++ b/golemcore/nextcloud/src/test/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudFilesServiceTest.java @@ -0,0 +1,239 @@ +package me.golemcore.plugins.golemcore.nextcloud; + +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.nextcloud.support.NextcloudFileContent; +import me.golemcore.plugins.golemcore.nextcloud.support.NextcloudResource; +import me.golemcore.plugins.golemcore.nextcloud.support.NextcloudWebDavClient; +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.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 NextcloudFilesServiceTest { + + private NextcloudPluginConfigService configService; + private NextcloudWebDavClient client; + private NextcloudFilesService service; + + @BeforeEach + void setUp() { + configService = mock(NextcloudPluginConfigService.class); + client = mock(NextcloudWebDavClient.class); + when(configService.getConfig()).thenReturn(defaultConfig()); + service = new NextcloudFilesService(client, configService); + } + + @Test + void shouldListRootDirectory() { + when(client.listDirectory("")).thenReturn(List.of( + new NextcloudResource("docs", "docs", true, null, null, null, null), + new NextcloudResource("notes.md", "notes.md", false, 123L, "text/markdown", null, 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.readFile("docs/readme.md")).thenReturn(new NextcloudFileContent( + "docs/readme.md", + "hello".getBytes(), + "text/markdown; charset=utf-8", + 5L, + "etag", + "Mon, 01 Jan 2026 00:00:00 GMT")); + + 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 shouldAttachBinaryFilesOnRead() { + when(client.readFile("docs/archive.zip")).thenReturn(new NextcloudFileContent( + "docs/archive.zip", + new byte[] { 1, 2, 3 }, + "application/zip", + 3L, + null, + null)); + + ToolResult result = service.readFile("docs/archive.zip"); + + assertTrue(result.isSuccess()); + assertTrue(result.getOutput().contains("Read binary file")); + 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()); + assertEquals(Attachment.Type.DOCUMENT, attachment.getType()); + } + + @Test + void shouldAttachLongTextFilesWhenTruncated() { + when(configService.getConfig()).thenReturn(NextcloudPluginConfig.builder() + .enabled(true) + .username("alex") + .appPassword("secret") + .maxInlineTextChars(4) + .maxDownloadBytes(100) + .build()); + when(client.readFile("docs/readme.md")).thenReturn(new NextcloudFileContent( + "docs/readme.md", + "hello".getBytes(), + "text/plain", + 5L, + null, + null)); + + ToolResult result = service.readFile("docs/readme.md"); + + assertTrue(result.isSuccess()); + Map data = assertInstanceOf(Map.class, result.getData()); + assertEquals(true, data.get("truncated")); + assertEquals(5, data.get("originalLength")); + assertInstanceOf(Attachment.class, data.get("attachment")); + } + + @Test + void shouldDenyWriteWhenDisabled() { + when(configService.getConfig()).thenReturn(NextcloudPluginConfig.builder() + .enabled(true) + .username("alex") + .appPassword("secret") + .allowWrite(false) + .build()); + + ToolResult result = service.writeFile("docs/file.txt", "hello", null, false); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.POLICY_DENIED, result.getFailureKind()); + assertTrue(result.getError().contains("write is disabled")); + } + + @Test + void shouldWriteBase64BinaryContent() { + ToolResult result = service.writeFile("docs/file.bin", null, "AQID", false); + + assertTrue(result.isSuccess()); + verify(client).createDirectoryRecursive("docs"); + verify(client).writeFile("docs/file.bin", new byte[] { 1, 2, 3 }); + } + + @Test + void shouldAppendExistingFileContent() { + when(client.fileInfo("docs/file.txt")).thenReturn(new NextcloudResource( + "docs/file.txt", + "file.txt", + false, + 3L, + "text/plain", + null, + null)); + when(client.readFile("docs/file.txt")).thenReturn(new NextcloudFileContent( + "docs/file.txt", + "abc".getBytes(), + "text/plain", + 3L, + null, + null)); + + ToolResult result = service.writeFile("docs/file.txt", "def", null, true); + + assertTrue(result.isSuccess()); + verify(client).writeFile("docs/file.txt", "abcdef".getBytes()); + } + + @Test + void shouldCreateDirectories() { + ToolResult result = service.createDirectory("docs/archive"); + + assertTrue(result.isSuccess()); + verify(client).createDirectoryRecursive("docs/archive"); + } + + @Test + void shouldDenyDeleteWhenDisabled() { + when(configService.getConfig()).thenReturn(NextcloudPluginConfig.builder() + .enabled(true) + .username("alex") + .appPassword("secret") + .allowDelete(false) + .build()); + + ToolResult result = service.delete("docs/file.txt"); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.POLICY_DENIED, result.getFailureKind()); + } + + @Test + void shouldMoveFiles() { + ToolResult result = service.move("docs/file.txt", "archive/file.txt"); + + assertTrue(result.isSuccess()); + verify(client).createDirectoryRecursive("archive"); + verify(client).move("docs/file.txt", "archive/file.txt"); + } + + @Test + void shouldCopyFiles() { + ToolResult result = service.copy("docs/file.txt", "archive/file.txt"); + + assertTrue(result.isSuccess()); + verify(client).createDirectoryRecursive("archive"); + verify(client).copy("docs/file.txt", "archive/file.txt"); + } + + @Test + void shouldReturnFileInfo() { + when(client.fileInfo("docs/file.txt")).thenReturn(new NextcloudResource( + "docs/file.txt", + "file.txt", + false, + 42L, + "text/plain", + "etag", + "Mon, 01 Jan 2026 00:00:00 GMT")); + + ToolResult result = service.fileInfo("docs/file.txt"); + + assertTrue(result.isSuccess()); + assertTrue(result.getOutput().contains("Path: docs/file.txt")); + assertTrue(result.getOutput().contains("MIME Type: text/plain")); + } + + private NextcloudPluginConfig defaultConfig() { + return NextcloudPluginConfig.builder() + .enabled(true) + .username("alex") + .appPassword("secret") + .allowWrite(true) + .allowDelete(true) + .allowMove(true) + .allowCopy(true) + .maxDownloadBytes(1024) + .maxInlineTextChars(100) + .build(); + } +} diff --git a/golemcore/nextcloud/src/test/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudFilesToolProviderTest.java b/golemcore/nextcloud/src/test/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudFilesToolProviderTest.java new file mode 100644 index 0000000..97f2f02 --- /dev/null +++ b/golemcore/nextcloud/src/test/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudFilesToolProviderTest.java @@ -0,0 +1,153 @@ +package me.golemcore.plugins.golemcore.nextcloud; + +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 NextcloudFilesToolProviderTest { + + private NextcloudFilesService service; + private NextcloudFilesToolProvider provider; + + @BeforeEach + void setUp() { + service = mock(NextcloudFilesService.class); + provider = new NextcloudFilesToolProvider(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("nextcloud_files", definition.getName()); + assertEquals(List.of( + "list_directory", + "read_file", + "write_file", + "create_directory", + "delete", + "move", + "copy", + "file_info"), operation.get("enum")); + } + + @Test + void shouldRequirePathForReadFileInSchema() { + ToolDefinition definition = provider.getDefinition(); + + Map schema = definition.getInputSchema(); + List allOf = (List) schema.get("allOf"); + Map readRule = (Map) allOf.stream() + .map(Map.class::cast) + .filter(rule -> { + Map condition = (Map) rule.get("if"); + Map conditionProperties = (Map) condition.get("properties"); + Map operationProperty = (Map) conditionProperties.get("operation"); + return "read_file".equals(operationProperty.get("const")); + }) + .findFirst() + .orElseThrow(); + Map thenClause = (Map) readRule.get("then"); + + assertEquals(List.of("path"), thenClause.get("required")); + } + + @Test + void shouldBeEnabledWhenConfigHasCredentialsAndPluginIsEnabled() { + when(service.getConfig()).thenReturn(NextcloudPluginConfig.builder() + .enabled(true) + .username("alex") + .appPassword("secret") + .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 shouldDispatchReadToService() { + when(service.readFile("docs/file.bin")) + .thenReturn(ToolResult.success("read")); + + ToolResult result = provider.execute(Map.of( + "operation", "read_file", + "path", "docs/file.bin")).join(); + + assertTrue(result.isSuccess()); + verify(service).readFile("docs/file.bin"); + } + + @Test + void shouldDispatchListDirectoryWithoutPath() { + when(service.listDirectory(null)).thenReturn(ToolResult.success("listed")); + + ToolResult result = provider.execute(Map.of("operation", "list_directory")).join(); + + assertTrue(result.isSuccess()); + verify(service).listDirectory(null); + } + + @Test + void shouldDispatchCopyToService() { + when(service.copy("docs/source.bin", "docs/target.bin")) + .thenReturn(ToolResult.success("copied")); + + ToolResult result = provider.execute(Map.of( + "operation", "copy", + "path", "docs/source.bin", + "target_path", "docs/target.bin")).join(); + + assertTrue(result.isSuccess()); + verify(service).copy("docs/source.bin", "docs/target.bin"); + } + + @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/nextcloud/src/test/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudPluginBootstrapTest.java b/golemcore/nextcloud/src/test/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudPluginBootstrapTest.java new file mode 100644 index 0000000..bcc2e4e --- /dev/null +++ b/golemcore/nextcloud/src/test/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudPluginBootstrapTest.java @@ -0,0 +1,21 @@ +package me.golemcore.plugins.golemcore.nextcloud; + +import me.golemcore.plugin.api.extension.spi.PluginDescriptor; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class NextcloudPluginBootstrapTest { + + @Test + void shouldDescribeNextcloudPlugin() { + NextcloudPluginBootstrap bootstrap = new NextcloudPluginBootstrap(); + + PluginDescriptor descriptor = bootstrap.descriptor(); + + assertEquals("golemcore/nextcloud", descriptor.getId()); + assertEquals("golemcore", descriptor.getProvider()); + assertEquals("nextcloud", descriptor.getName()); + assertEquals(NextcloudPluginConfiguration.class, bootstrap.configurationClass()); + } +} diff --git a/golemcore/nextcloud/src/test/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudPluginConfigTest.java b/golemcore/nextcloud/src/test/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudPluginConfigTest.java new file mode 100644 index 0000000..da34969 --- /dev/null +++ b/golemcore/nextcloud/src/test/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudPluginConfigTest.java @@ -0,0 +1,42 @@ +package me.golemcore.plugins.golemcore.nextcloud; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +class NextcloudPluginConfigTest { + + @Test + void shouldNormalizeSafeDefaultsAndRootSandbox() { + NextcloudPluginConfig config = NextcloudPluginConfig.builder() + .enabled(null) + .baseUrl(" https://cloud.example.com/ ") + .username(" alex ") + .rootPath(" /AI/../AI Docs/ ") + .timeoutMs(0) + .allowInsecureTls(null) + .maxDownloadBytes(0) + .maxInlineTextChars(0) + .allowWrite(null) + .allowDelete(null) + .allowMove(null) + .allowCopy(null) + .build(); + + config.normalize(); + + assertFalse(config.getEnabled()); + assertEquals("https://cloud.example.com", config.getBaseUrl()); + assertEquals("alex", config.getUsername()); + assertEquals("AI Docs", config.getRootPath()); + assertEquals(30_000, config.getTimeoutMs()); + assertFalse(config.getAllowInsecureTls()); + assertEquals(50 * 1024 * 1024, config.getMaxDownloadBytes()); + assertEquals(12_000, config.getMaxInlineTextChars()); + assertFalse(config.getAllowWrite()); + assertFalse(config.getAllowDelete()); + assertFalse(config.getAllowMove()); + assertFalse(config.getAllowCopy()); + } +} diff --git a/golemcore/nextcloud/src/test/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudPluginSettingsContributorTest.java b/golemcore/nextcloud/src/test/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudPluginSettingsContributorTest.java new file mode 100644 index 0000000..7d5c29c --- /dev/null +++ b/golemcore/nextcloud/src/test/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudPluginSettingsContributorTest.java @@ -0,0 +1,149 @@ +package me.golemcore.plugins.golemcore.nextcloud; + +import me.golemcore.plugin.api.extension.spi.PluginActionResult; +import me.golemcore.plugin.api.extension.spi.PluginSettingsSection; +import me.golemcore.plugins.golemcore.nextcloud.support.NextcloudTransportException; +import me.golemcore.plugins.golemcore.nextcloud.support.NextcloudWebDavClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.io.IOException; +import java.util.LinkedHashMap; +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 NextcloudPluginSettingsContributorTest { + + private NextcloudPluginConfigService configService; + private NextcloudWebDavClient client; + private NextcloudPluginSettingsContributor contributor; + private NextcloudPluginConfig config; + + @BeforeEach + void setUp() { + configService = mock(NextcloudPluginConfigService.class); + client = mock(NextcloudWebDavClient.class); + contributor = new NextcloudPluginSettingsContributor(configService, client); + config = NextcloudPluginConfig.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://nextcloud.example.com", section.getValues().get("baseUrl")); + assertEquals("", section.getValues().get("appPassword")); + assertEquals("/", section.getValues().get("rootPath")); + 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("allowWrite")); + assertFalse((Boolean) section.getValues().get("allowDelete")); + assertFalse((Boolean) section.getValues().get("allowMove")); + assertFalse((Boolean) section.getValues().get("allowCopy")); + } + + @Test + void shouldRoundTripSavedPolicyFlagsThroughGetSection() { + NextcloudPluginConfig initialConfig = NextcloudPluginConfig.builder() + .appPassword("existing-secret") + .build(); + initialConfig.normalize(); + NextcloudPluginConfig persistedConfig = NextcloudPluginConfig.builder() + .enabled(true) + .baseUrl("https://cloud.example.com") + .username("alex") + .appPassword("existing-secret") + .rootPath("AI") + .timeoutMs(45_000) + .allowInsecureTls(true) + .maxDownloadBytes(4096) + .maxInlineTextChars(2048) + .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("baseUrl", "https://cloud.example.com"); + values.put("username", "alex"); + values.put("appPassword", ""); + values.put("rootPath", "/AI"); + values.put("timeoutMs", 45_000); + values.put("allowInsecureTls", true); + values.put("maxDownloadBytes", 4096); + values.put("maxInlineTextChars", 2048); + 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(NextcloudPluginConfig.class); + verify(configService).save(captor.capture()); + NextcloudPluginConfig saved = captor.getValue(); + assertEquals("existing-secret", saved.getAppPassword()); + assertTrue(saved.getAllowWrite()); + assertFalse(saved.getAllowDelete()); + assertTrue(saved.getAllowMove()); + assertFalse(saved.getAllowCopy()); + + assertEquals("", section.getValues().get("appPassword")); + assertEquals("/AI", section.getValues().get("rootPath")); + 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.setUsername("alex"); + config.setAppPassword("secret"); + when(client.listDirectory("")).thenReturn(List.of()); + + PluginActionResult result = contributor.executeAction("main", "test-connection", Map.of()); + + assertEquals("ok", result.getStatus()); + assertEquals("Connected to Nextcloud. Root returned 0 item(s).", result.getMessage()); + } + + @Test + void shouldReturnErrorWhenCredentialsAreMissing() { + PluginActionResult result = contributor.executeAction("main", "test-connection", Map.of()); + + assertEquals("error", result.getStatus()); + assertEquals("Nextcloud username and app password must be configured.", result.getMessage()); + } + + @Test + void shouldReturnErrorWhenConnectionTestFails() { + config.setUsername("alex"); + config.setAppPassword("secret"); + when(client.listDirectory("")).thenThrow(new NextcloudTransportException( + "Nextcloud transport failed: timeout", + new IOException("timeout"))); + + PluginActionResult result = contributor.executeAction("main", "test-connection", Map.of()); + + assertEquals("error", result.getStatus()); + assertEquals("Connection failed: Nextcloud transport failed: timeout", result.getMessage()); + } +} diff --git a/golemcore/nextcloud/src/test/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudSpringWiringTest.java b/golemcore/nextcloud/src/test/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudSpringWiringTest.java new file mode 100644 index 0000000..388b1d5 --- /dev/null +++ b/golemcore/nextcloud/src/test/java/me/golemcore/plugins/golemcore/nextcloud/NextcloudSpringWiringTest.java @@ -0,0 +1,36 @@ +package me.golemcore.plugins.golemcore.nextcloud; + +import me.golemcore.plugin.api.runtime.PluginConfigurationService; +import me.golemcore.plugins.golemcore.nextcloud.support.NextcloudWebDavClient; +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 NextcloudSpringWiringTest { + + @Test + void shouldCreateNextcloudBeansWithoutDefaultConstructor() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + context.register(NextcloudPluginConfiguration.class, TestConfig.class); + context.refresh(); + + assertNotNull(context.getBean(NextcloudWebDavClient.class)); + assertNotNull(context.getBean(NextcloudPluginSettingsContributor.class)); + assertNotNull(context.getBean(NextcloudFilesToolProvider.class)); + } + } + + @Configuration(proxyBeanMethods = false) + @SuppressWarnings("PMD.TestClassWithoutTestCases") + static class TestConfig { + + @Bean + PluginConfigurationService pluginConfigurationService() { + return mock(PluginConfigurationService.class); + } + } +} diff --git a/golemcore/nextcloud/src/test/java/me/golemcore/plugins/golemcore/nextcloud/support/NextcloudWebDavClientTest.java b/golemcore/nextcloud/src/test/java/me/golemcore/plugins/golemcore/nextcloud/support/NextcloudWebDavClientTest.java new file mode 100644 index 0000000..d2dc165 --- /dev/null +++ b/golemcore/nextcloud/src/test/java/me/golemcore/plugins/golemcore/nextcloud/support/NextcloudWebDavClientTest.java @@ -0,0 +1,255 @@ +package me.golemcore.plugins.golemcore.nextcloud.support; + +import me.golemcore.plugins.golemcore.nextcloud.NextcloudPluginConfig; +import me.golemcore.plugins.golemcore.nextcloud.NextcloudPluginConfigService; +import okhttp3.MediaType; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; +import java.util.Queue; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class NextcloudWebDavClientTest { + + private static final MediaType XML = MediaType.get("application/xml; charset=utf-8"); + private static final MediaType OCTET_STREAM = MediaType.get("application/octet-stream"); + + private NextcloudPluginConfigService configService; + private MockNextcloudWebDavClient client; + + @BeforeEach + void setUp() { + configService = mock(NextcloudPluginConfigService.class); + NextcloudPluginConfig config = NextcloudPluginConfig.builder() + .enabled(true) + .baseUrl("https://cloud.example.com") + .username("alex") + .appPassword("secret") + .rootPath("AI") + .timeoutMs(30_000) + .allowInsecureTls(false) + .build(); + when(configService.getConfig()).thenReturn(config); + client = new MockNextcloudWebDavClient(configService); + } + + @Test + void shouldListDirectoryUsingPropfindAndParseDavXml() { + client.enqueueResponse(207, """ + + + + /remote.php/dav/files/alex/AI/docs/ + + HTTP/1.1 200 OK + + + + + + + /remote.php/dav/files/alex/AI/docs/readme.md + + HTTP/1.1 200 OK + + + 5 + text/markdown + + + + + """, XML); + + List entries = client.listDirectory("docs"); + + Request request = client.getCapturedRequests().getFirst(); + assertEquals("PROPFIND", request.method()); + assertEquals("1", request.header("Depth")); + assertEquals("https://cloud.example.com/remote.php/dav/files/alex/AI/docs/", request.url().toString()); + assertEquals(1, entries.size()); + assertEquals("docs/readme.md", entries.getFirst().path()); + assertEquals("readme.md", entries.getFirst().name()); + assertEquals(false, entries.getFirst().directory()); + assertEquals(5L, entries.getFirst().size()); + } + + @Test + void shouldReadFileAndPreserveMetadata() { + client.enqueueResponse(200, "hello", OCTET_STREAM, "text/plain; charset=utf-8", "etag", + "Mon, 01 Jan 2026 00:00:00 GMT"); + + NextcloudFileContent content = client.readFile("docs/readme.md"); + + Request request = client.getCapturedRequests().getFirst(); + assertEquals("GET", request.method()); + assertEquals("https://cloud.example.com/remote.php/dav/files/alex/AI/docs/readme.md", request.url().toString()); + assertEquals("docs/readme.md", content.path()); + assertEquals("text/plain; charset=utf-8", content.mimeType()); + assertEquals("etag", content.etag()); + assertEquals("Mon, 01 Jan 2026 00:00:00 GMT", content.lastModified()); + assertEquals("hello", new String(content.bytes())); + } + + @Test + void shouldWriteBinaryFileUsingPut() { + client.enqueueResponse(201, "", OCTET_STREAM); + + client.writeFile("docs/archive.zip", new byte[] { 1, 2, 3 }); + + Request request = client.getCapturedRequests().getFirst(); + assertEquals("PUT", request.method()); + assertEquals("https://cloud.example.com/remote.php/dav/files/alex/AI/docs/archive.zip", + request.url().toString()); + assertEquals("application/zip", request.header("Content-Type")); + } + + @Test + void shouldCreateIntermediateDirectoriesWithMkcol() { + client.enqueueResponse(201, "", OCTET_STREAM); + client.enqueueResponse(405, "", OCTET_STREAM); + + client.createDirectoryRecursive("docs/archive"); + + assertEquals(2, client.getCapturedRequests().size()); + assertEquals("MKCOL", client.getCapturedRequests().get(0).method()); + assertEquals("https://cloud.example.com/remote.php/dav/files/alex/AI/docs/", + client.getCapturedRequests().get(0).url().toString()); + assertEquals("https://cloud.example.com/remote.php/dav/files/alex/AI/docs/archive/", + client.getCapturedRequests().get(1).url().toString()); + } + + @Test + void shouldSendDestinationHeaderForMoveAndCopy() { + client.enqueueResponse(201, "", OCTET_STREAM); + client.move("docs/a.txt", "archive/a.txt"); + Request moveRequest = client.getCapturedRequests().getFirst(); + assertEquals("MOVE", moveRequest.method()); + assertEquals("https://cloud.example.com/remote.php/dav/files/alex/AI/archive/a.txt", + moveRequest.header("Destination")); + + client.clear(); + client.enqueueResponse(201, "", OCTET_STREAM); + client.copy("docs/a.txt", "archive/a.txt"); + Request copyRequest = client.getCapturedRequests().getFirst(); + assertEquals("COPY", copyRequest.method()); + assertEquals("https://cloud.example.com/remote.php/dav/files/alex/AI/archive/a.txt", + copyRequest.header("Destination")); + } + + @Test + void shouldMapHttpErrorsToApiException() { + client.enqueueResponse(404, "missing", OCTET_STREAM); + + NextcloudApiException exception = assertThrows(NextcloudApiException.class, + () -> client.readFile("docs/readme.md")); + + assertEquals(404, exception.getStatusCode()); + assertTrue(exception.getMessage().contains("missing")); + } + + @Test + void shouldWrapTransportFailures() { + client.enqueueFailure(new IOException("timeout")); + + NextcloudTransportException exception = assertThrows(NextcloudTransportException.class, + () -> client.readFile("docs/readme.md")); + + assertTrue(exception.getMessage().contains("timeout")); + assertInstanceOf(IOException.class, exception.getCause()); + } + + @Test + void shouldFailWhenDavXmlIsMalformed() { + client.enqueueResponse(207, " client.listDirectory("docs")); + + assertEquals(500, exception.getStatusCode()); + assertTrue(exception.getMessage().contains("Invalid WebDAV XML")); + } + + private static final class MockNextcloudWebDavClient extends NextcloudWebDavClient { + + private final Queue plannedResponses = new ArrayDeque<>(); + private final List capturedRequests = new ArrayList<>(); + + private MockNextcloudWebDavClient(NextcloudPluginConfigService configService) { + super(configService); + } + + @Override + @SuppressWarnings("PMD.CloseResource") + protected Response executeRequest(Request request) throws IOException { + capturedRequests.add(request); + PlannedResponse planned = plannedResponses.remove(); + if (planned.failure() != null) { + throw planned.failure(); + } + ResponseBody responseBody = ResponseBody.create(planned.body(), planned.mediaType()); + Response.Builder builder = new Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(planned.code()) + .message("mock") + .body(responseBody); + if (planned.contentType() != null) { + builder.header("Content-Type", planned.contentType()); + } + if (planned.etag() != null) { + builder.header("ETag", planned.etag()); + } + if (planned.lastModified() != null) { + builder.header("Last-Modified", planned.lastModified()); + } + return builder.build(); + } + + private void enqueueResponse(int code, String body, MediaType mediaType) { + plannedResponses.add(new PlannedResponse(code, body, mediaType, null, null, null, null)); + } + + private void enqueueResponse(int code, String body, MediaType mediaType, String contentType, String etag, + String lastModified) { + plannedResponses.add(new PlannedResponse(code, body, mediaType, contentType, etag, lastModified, null)); + } + + private void enqueueFailure(IOException failure) { + plannedResponses.add(new PlannedResponse(0, "", OCTET_STREAM, null, null, null, failure)); + } + + private List getCapturedRequests() { + return capturedRequests; + } + + private void clear() { + capturedRequests.clear(); + plannedResponses.clear(); + } + } + + private record PlannedResponse( + int code, + String body, + MediaType mediaType, + String contentType, + String etag, + String lastModified, + IOException failure) { + } +} diff --git a/pom.xml b/pom.xml index 272ad83..49a052c 100644 --- a/pom.xml +++ b/pom.xml @@ -77,6 +77,7 @@ golemcore/browserless golemcore/obsidian golemcore/notion + golemcore/nextcloud golemcore/tavily-search golemcore/firecrawl golemcore/perplexity-sonar diff --git a/registry/golemcore/nextcloud/index.yaml b/registry/golemcore/nextcloud/index.yaml new file mode 100644 index 0000000..61d1191 --- /dev/null +++ b/registry/golemcore/nextcloud/index.yaml @@ -0,0 +1,7 @@ +id: golemcore/nextcloud +owner: golemcore +name: nextcloud +latest: 1.0.0 +versions: + - 1.0.0 +source: "https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/nextcloud" diff --git a/registry/golemcore/nextcloud/versions/1.0.0.yaml b/registry/golemcore/nextcloud/versions/1.0.0.yaml new file mode 100644 index 0000000..41eda7e --- /dev/null +++ b/registry/golemcore/nextcloud/versions/1.0.0.yaml @@ -0,0 +1,12 @@ +id: golemcore/nextcloud +version: 1.0.0 +pluginApiVersion: 1 +engineVersion: ">=0.0.0 <1.0.0" +artifactUrl: "dist/golemcore/nextcloud/1.0.0/golemcore-nextcloud-plugin-1.0.0.jar" +publishedAt: "2026-04-08T03:21:00Z" +sourceCommit: "0172b8e13a2b49a7bd7bdb3a0bf48e76371ab458" +entrypoint: me.golemcore.plugins.golemcore.nextcloud.NextcloudPluginBootstrap +sourceUrl: "https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/nextcloud" +license: "Apache-2.0" +maintainers: + - alexk-dev