diff --git a/.gitignore b/.gitignore index ab06424..9c916d6 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,5 @@ go.work.sum watcher lts + +ALLOWED_USERS.txt diff --git a/go.mod b/go.mod index 5b39b6e..aef7a16 100644 --- a/go.mod +++ b/go.mod @@ -2,3 +2,7 @@ module lts go 1.24.3 +require ( + github.com/fsnotify/fsnotify v1.9.0 // indirect + golang.org/x/sys v0.13.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c1e3272 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/lts.go b/lts.go index 8d6a65d..0aa2062 100644 --- a/lts.go +++ b/lts.go @@ -1,10 +1,20 @@ package main import ( + "bufio" + "crypto/subtle" "embed" + "fmt" "html/template" + "io/ioutil" "log" "net/http" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/fsnotify/fsnotify" ) //go:embed tmpls/* @@ -13,14 +23,232 @@ var tmpls embed.FS //go:embed etc/* var etc embed.FS -func main() { - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - tmpl := template.Must(template.ParseFS(tmpls, "tmpls/*.html")) - if err := tmpl.Execute(w, nil); err != nil { - log.Fatal(err) +func createFile(storePath, name string) (*os.File, error) { + if _, err := os.Stat(storePath); os.IsNotExist(err) { + os.Mkdir(storePath, 0755) + } + + uuidBytes, err := NewV4() + if err != nil { + return nil, err + } + uuid := uuidBytes.String() + + dst, err := os.Create(filepath.Join(storePath, fmt.Sprintf("%s-%s", uuid, name))) + if err != nil { + return nil, err + } + return dst, nil +} + +type AllowedUser struct { + Name string + Password string +} + +var allowedUsers []AllowedUser +var allowedUsersMutex sync.Mutex + +func basicAuth(handler http.HandlerFunc, realm string) http.HandlerFunc { + + return func(w http.ResponseWriter, r *http.Request) { + user, pass, ok := r.BasicAuth() + if !ok { + return } - }) + + allowedUsersMutex.Lock() + for _, allowedUser := range allowedUsers { + if subtle.ConstantTimeCompare([]byte(user), []byte(allowedUser.Name)) == 1 { + if subtle.ConstantTimeCompare([]byte(pass), []byte(allowedUser.Password)) == 1 { + goto auth_ok + } else { + goto auth_fail + } + } + } + goto auth_fail + + auth_ok: + allowedUsersMutex.Unlock() + handler(w, r) + return + + auth_fail: + allowedUsersMutex.Unlock() + w.Header().Set("WWW-Authenticate", `Basic realm="`+realm+`"`) + w.WriteHeader(401) + w.Write([]byte("Unauthorised.\n")) + return + } +} + +var storePath string + +// https://yourbasic.org/golang/formatting-byte-size-to-human-readable-format/ +func byteCountSI(b int64) string { + const unit = 1000 + if b < unit { + return fmt.Sprintf("%d B", b) + } + div, exp := int64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", + float64(b)/float64(div), "kMGTPE"[exp]) +} + +func handleUpload(w http.ResponseWriter, r *http.Request) { + limit := int64(10737418240) // 10GiB + r.ParseMultipartForm(limit) + + file, handler, err := r.FormFile("myfile") + if err != nil { + log.Println(err) + http.Error(w, "Could not get the file", http.StatusBadRequest) + return + } + defer file.Close() + + fmt.Fprintf(w, "Uploaded file: %s\n", handler.Filename) + fmt.Fprintf(w, "File size: %s\n", byteCountSI(handler.Size)) + + dst, err := createFile(storePath, handler.Filename) + if err != nil { + http.Error(w, "Could not save file", http.StatusInternalServerError) + return + } + defer dst.Close() + + if _, err := dst.ReadFrom(file); err != nil { + http.Error(w, "Error saving file", http.StatusInternalServerError) + } +} + +func handleHome(w http.ResponseWriter, r *http.Request) { + home_tmpl, _ := tmpls.ReadFile("tmpls/home.html") + tmpl := template.Must(template.New("home").Parse(string(home_tmpl))) + if err := tmpl.Execute(w, nil); err != nil { + log.Println(err) + } +} + +type BrowseRecord struct { + FileName string + ReadableName string + Perm string + Modtime string +} + +func handleBrowse(w http.ResponseWriter, r *http.Request) { + browse_tmpl, _ := tmpls.ReadFile("tmpls/browse.html") + tmpl := template.Must(template.New("browse").Parse(string(browse_tmpl))) + + storeEntries, err := os.ReadDir(storePath) + if err != nil { + log.Println(err) + return + } + + var records []BrowseRecord + + for _, e := range storeEntries { + filename := e.Name() + readable_name := filename[37:] // 32 hex + 4 `-` + 1 `-` + info, _ := e.Info() + + modtime := fmt.Sprintf("%d/%s/%d %02d:%02d:%02d", + info.ModTime().Day(), info.ModTime().Month().String(), info.ModTime().Year(), + info.ModTime().Hour(), info.ModTime().Minute(), info.ModTime().Second(), + ) + + records = append(records, BrowseRecord{ + FileName: filename, + ReadableName: readable_name, + Perm: info.Mode().Perm().String(), + Modtime: modtime, + }) + } + + if err := tmpl.Execute(w, records); err != nil { + log.Println(err) + } +} + +func reloadAllowedUsers() { + log.Println("loading ALLOWED_USERS.txt") + allowedUsersTxt, err := ioutil.ReadFile("./ALLOWED_USERS.txt") + if err != nil { + log.Println(err) + return + } + scanner := bufio.NewScanner(strings.NewReader(string(allowedUsersTxt))) + allowedUsersMutex.Lock() + for scanner.Scan() { + line := scanner.Text() + parts := strings.Fields(line) + allowedUsers = append(allowedUsers, AllowedUser{ Name: parts[0], Password: parts[1] }) + } + allowedUsersMutex.Unlock() +} + +func watchAllowedUsers() chan bool { + watcher, err := fsnotify.NewWatcher() + if err != nil { + log.Fatal(err) + } + defer watcher.Close() + + done := make(chan bool) + + go func() { + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return + } + if event.Has(fsnotify.Chmod | fsnotify.Rename) { + reloadAllowedUsers() + } + case err, ok := <-watcher.Errors: + if !ok { + return + } + log.Println(err) + case <-done: + break + } + } + }() + + err = watcher.Add("./ALLOWED_USERS.txt") + if err != nil { + log.Fatal(err) + } + + return done +} + +func main() { + storePathValue, ok := os.LookupEnv("LTS_STORE_PATH") + if !ok { + log.Fatal("LTS_STORE_PATH not set") + } + storePath = storePathValue + + reloadAllowedUsers() + doneWatching := watchAllowedUsers() + + http.HandleFunc("/", handleHome) + http.HandleFunc("/browse", handleBrowse) + http.HandleFunc("/upload", basicAuth(handleUpload, "Enter username and password")) http.Handle("/etc/", http.FileServerFS(etc)) + http.Handle("/store/", http.StripPrefix("/store/", http.FileServer(http.Dir(storePath)))) http.ListenAndServe(":9090", nil) + + doneWatching<-true } diff --git a/tmpls/browse.html b/tmpls/browse.html new file mode 100644 index 0000000..b6f8d9e --- /dev/null +++ b/tmpls/browse.html @@ -0,0 +1,18 @@ + +
+{{.ReadableName}} | +{{.Perm}} | +{{.Modtime}} | +
Welcome to The Lair's temporary storage service. Use this service to transport files via
- a temporary remote storage. ONLY WHITELISTED IPs CAN UPLOAD!!
+ a temporary remote storage.
+ ONLY WHITELISTED USERS can upload! For access, reach out to me.
+ Only 30PLN / 12MON.
+
+ ++ Browse the files here +
+