├── .envrc ├── .gitignore ├── .goreleaser.yml ├── LICENSE ├── README.md ├── empty.html ├── flake.lock ├── flake.nix ├── go.mod ├── go.sum └── main.go /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | widdler 2 | .htpasswd 3 | dist/ 4 | .idea 5 | .direnv 6 | *.bak 7 | result 8 | tags 9 | wikis 10 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod tidy 4 | - go vet 5 | - staticcheck 6 | - gosec . 7 | builds: 8 | - env: 9 | - CGO_ENABLED=0 10 | targets: 11 | - openbsd_amd64 12 | - openbsd_arm64 13 | - openbsd_386 14 | - openbsd_arm 15 | - freebsd_amd64 16 | - freebsd_arm64 17 | - freebsd_386 18 | - darwin_amd64 19 | - darwin_arm64 20 | - windows_amd64 21 | - netbsd_amd64 22 | - netbsd_arm64 23 | - netbsd_386 24 | - dragonfly_amd64 25 | goos: 26 | - openbsd 27 | - freebsd 28 | - netbsd 29 | - dragonfly 30 | - linux 31 | - windows 32 | - darwin 33 | ldflags: 34 | - -s -w -X main.build={{.Version}} 35 | signs: 36 | - artifacts: checksum 37 | source: 38 | enabled: true 39 | name_template: '{{ .ProjectName }}-src-{{ .Version }}' 40 | checksum: 41 | name_template: 'checksums.txt' 42 | snapshot: 43 | name_template: "{{ incpatch .Version }}-next" 44 | changelog: 45 | sort: asc 46 | filters: 47 | exclude: 48 | - '^docs:' 49 | - '^test:' 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021-2024 Aaron Bieber 3 | * 4 | * Permission to use, copy, modify, and distribute this software for any 5 | * purpose with or without fee is hereby granted, provided that the above 6 | * copyright notice and this permission notice appear in all copies. 7 | * 8 | * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 9 | * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 10 | * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 11 | * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 12 | * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 13 | * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 14 | * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 15 | */ 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | widdler 2 | ======= 3 | 4 | widdler is a single binary that serves up 5 | [TiddlyWiki](https://tiddlywiki.com)s. 6 | 7 | It can be used to serve existing wikis, or to create new ones. 8 | 9 | # Features 10 | 11 | - TiddlyWikis are served over WebDav so you can save directly from the browser. 12 | - Automatically create new wiki files by browsing to a non-existent html file. 13 | - Built in .htpasswd management (Adding users). 14 | - Password protection via HTTP Basic Authentication. 15 | - Multiple users (adding another user to the .htaccess file creates a new user 16 | namespace). 17 | - Optional TLS support. 18 | 19 | # Installation 20 | 21 | For Go 1.16: 22 | ``` 23 | go get -u suah.dev/widdler 24 | ``` 25 | 26 | For Go 1.17 and up: 27 | ``` 28 | go install suah.dev/widdler@latest 29 | ``` 30 | 31 | # Running 32 | 33 | ``` 34 | mkdir wiki 35 | cd wiki 36 | # Generate a .htpasswd file: 37 | widdler -gen 38 | Username: qbit 39 | Passwd: ****** 40 | # Start the server 41 | ./widdler 42 | ``` 43 | 44 | Now open your browser to [http://localhost:8080](http://localhost:8080). 45 | 46 | # Creating a new TiddlyWiki 47 | 48 | Simply browse to the file name you wish to create. widdler will automatically 49 | create the wiki file based off the current `empty.html` TiddlyWiki version. 50 | 51 | # Saving changes 52 | 53 | Simply hit the save button! 54 | 55 | # Updating widdler 56 | 57 | ``` 58 | go install suah.dev/widdler@latest 59 | ``` 60 | 61 | # Running without .htpasswd 62 | 63 | You can disable auth all together by setting the `-auth` flag to false: 64 | 65 | ``` 66 | widdler -auth=false -wikis ~/wiki 67 | ``` 68 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1721379653, 6 | "narHash": "sha256-8MUgifkJ7lkZs3u99UDZMB4kbOxvMEXQZ31FO3SopZ0=", 7 | "owner": "NixOS", 8 | "repo": "nixpkgs", 9 | "rev": "1d9c2c9b3e71b9ee663d11c5d298727dace8d374", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "id": "nixpkgs", 14 | "ref": "nixos-unstable", 15 | "type": "indirect" 16 | } 17 | }, 18 | "root": { 19 | "inputs": { 20 | "nixpkgs": "nixpkgs" 21 | } 22 | } 23 | }, 24 | "root": "root", 25 | "version": 7 26 | } 27 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "widdler: A WebDAV server for TiddlyWikis "; 3 | 4 | inputs.nixpkgs.url = "nixpkgs/nixos-unstable"; 5 | 6 | outputs = 7 | { self 8 | , nixpkgs 9 | , 10 | }: 11 | let 12 | supportedSystems = [ "x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin" ]; 13 | forAllSystems = nixpkgs.lib.genAttrs supportedSystems; 14 | nixpkgsFor = forAllSystems (system: import nixpkgs { inherit system; }); 15 | in 16 | { 17 | overlay = _: prev: { inherit (self.packages.${prev.system}) widdler; }; 18 | 19 | packages = forAllSystems (system: 20 | let 21 | pkgs = nixpkgsFor.${system}; 22 | in 23 | { 24 | widdler = pkgs.buildGo122Module { 25 | pname = "widdler"; 26 | version = "v1.2.5"; 27 | src = ./.; 28 | 29 | vendorHash = "sha256-R2NkKxDPfZXVIaVbRYutw5DXYhk4NVniQOeVaJcuZNU="; 30 | }; 31 | }); 32 | 33 | defaultPackage = forAllSystems (system: self.packages.${system}.widdler); 34 | devShells = forAllSystems (system: 35 | let 36 | pkgs = nixpkgsFor.${system}; 37 | in 38 | { 39 | default = pkgs.mkShell { 40 | shellHook = '' 41 | PS1='\u@\h:\@; ' 42 | nix flake run github:qbit/xin#flake-warn 43 | echo "Go `${pkgs.go}/bin/go version`" 44 | ''; 45 | nativeBuildInputs = with pkgs; [ 46 | git 47 | go 48 | gopls 49 | goreleaser 50 | gosec 51 | go-tools 52 | nilaway 53 | ]; 54 | }; 55 | }); 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module suah.dev/widdler 2 | 3 | go 1.22.2 4 | 5 | require ( 6 | golang.org/x/crypto v0.22.0 7 | golang.org/x/net v0.24.0 8 | golang.org/x/term v0.20.0 9 | suah.dev/protect v1.2.4 10 | ) 11 | 12 | require golang.org/x/sys v0.20.0 // indirect 13 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= 2 | golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= 3 | golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= 4 | golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= 5 | golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= 6 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 7 | golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= 8 | golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 9 | suah.dev/protect v1.2.4 h1:iVZG/zQB63FKNpITDYM/cXoAeCTIjCiXHuFVByJFDzg= 10 | suah.dev/protect v1.2.4/go.mod h1:vVrquYO3u1Ep9Ez2z8x+6N6/czm+TBmWKZfiXU2tb54= 11 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | "embed" 6 | "encoding/csv" 7 | "flag" 8 | "fmt" 9 | "log" 10 | "net" 11 | "net/http" 12 | "os" 13 | "path" 14 | "path/filepath" 15 | "regexp" 16 | "strings" 17 | "sync" 18 | "text/template" 19 | "time" 20 | 21 | "golang.org/x/crypto/bcrypt" 22 | "golang.org/x/net/webdav" 23 | "golang.org/x/term" 24 | "suah.dev/protect" 25 | ) 26 | 27 | // Landing will be used to fill our landing template 28 | type Landing struct { 29 | User string 30 | URL string 31 | } 32 | 33 | const landingPage = ` 34 |

Hello{{if .User}} {{.User}}{{end}}! Welcome to widdler!

35 | 36 |

To create a new TiddlyWiki html file, simply append an html file name to the URL in the address bar!

37 | 38 |

For example:

39 | 40 | {{.URL}} 41 | 42 |

This will create a new wiki called "wiki.html"

43 | 44 |

After creating a wiki, this message will be replaced by a list of your wiki files.

45 | ` 46 | 47 | var ( 48 | twFile = "empty.html" 49 | 50 | //go:embed empty.html 51 | tiddly embed.FS 52 | templ *template.Template 53 | ) 54 | 55 | type userHandler struct { 56 | mu sync.Mutex 57 | dav *webdav.Handler 58 | fs http.Handler 59 | name string 60 | } 61 | 62 | type userHandlers struct { 63 | list []userHandler 64 | mu sync.RWMutex 65 | } 66 | 67 | func (u *userHandlers) find(name string) *userHandler { 68 | for i := range u.list { 69 | if u.list[i].name == name { 70 | return &u.list[i] 71 | } 72 | } 73 | return nil 74 | } 75 | 76 | var ( 77 | auth string 78 | davDir string 79 | fullListen string 80 | genHtpass bool 81 | handlers userHandlers 82 | listen string 83 | passPath string 84 | tlsCert string 85 | tlsKey string 86 | users map[string]string 87 | version bool 88 | build string 89 | ) 90 | 91 | var pledges = "stdio wpath rpath cpath tty inet dns unveil" 92 | 93 | func init() { 94 | users = make(map[string]string) 95 | dir, err := filepath.Abs(filepath.Dir(os.Args[0])) 96 | if err != nil { 97 | log.Fatalln(err) 98 | } 99 | 100 | flag.StringVar(&davDir, "wikis", dir, "Directory of TiddlyWikis to serve over WebDAV.") 101 | flag.StringVar(&listen, "http", "localhost:8080", "Listen on") 102 | flag.StringVar(&tlsCert, "tlscert", "", "TLS certificate.") 103 | flag.StringVar(&tlsKey, "tlskey", "", "TLS key.") 104 | flag.StringVar(&passPath, "htpass", fmt.Sprintf("%s/.htpasswd", dir), "Path to .htpasswd file..") 105 | flag.StringVar(&auth, "auth", "basic", "Enable HTTP Basic Authentication.") 106 | flag.BoolVar(&genHtpass, "gen", false, "Generate a .htpasswd file or add a new entry to an existing file.") 107 | flag.BoolVar(&version, "v", false, "Show version and exit.") 108 | flag.Parse() 109 | 110 | // These are OpenBSD specific protections used to prevent unnecessary file access. 111 | _ = protect.Unveil(passPath, "rwc") 112 | _ = protect.Unveil(davDir, "rwc") 113 | _ = protect.Unveil("/etc/ssl/cert.pem", "r") 114 | _ = protect.Unveil("/etc/resolv.conf", "r") 115 | _ = protect.Pledge(pledges) 116 | 117 | templ, err = template.New("landing").Parse(landingPage) 118 | if err != nil { 119 | log.Fatalln(err) 120 | } 121 | } 122 | 123 | func authenticate(user string, pass string) bool { 124 | htpass, exists := users[user] 125 | 126 | if !exists { 127 | return false 128 | } 129 | 130 | err := bcrypt.CompareHashAndPassword([]byte(htpass), []byte(pass)) 131 | return err == nil 132 | } 133 | 134 | func logger(f http.HandlerFunc) http.HandlerFunc { 135 | return func(w http.ResponseWriter, r *http.Request) { 136 | n := time.Now() 137 | fmt.Printf("%s (%s) [%s] \"%s %s\" %03d\n", 138 | r.RemoteAddr, 139 | n.Format(time.RFC822Z), 140 | r.Method, 141 | r.URL.Path, 142 | r.Proto, 143 | r.ContentLength, 144 | ) 145 | f(w, r) 146 | } 147 | } 148 | 149 | func createEmpty(path string) error { 150 | _, fErr := os.Stat(path) 151 | if os.IsNotExist(fErr) { 152 | log.Printf("creating %q\n", path) 153 | twData, _ := tiddly.ReadFile(twFile) 154 | wErr := os.WriteFile(path, twData, 0600) 155 | if wErr != nil { 156 | return wErr 157 | } 158 | } 159 | return nil 160 | } 161 | 162 | func prompt(prompt string, secure bool) (string, error) { 163 | var input string 164 | fmt.Print(prompt) 165 | 166 | if secure { 167 | b, err := term.ReadPassword(int(os.Stdin.Fd())) 168 | if err != nil { 169 | return "", err 170 | } 171 | input = string(b) 172 | } else { 173 | _, err := fmt.Scanln(&input) 174 | if err != nil { 175 | return "", err 176 | } 177 | } 178 | return input, nil 179 | } 180 | 181 | func addHandler(u, uPath string) { 182 | handlers.list = append(handlers.list, userHandler{ 183 | name: u, 184 | dav: &webdav.Handler{ 185 | LockSystem: webdav.NewMemLS(), 186 | FileSystem: webdav.Dir(uPath), 187 | Logger: func(r *http.Request, err error) { 188 | if err != nil { 189 | log.Print(err) 190 | } 191 | }, 192 | }, 193 | fs: http.FileServer(http.Dir(uPath)), 194 | }) 195 | } 196 | 197 | func main() { 198 | if version { 199 | fmt.Println(build) 200 | os.Exit(0) 201 | } 202 | if genHtpass { 203 | user, err := prompt("Username: ", false) 204 | if err != nil { 205 | log.Fatalln(err) 206 | } 207 | 208 | pass, err := prompt("Password: ", true) 209 | if err != nil { 210 | log.Fatalln(err) 211 | } 212 | 213 | hash, err := bcrypt.GenerateFromPassword([]byte(pass), 11) 214 | if err != nil { 215 | log.Fatalln(err) 216 | } 217 | 218 | f, err := os.OpenFile(filepath.Clean(passPath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) 219 | if err != nil { 220 | log.Fatalln(err) 221 | } 222 | 223 | if _, err := f.WriteString(fmt.Sprintf("%s:%s\n", user, hash)); err != nil { 224 | log.Fatalln(err) 225 | } 226 | 227 | err = f.Close() 228 | if err != nil { 229 | log.Fatalln(err) 230 | } 231 | 232 | fmt.Printf("Added %q to %q\n", user, passPath) 233 | 234 | os.Exit(0) 235 | } 236 | pledges, _ = protect.ReducePledges(pledges, "tty") 237 | 238 | // drop to only read on passPath 239 | _ = protect.Unveil(passPath, "r") 240 | pledges, _ = protect.ReducePledges(pledges, "unveil") 241 | 242 | _, fErr := os.Stat(passPath) 243 | if os.IsNotExist(fErr) { 244 | if auth == "basic" || auth == "header" { 245 | fmt.Println("No .htpasswd file found!") 246 | os.Exit(1) 247 | } 248 | } else { 249 | p, err := os.Open(filepath.Clean(passPath)) 250 | if err != nil { 251 | log.Fatal(err) 252 | } 253 | 254 | ht := csv.NewReader(p) 255 | ht.Comma = ':' 256 | ht.Comment = '#' 257 | ht.TrimLeadingSpace = true 258 | 259 | entries, err := ht.ReadAll() 260 | if err != nil { 261 | log.Fatal(err) 262 | } 263 | 264 | err = p.Close() 265 | if err != nil { 266 | log.Fatal(err) 267 | } 268 | 269 | for _, parts := range entries { 270 | users[parts[0]] = parts[1] 271 | } 272 | } 273 | 274 | if auth == "basic" || auth == "header" { 275 | for u := range users { 276 | uPath := path.Join(davDir, u) 277 | addHandler(u, uPath) 278 | } 279 | } else { 280 | addHandler("", davDir) 281 | } 282 | 283 | mux := http.NewServeMux() 284 | mux.HandleFunc("/", logger(func(w http.ResponseWriter, r *http.Request) { 285 | user, pass := "", "" 286 | var ok bool 287 | 288 | if strings.Contains(r.URL.Path, ".htpasswd") { 289 | http.NotFound(w, r) 290 | return 291 | } 292 | 293 | // Prevent directory traversal 294 | if strings.Contains(r.URL.Path, "..") { 295 | http.NotFound(w, r) 296 | return 297 | } 298 | 299 | if auth == "basic" { 300 | user, pass, ok = r.BasicAuth() 301 | if !(ok && authenticate(user, pass)) { 302 | w.Header().Set("WWW-Authenticate", `Basic realm="widdler"`) 303 | http.Error(w, "Unauthorized", http.StatusUnauthorized) 304 | return 305 | } 306 | } else if auth == "header" { 307 | var prefix = "Auth" 308 | for name, values := range r.Header { 309 | if strings.HasPrefix(name, prefix) { 310 | user = strings.TrimLeft(name, prefix) 311 | pass = values[0] 312 | ok = true 313 | break 314 | } 315 | } 316 | 317 | if !(ok && authenticate(user, pass)) { 318 | w.Header().Set("WWW-Authenticate", `Basic realm="widdler"`) 319 | http.Error(w, "Unauthorized", http.StatusUnauthorized) 320 | return 321 | } 322 | } 323 | 324 | handlers.mu.RLock() 325 | handler := handlers.find(user) 326 | handlers.mu.RUnlock() 327 | 328 | if handler == nil { 329 | http.NotFound(w, r) 330 | return 331 | } 332 | 333 | handler.mu.Lock() 334 | 335 | defer handler.mu.Unlock() 336 | 337 | userPath := path.Join(davDir, user) 338 | fullPath := path.Join(davDir, user, r.URL.Path) 339 | 340 | _, dErr := os.Stat(userPath) 341 | if os.IsNotExist(dErr) { 342 | mErr := os.Mkdir(userPath, 0700) 343 | if mErr != nil { 344 | http.Error(w, mErr.Error(), http.StatusInternalServerError) 345 | return 346 | } 347 | } 348 | 349 | isHTML, err := regexp.Match(`\.html$`, []byte(r.URL.Path)) 350 | if err != nil { 351 | http.Error(w, err.Error(), http.StatusInternalServerError) 352 | return 353 | } 354 | 355 | if isHTML { 356 | // HTML files will be created or sent back 357 | err := createEmpty(fullPath) 358 | if err != nil { 359 | log.Println(err) 360 | http.Error(w, err.Error(), http.StatusInternalServerError) 361 | return 362 | } 363 | handler.dav.ServeHTTP(w, r) 364 | } else { 365 | // Everything else is browsable 366 | entries, err := os.ReadDir(userPath) 367 | if err != nil { 368 | log.Println(err) 369 | http.Error(w, err.Error(), http.StatusInternalServerError) 370 | return 371 | } 372 | 373 | if len(entries) > 0 { 374 | if r.URL.Path == "/" { 375 | // If we have entries, and are serving up /, check for 376 | // index.html and redirect to that if it exists. We redirect 377 | // because net/http handles index.html magically for FileServer 378 | _, fErr := os.Stat(filepath.Clean(path.Join(userPath, "index.html"))) 379 | if !os.IsNotExist(fErr) { 380 | http.Redirect(w, r, "/index.html", http.StatusMovedPermanently) 381 | return 382 | } 383 | } 384 | handler.fs.ServeHTTP(w, r) 385 | } else { 386 | l := Landing{ 387 | URL: fmt.Sprintf("%s/wiki.html", fullListen), 388 | } 389 | if user != "" { 390 | l.User = user 391 | } 392 | err = templ.ExecuteTemplate(w, "landing", l) 393 | if err != nil { 394 | log.Println(err) 395 | http.Error(w, err.Error(), http.StatusInternalServerError) 396 | } 397 | } 398 | } 399 | })) 400 | 401 | s := http.Server{ 402 | Handler: mux, 403 | ReadHeaderTimeout: 0, 404 | } 405 | 406 | lis, err := net.Listen("tcp", listen) 407 | if err != nil { 408 | log.Fatalln(err) 409 | } 410 | 411 | if tlsCert != "" && tlsKey != "" { 412 | fullListen = fmt.Sprintf("https://%s", listen) 413 | 414 | s.TLSConfig = &tls.Config{ 415 | MinVersion: tls.VersionTLS12, 416 | CurvePreferences: []tls.CurveID{tls.CurveP521, tls.CurveP384, tls.CurveP256}, 417 | PreferServerCipherSuites: true, 418 | } 419 | 420 | log.Printf("Listening for HTTPS on 'https://%s'", listen) 421 | log.Fatalln(s.ServeTLS(lis, tlsCert, tlsKey)) 422 | } else { 423 | fullListen = fmt.Sprintf("http://%s", listen) 424 | 425 | log.Printf("Listening for HTTP on 'http://%s'", listen) 426 | log.Fatalln(s.Serve(lis)) 427 | } 428 | } 429 | --------------------------------------------------------------------------------