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)
}
}
~