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