svg

Build Simple Web Dav Server Using Golang

webdav golang

I am searching for an alternative way to sync my Obsidian notes. Currently, I’m using Syncthing, which works pretty well, except on Android. The Syncthing client on Android has been discontinued. There is still a fork, but I’m interested in finding an alternative. There are plenty of options, and WebDAV is one of them.

Going down the rabbit hole, I searched for WebDAV servers, and someone recommended Nextcloud. No, that’s too heavy for me. I need something small and lightweight, just to do a proof of concept (PoC) for syncing. Then I found “golang.org/x/net/webdav”. Cool, it seems neat. Let’s try to implement it with just a few lines of code:

mkdir simple-webdav
cd simple-webdav
nvim main.go

and this is the main.go file

package main

import (
        "flag"
        "net/http"

        "golang.org/x/net/webdav"
)

func main() {
        var address string
        var directory string

        flag.StringVar(&address, "a", "localhost:8080", "Address to listen to.")
        flag.StringVar(&directory, "d", ".", "Path to share via WebDav.")
        flag.Parse()

        handler := &webdav.Handler{
                FileSystem: webdav.Dir(directory),
                LockSystem: webdav.NewMemLS(),
        }

        http.ListenAndServe(address, handler)
}
go mod init github.com/nalakawula/simple-webdav
go mod tidy
go build

Then I tried running the binary to serve a dir, and testing it on my Android device. Cool! I could connect and browse files via WebDAV.

Now, going deeper into the rabbit hole, here is my latest main.go:



package main

import (
	"context"
	"encoding/base64"
	"flag"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"strings"
	"syscall"
	"time"

	"golang.org/x/net/webdav"
)

const (
	DefaultAddress   = "127.0.0.1:8080"
	DefaultSharePath = "."
	ShutdownTimeout  = 5 * time.Second
	BasicAuthRealm   = "WebDAV Server"
)

var readOnlyMethods = map[string]bool{
	"PUT":       true,
	"DELETE":    true,
	"MKCOL":     true,
	"MOVE":      true,
	"COPY":      true,
	"PROPPATCH": true,
	"LOCK":      true,
	"UNLOCK":    true,
}

type Config struct {
	Address   string
	SharePath string
	Username  string
	Password  string
	ReadOnly  bool
}

func parseFlags() *Config {
	config := &Config{}

	flag.Usage = func() {
		fmt.Fprintf(flag.CommandLine.Output(), "Usage: %s [OPTIONS]\n", os.Args[0])
		fmt.Fprintf(flag.CommandLine.Output(), "\nWebDAV Server - Share directories via WebDAV protocol\n\n")
		fmt.Fprintf(flag.CommandLine.Output(), "Options:\n")
		flag.PrintDefaults()
		fmt.Fprintf(flag.CommandLine.Output(), "\nExamples:\n")
		fmt.Fprintf(flag.CommandLine.Output(), "  %s -a :8080 -d /path/to/share\n", os.Args[0])
		fmt.Fprintf(flag.CommandLine.Output(), "  %s -a :8080 -d /path/to/share -u user -p pass -r\n", os.Args[0])
	}

	flag.StringVar(&config.Address, "a", DefaultAddress, "Address to listen on (e.g., 0.0.0.0:8080)")
	flag.StringVar(&config.SharePath, "d", DefaultSharePath, "Directory path to share via WebDAV")
	flag.StringVar(&config.Username, "u", "", "Username for Basic Auth (optional)")
	flag.StringVar(&config.Password, "p", "", "Password for Basic Auth (optional)")
	flag.BoolVar(&config.ReadOnly, "r", false, "Enable read-only mode (no write/delete)")

	flag.Parse()

	if flag.NArg() > 0 {
		fmt.Fprintf(os.Stderr, "❌ Error: Unexpected arguments: %v\n\n", flag.Args())
		flag.Usage()
		os.Exit(1)
	}

	return config
}

func validateConfig(config *Config) error {
	if _, err := os.Stat(config.SharePath); os.IsNotExist(err) {
		return fmt.Errorf("share path does not exist: %s", config.SharePath)
	}

	if (config.Username != "" && config.Password == "") || (config.Username == "" && config.Password != "") {
		return fmt.Errorf("both username and password must be provided for authentication")
	}

	return nil
}

func AuthMiddleware(h http.Handler, username, password string) http.Handler {
	if username == "" || password == "" {
		return h
	}

	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !checkBasicAuth(r, username, password) {
			w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm="%s"`, BasicAuthRealm))
			http.Error(w, "Unauthorized", http.StatusUnauthorized)
			return
		}
		h.ServeHTTP(w, r)
	})
}

func checkBasicAuth(r *http.Request, expectedUser, expectedPass string) bool {
	authHeader := r.Header.Get("Authorization")
	if authHeader == "" || !strings.HasPrefix(authHeader, "Basic ") {
		return false
	}

	payload, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authHeader, "Basic "))
	if err != nil {
		log.Printf("Failed to decode auth header: %v", err)
		return false
	}

	credentials := strings.SplitN(string(payload), ":", 2)
	if len(credentials) != 2 {
		log.Printf("Invalid auth format")
		return false
	}

	return credentials[0] == expectedUser && credentials[1] == expectedPass
}

func LoggingMiddleware(h http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()

		lrw := &loggingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}

		h.ServeHTTP(lrw, r)

		duration := time.Since(start)
		log.Printf("%s %s %d %v from %s",
			r.Method, r.URL.Path, lrw.statusCode, duration, r.RemoteAddr)
	})
}

type loggingResponseWriter struct {
	http.ResponseWriter
	statusCode int
}

func (lrw *loggingResponseWriter) WriteHeader(code int) {
	lrw.statusCode = code
	lrw.ResponseWriter.WriteHeader(code)
}

func ReadOnlyMiddleware(h http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if readOnlyMethods[r.Method] {
			log.Printf("Blocked %s request to %s (read-only mode)", r.Method, r.URL.Path)
			http.Error(w, "Read-only mode: modification not allowed", http.StatusForbidden)
			return
		}
		h.ServeHTTP(w, r)
	})
}

func createWebDAVHandler(config *Config) http.Handler {
	handler := &webdav.Handler{
		FileSystem: webdav.Dir(config.SharePath),
		LockSystem: webdav.NewMemLS(),
	}

	var h http.Handler = handler

	if config.ReadOnly {
		h = ReadOnlyMiddleware(h)
	}

	h = AuthMiddleware(h, config.Username, config.Password)
	h = LoggingMiddleware(h)

	return h
}

func createServer(config *Config, handler http.Handler) *http.Server {
	mux := http.NewServeMux()
	mux.Handle("/", handler)

	return &http.Server{
		Addr:           config.Address,
		Handler:        mux,
		ReadTimeout:    30 * time.Second,
		WriteTimeout:   30 * time.Second,
		IdleTimeout:    60 * time.Second,
		MaxHeaderBytes: 1 << 20, // 1 MB
	}
}

func logServerInfo(config *Config) {
	log.Printf("Serving %s via WebDAV on http://%s/", config.SharePath, config.Address)

	if config.Username != "" {
		log.Printf("Basic Auth enabled for user: %s", config.Username)
	} else {
		log.Printf("No authentication set — open access!")
	}

	if config.ReadOnly {
		log.Printf("Read-only mode enabled — write operations blocked")
	}
}

func runServer(server *http.Server) error {
	// Create channel for shutdown signals
	stop := make(chan os.Signal, 1)
	signal.Notify(stop, os.Interrupt, syscall.SIGTERM)

	// Start server in goroutine
	serverErr := make(chan error, 1)
	go func() {
		if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			serverErr <- fmt.Errorf("server failed: %w", err)
		}
	}()

	// Wait for shutdown signal or server error
	select {
	case err := <-serverErr:
		return err
	case <-stop:
		log.Println("Shutting down server gracefully...")

		// Create shutdown context with timeout
		ctx, cancel := context.WithTimeout(context.Background(), ShutdownTimeout)
		defer cancel()

		if err := server.Shutdown(ctx); err != nil {
			return fmt.Errorf("graceful shutdown failed: %w", err)
		}

		log.Println("Server stopped cleanly.")
		return nil
	}
}

func main() {
	config := parseFlags()

	if err := validateConfig(config); err != nil {
		log.Fatalf("❌ Configuration error: %v", err)
	}

	handler := createWebDAVHandler(config)

	server := createServer(config, handler)

	logServerInfo(config)

	if err := runServer(server); err != nil {
		log.Fatalf("❌ %v", err)
	}
}

~