소스 검색

Added thumbnail rendering endpoint

TC 3 일 전
부모
커밋
63d3db3c83
54개의 변경된 파일4074개의 추가작업 그리고 100개의 파일을 삭제
  1. 7 1
      go.mod
  2. 14 2
      go.sum
  3. 18 14
      main.go
  4. 7 7
      mod/bokofs/bokofile/bokodir.go
  5. 61 10
      mod/bokofs/bokofs.go
  6. 127 0
      mod/bokofs/bokothumb/bokodir.go
  7. 37 0
      mod/bokofs/bokothumb/bokothumb.go
  8. 31 39
      mod/bokofs/bokoworker/bokoworker.go
  9. 61 15
      mod/bokofs/router.go
  10. 1 1
      mod/bokofs/utils.go
  11. 5 11
      mod/bokofs/vobject.go
  12. 0 0
      mod/disktool/SMART/extract.go
  13. 0 0
      mod/disktool/SMART/smart.go
  14. 272 0
      mod/disktool/diskfs/diskfs.go
  15. 48 0
      mod/disktool/diskfs/disklb.go
  16. 681 0
      mod/disktool/raid/handler.go
  17. 135 0
      mod/disktool/raid/losetup.go
  18. 318 0
      mod/disktool/raid/mdadm.go
  19. 205 0
      mod/disktool/raid/mdadmConf.go
  20. 87 0
      mod/disktool/raid/raid.go
  21. 76 0
      mod/disktool/raid/raid_test.go
  22. 176 0
      mod/disktool/raid/raiddetails.go
  23. 291 0
      mod/disktool/raid/raidutils.go
  24. 75 0
      mod/disktool/raid/status.go
  25. 132 0
      mod/hardwareinfo/hardwareinfo.go
  26. 204 0
      mod/hardwareinfo/sysinfo.go
  27. 69 0
      mod/renderer/audio.go
  28. 71 0
      mod/renderer/image.go
  29. 45 0
      mod/renderer/model.go
  30. 69 0
      mod/renderer/psd.go
  31. 105 0
      mod/renderer/render3d.go
  32. 132 0
      mod/renderer/renderer.go
  33. 72 0
      mod/renderer/video.go
  34. 116 0
      mod/transcoder/transcoder.go
  35. 105 0
      mod/utils/conv.go
  36. 19 0
      mod/utils/template.go
  37. 202 0
      mod/utils/utils.go
  38. BIN
      test/104673594_p19_master1200.jpg
  39. BIN
      test/12668203_169165166787316_1525118736_n.mp4
  40. BIN
      test/subfolder/144385-1776325172-1girl, neuro-sama, (yuzu modoki_1.22), on head, animal on head, cowboy shot, blue eyes, aqua bow, yellow cardigan, open cardigan.png
  41. BIN
      test/subfolder/1aaqk8tppa3e1.jpeg
  42. BIN
      test/subfolder/1wmix0w9c61e1.jpeg
  43. BIN
      test/subfolder/bjuqre6c3f6e1.jpeg
  44. BIN
      test/subfolder/my-new-mini-lab-2-0-remember-waf-v0-ujzzdx5vvp0e1.webp
  45. BIN
      test/subfolder/wkuq1a6svy9e1.png
  46. BIN
      test/will be fine.mp3
  47. BIN
      tmp/test/104673594_p19_master1200.jpg.jpg
  48. BIN
      tmp/test/12668203_169165166787316_1525118736_n.mp4.jpg
  49. BIN
      tmp/test/subfolder/144385-1776325172-1girl, neuro-sama, (yuzu modoki_1.22), on head, animal on head, cowboy shot, blue eyes, aqua bow, yellow cardigan, open cardigan.png.jpg
  50. BIN
      tmp/test/subfolder/1aaqk8tppa3e1.jpeg.jpg
  51. BIN
      tmp/test/subfolder/1wmix0w9c61e1.jpeg.jpg
  52. BIN
      tmp/test/subfolder/bjuqre6c3f6e1.jpeg.jpg
  53. BIN
      tmp/test/subfolder/wkuq1a6svy9e1.png.jpg
  54. BIN
      tmp/test/will be fine.mp3.jpg

+ 7 - 1
go.mod

@@ -4,11 +4,17 @@ go 1.23.2
 
 require (
 	github.com/anatol/smart.go v0.0.0-20241126061019-f03d79b340d2
-	github.com/google/uuid v1.6.0
+	github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
+	github.com/fogleman/fauxgl v0.0.0-20250110135958-abf826acbbbd
+	github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
+	github.com/oliamb/cutter v0.2.2
+	github.com/oov/psd v0.0.0-20220121172623-5db5eafcecbb
 	golang.org/x/net v0.21.0
 )
 
 require (
+	github.com/fogleman/simplify v0.0.0-20170216171241-d32f302d5046 // indirect
+	github.com/gopherjs/gopherjs v1.17.2 // indirect
 	golang.org/x/crypto v0.33.0 // indirect
 	golang.org/x/sys v0.30.0 // indirect
 )

+ 14 - 2
go.sum

@@ -4,10 +4,22 @@ github.com/anatol/vmtest v0.0.0-20230711210602-87511df0d4bc h1:xMQuzBhj6hXQZufed
 github.com/anatol/vmtest v0.0.0-20230711210602-87511df0d4bc/go.mod h1:NC+g66bgkUjV1unIJXhHO35RHxVViWUzNeeKAkkO7DU=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
-github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 h1:OtSeLS5y0Uy01jaKK4mA/WVIYtpzVm63vLVAPzJXigg=
+github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
+github.com/fogleman/fauxgl v0.0.0-20250110135958-abf826acbbbd h1:8bZGm26jDoW+JQ1ZPugRU0ADy5k45DRb42sOxEeufNo=
+github.com/fogleman/fauxgl v0.0.0-20250110135958-abf826acbbbd/go.mod h1:7f7F8EvO8MWvDx9sIoloOfZBCKzlWuZV/h3TjpXOO3k=
+github.com/fogleman/simplify v0.0.0-20170216171241-d32f302d5046 h1:n3RPbpwXSFT0G8FYslzMUBDO09Ix8/dlqzvUkcJm4Jk=
+github.com/fogleman/simplify v0.0.0-20170216171241-d32f302d5046/go.mod h1:KDwyDqFmVUxUmo7tmqXtyaaJMdGon06y8BD2jmh84CQ=
+github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
+github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
+github.com/oliamb/cutter v0.2.2 h1:Lfwkya0HHNU1YLnGv2hTkzHfasrSMkgv4Dn+5rmlk3k=
+github.com/oliamb/cutter v0.2.2/go.mod h1:4BenG2/4GuRBDbVm/OPahDVqbrOemzpPiG5mi1iryBU=
+github.com/oov/psd v0.0.0-20220121172623-5db5eafcecbb h1:JF9kOhBBk4WPF7luXFu5yR+WgaFm9L/KiHJHhU9vDwA=
+github.com/oov/psd v0.0.0-20220121172623-5db5eafcecbb/go.mod h1:GHI1bnmAcbp96z6LNfBJvtrjxhaXGkbsk967utPlvL8=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=

+ 18 - 14
main.go

@@ -45,41 +45,45 @@ func main() {
 	}
 
 	//DEBUG
-	wds, err := bokofs.NewWebdavInterfaceServer("/disk/")
+	wds, err := bokofs.NewWebdavInterfaceServer("/disk/", "/thumb/")
 	if err != nil {
 		panic(err)
 	}
 
-	test, err := bokoworker.NewWorker("test", "./web")
+	test, err := bokoworker.NewFSWorker(&bokoworker.Options{
+		NodeName:       "test",
+		ServePath:      "./test",
+		ThumbnailStore: "./tmp/test/",
+	})
 	if err != nil {
 		panic(err)
 	}
 	wds.AddWorker(test)
 
-	test2, err := bokoworker.NewWorker("test2", "./mod")
+	test2, err := bokoworker.NewFSWorker(&bokoworker.Options{
+		NodeName:       "test2",
+		ServePath:      "./mod",
+		ThumbnailStore: "./tmp/mod/",
+	})
 	if err != nil {
 		panic(err)
 	}
 	wds.AddWorker(test2)
 
+	//END DEBUG
+
 	http.Handle("/", http.FileServer(fileSystem))
-	http.Handle("/disk/", wds.Handler())
 
+	/* WebDAV Handlers */
+	http.Handle("/disk/", wds.FsHandler())     //Note the trailing slash
+	http.Handle("/thumb/", wds.ThumbHandler()) //Note the trailing slash
+
+	/* REST API Handlers */
 	http.Handle("/meta", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		// TODO: Implement handler logic for /meta
 		fmt.Fprintln(w, "Meta handler not implemented yet")
 	}))
 
-	http.Handle("/cache", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		// TODO: Implement handler logic for /cache
-		fmt.Fprintln(w, "Cache handler not implemented yet")
-	}))
-
-	http.Handle("/thumb", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		// TODO: Implement handler logic for /thumb
-		fmt.Fprintln(w, "Thumb handler not implemented yet")
-	}))
-
 	addr := fmt.Sprintf(":%d", *httpPort)
 	fmt.Printf("Starting static web server on %s\n", addr)
 	if err := http.ListenAndServe(addr, nil); err != nil {

+ 7 - 7
mod/bokofs/bokofile/bokodir.go

@@ -43,7 +43,7 @@ func CreateRouterFromDir(dir string, prefix string, readonly bool) (*RouterDir,
 	}, nil
 }
 
-func (r *RouterDir) CleanPrefix(name string) string {
+func (r *RouterDir) cleanPrefix(name string) string {
 	name = filepath.ToSlash(filepath.Clean(name)) + "/"
 	fmt.Println("[Bokodir]", r.Prefix, name, strings.TrimPrefix(name, r.Prefix))
 	return strings.TrimPrefix(name, r.Prefix)
@@ -51,36 +51,36 @@ func (r *RouterDir) CleanPrefix(name string) string {
 
 func (r *RouterDir) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
 	// Implement the Mkdir method
-	name = r.CleanPrefix(name)
+	name = r.cleanPrefix(name)
 	fmt.Println("[Bokodir]", "Mkdir called to "+name)
 	return r.dir.Mkdir(ctx, name, perm)
 }
 
 func (r *RouterDir) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
 	// Implement the OpenFile method
-	name = r.CleanPrefix(name)
+	name = r.cleanPrefix(name)
 	fmt.Println("[Bokodir]", "OpenFile called to "+name)
 	return r.dir.OpenFile(ctx, name, flag, perm)
 }
 
 func (r *RouterDir) RemoveAll(ctx context.Context, name string) error {
 	// Implement the RemoveAll method
-	name = r.CleanPrefix(name)
+	name = r.cleanPrefix(name)
 	fmt.Println("[Bokodir]", "RemoveAll called to "+name)
 	return r.dir.RemoveAll(ctx, name)
 }
 
 func (r *RouterDir) Rename(ctx context.Context, oldName, newName string) error {
 	// Implement the Rename method
-	oldName = r.CleanPrefix(oldName)
-	newName = r.CleanPrefix(newName)
+	oldName = r.cleanPrefix(oldName)
+	newName = r.cleanPrefix(newName)
 	fmt.Println("[Bokodir]", "Rename called from "+oldName+" to "+newName)
 	return r.dir.Rename(ctx, oldName, newName)
 }
 
 func (r *RouterDir) Stat(ctx context.Context, name string) (os.FileInfo, error) {
 	// Implement the Stat method
-	name = r.CleanPrefix(name)
+	name = r.cleanPrefix(name)
 	fmt.Println("[Bokodir]", "Stat called to "+name)
 	return r.dir.Stat(ctx, name)
 }

+ 61 - 10
mod/bokofs/bokofs.go

@@ -1,9 +1,11 @@
 package bokofs
 
 import (
+	"errors"
 	"log"
 	"net/http"
 	"os"
+	"strings"
 	"sync"
 
 	"golang.org/x/net/webdav"
@@ -18,33 +20,67 @@ import (
 */
 
 type Server struct {
-	LoadedWorkers sync.Map //Storing uuid to bokoworker pointer (*bokoworker.Worker)
-	RootRouter    FlowRouter
-	prefix        string
+	LoadedWorkers sync.Map   //Storing uuid to bokoworker pointer (*bokoworker.Worker)
+	FsRouter      FlowRouter //The file system router
+	ThumbRouter   FlowRouter //The thumbnail router
+	fsprefix      string
+	thumbprefix   string
 }
 
 /* NewWebdavInterfaceServer creates a new WebDAV server instance */
-func NewWebdavInterfaceServer(pathPrfix string) (*Server, error) {
+func NewWebdavInterfaceServer(fsPrefix string, thumbPrefix string) (*Server, error) {
+	//Make sure the prefix has a prefix and a trailing slash
+	if fsPrefix == "" || thumbPrefix == "" {
+		return nil, os.ErrInvalid
+	}
+
+	if !strings.HasPrefix(fsPrefix, "/") {
+		fsPrefix = "/" + fsPrefix
+	}
+
+	if !strings.HasSuffix(fsPrefix, "/") {
+		fsPrefix = fsPrefix + "/"
+	}
+
+	if !strings.HasPrefix(thumbPrefix, "/") {
+		thumbPrefix = "/" + thumbPrefix
+	}
+
+	if !strings.HasSuffix(thumbPrefix, "/") {
+		thumbPrefix = thumbPrefix + "/"
+	}
+
 	thisServer := Server{
 		LoadedWorkers: sync.Map{},
-		prefix:        pathPrfix,
+		fsprefix:      fsPrefix,
+		thumbprefix:   thumbPrefix,
 	}
 
 	//Initiate the root router file system
-	err := thisServer.InitiateRootRouter()
+	fsRouter, err := NewRootRouter(&thisServer, fsPrefix, RouterType_FS)
 	if err != nil {
 		return nil, err
 	}
+	thisServer.FsRouter = fsRouter
 
+	thumbRouter, err := NewRootRouter(&thisServer, thumbPrefix, RouterType_Thumb)
+	if err != nil {
+		return nil, err
+	}
+	thisServer.ThumbRouter = thumbRouter
 	return &thisServer, nil
 }
 
 func (s *Server) AddWorker(worker *bokoworker.Worker) error {
+	if worker.Filesystem == nil || worker.Thumbnails == nil {
+		return errors.New("missing resources router")
+	}
+
 	//Check if the worker root path is already loaded
-	if _, ok := s.LoadedWorkers.Load(worker.RootPath); ok {
+	if _, ok := s.LoadedWorkers.Load(worker.NodeName); ok {
 		return os.ErrExist
 	}
-	s.LoadedWorkers.Store(worker.RootPath, worker)
+	s.LoadedWorkers.Store(worker.NodeName, worker)
 	return nil
 }
 
@@ -52,9 +88,9 @@ func (s *Server) RemoveWorker(workerRootPath string) {
 	s.LoadedWorkers.Delete(workerRootPath)
 }
 
-func (s *Server) Handler() http.Handler {
+func (s *Server) FsHandler() http.Handler {
 	srv := &webdav.Handler{
-		FileSystem: s.RootRouter,
+		FileSystem: s.FsRouter,
 		LockSystem: webdav.NewMemLS(),
 		Logger: func(r *http.Request, err error) {
 			if err != nil {
@@ -66,3 +102,18 @@ func (s *Server) Handler() http.Handler {
 	}
 	return srv
 }
+
+func (s *Server) ThumbHandler() http.Handler {
+	srv := &webdav.Handler{
+		FileSystem: s.ThumbRouter,
+		LockSystem: webdav.NewMemLS(),
+		Logger: func(r *http.Request, err error) {
+			if err != nil {
+				log.Printf("THUMB [%s]: %s, ERROR: %s\n", r.Method, r.URL, err)
+			} else {
+				log.Printf("THUMB [%s]: %s \n", r.Method, r.URL)
+			}
+		},
+	}
+	return srv
+}

+ 127 - 0
mod/bokofs/bokothumb/bokodir.go

@@ -0,0 +1,127 @@
+package bokothumb
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"golang.org/x/net/webdav"
+	"imuslab.com/bokofs/bokofsd/mod/renderer"
+)
+
+/*
+	bokodir.go
+
+	The bokodir implements a disk based file system from the webdav.FileSystem interface
+	A file in this implementation corrisponding to a real file on disk
+*/
+
+type Resolutions struct {
+	Width  int
+	Height int
+}
+
+type RouterDir struct {
+	Prefix     string //Path prefix to trim, usually is the root path of the worker
+	ThumbStore string //Path to the thumbnail store
+	FsPath     string //Disk path for the corrisponding file system to create thumbnail
+
+	/* Private Properties */
+	renderer *renderer.RenderHandler
+	dir      webdav.Dir
+}
+
+// CreateThumbnailRenderer creates a new thumbnail renderer from a directory
+func CreateThumbnailRenderer(thumbDir string, sourceFsDir string, prefix string, readonly bool) (*RouterDir, error) {
+	if _, err := os.Stat(sourceFsDir); os.IsNotExist(err) {
+		//Check if the sourceFsDir is a valid directory
+		return nil, err
+	}
+
+	//Initiate the dir
+	fs := webdav.Dir(thumbDir)
+
+	//Create the thumbnail store if it does not exist
+	if err := os.MkdirAll(thumbDir, 0755); err != nil {
+		return nil, err
+	}
+
+	//Create the renderer
+	thumbrRenderer := renderer.NewRenderHandler()
+
+	return &RouterDir{
+		Prefix:     prefix,
+		ThumbStore: thumbDir,
+		FsPath:     sourceFsDir,
+		renderer:   thumbrRenderer,
+		dir:        fs,
+	}, nil
+}
+
+func (r *RouterDir) cleanPrefix(name string) string {
+	name = filepath.ToSlash(filepath.Clean(name))
+	fmt.Println("[Bokothumb]", r.Prefix, name, strings.TrimPrefix(name, r.Prefix))
+	return strings.TrimPrefix(name, r.Prefix)
+}
+
+func (r *RouterDir) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
+	// Implement the Mkdir method
+	return fmt.Errorf("Mkdir operation is not allowed: read-only file system")
+}
+
+func (r *RouterDir) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
+	// Implement the OpenFile method
+	name = r.cleanPrefix(name)
+	ext := filepath.Ext(name)
+	if ext == "" {
+
+		//Requested a folder path. Render the content
+		contents, err := os.ReadDir(filepath.Join(r.FsPath, name))
+		if err != nil {
+			return nil, err
+		}
+
+		//Start thumbnail rendering in background
+		outputFolder := filepath.Join(r.ThumbStore, name)
+		for _, entry := range contents {
+			if entry.IsDir() {
+				os.MkdirAll(filepath.Join(outputFolder, entry.Name()), 0755)
+				continue
+			}
+			go func() {
+				r.renderer.RenderThumbnail(filepath.Join(r.FsPath, name, entry.Name()), outputFolder)
+			}()
+		}
+	}
+
+	fmt.Println("[Bokothumb]", "OpenFile called to "+name)
+	// Check if the file is being opened with write permissions
+	if flag&(os.O_WRONLY|os.O_RDWR|os.O_APPEND|os.O_CREATE|os.O_TRUNC) != 0 {
+		return nil, fmt.Errorf("write operations are not allowed: read-only file system")
+	}
+	return r.dir.OpenFile(ctx, name, flag, perm)
+}
+
+func (r *RouterDir) RemoveAll(ctx context.Context, name string) error {
+	// Implement the RemoveAll method
+	name = r.cleanPrefix(name)
+	fmt.Println("[Bokothumb]", "RemoveAll called to "+name)
+	return r.dir.RemoveAll(ctx, name)
+}
+
+func (r *RouterDir) Rename(ctx context.Context, oldName, newName string) error {
+	// Implement the Rename method
+	return fmt.Errorf("Rename operation is not allowed: read-only file system")
+}
+
+func (r *RouterDir) Stat(ctx context.Context, name string) (os.FileInfo, error) {
+	// Implement the Stat method
+	name = r.cleanPrefix(name)
+	fmt.Println("[Bokothumb]", "Stat called to "+name)
+	return r.dir.Stat(ctx, name)
+}
+
+// Ensure RouterDir implements the FileSystem interface
+var _ webdav.FileSystem = (*RouterDir)(nil)

+ 37 - 0
mod/bokofs/bokothumb/bokothumb.go

@@ -0,0 +1,37 @@
+package bokothumb
+
+import (
+	"errors"
+	"io"
+	"net/http"
+	"os"
+)
+
+type File struct {
+	http.File
+	io.Writer
+}
+
+func (f *File) Write(p []byte) (n int, err error) {
+	return 0, errors.New("readonly file system")
+}
+
+func (f *File) Close() error {
+	return f.File.Close()
+}
+
+func (f *File) Read(p []byte) (n int, err error) {
+	return f.File.Read(p)
+}
+
+func (f *File) Seek(offset int64, whence int) (int64, error) {
+	return f.File.Seek(offset, whence)
+}
+
+func (f *File) Readdir(count int) ([]os.FileInfo, error) {
+	return f.File.Readdir(count)
+}
+
+func (f *File) Stat() (os.FileInfo, error) {
+	return f.File.Stat()
+}

+ 31 - 39
mod/bokofs/bokoworker/bokoworker.go

@@ -1,35 +1,43 @@
 package bokoworker
 
 import (
+	"os"
 	"path/filepath"
 	"strings"
 
 	"imuslab.com/bokofs/bokofsd/mod/bokofs/bokofile"
+	"imuslab.com/bokofs/bokofsd/mod/bokofs/bokothumb"
 )
 
 /*
-	Boko Worker
+Boko Worker
 
-	A boko worker is an instance of WebDAV file server that serves a specific
-	disk partition or subpath in which the user can interact with the disk
-	via WebDAV interface
+A boko worker is an instance of WebDAV file server that serves a specific
+disk partition or subpath in which the user can interact with the disk
+via WebDAV interface
 */
+type Options struct {
+	NodeName       string //The node name (also the id) of the directory tree, e.g. disk1
+	ServePath      string // The actual path to serve, e.g. /media/disk1/mydir
+	ThumbnailStore string // The path to the thumbnail store, e.g. /media/disk1/thumbs
+}
 
 type Worker struct {
 	/* Worker Properties */
-	RootPath  string //The root path (also act as ID) request by this worker, e.g. /disk1
-	DiskUUID  string // Disk UUID, when provided, will be used instead of disk path
-	DiskPath  string // Disk device path (e.g. /dev/sda1), Disk UUID will have higher priority
-	Subpath   string // Subpath to serve, default is root
-	ReadOnly  bool   //Label this worker as read only
-	AutoMount bool   //Automatically mount the disk if not mounted
+	NodeName  string //The node name (also the id) of the directory tree, e.g. disk1
+	ServePath string // The actual path to serve, e.g. /media/disk1/mydir
 
-	/* Private Properties */
-	Filesystem *bokofile.RouterDir
+	/* Runtime Properties */
+	Filesystem *bokofile.RouterDir  //The file system to serve
+	Thumbnails *bokothumb.RouterDir //Thumbnail interface for this worker
 }
 
-// NewWorker Generate and return a webdav node that mounts a given path
-func NewWorker(nodeName string, mountPath string) (*Worker, error) {
+// NewFSWorker creates a new file system worker from a directory
+func NewFSWorker(options *Options) (*Worker, error) {
+	nodeName := options.NodeName
+	mountPath := options.ServePath
+	thumbnailStore := options.ThumbnailStore
+
 	if !strings.HasPrefix(nodeName, "/") {
 		nodeName = "/" + nodeName
 	}
@@ -40,34 +48,18 @@ func NewWorker(nodeName string, mountPath string) (*Worker, error) {
 		return nil, err
 	}
 
-	return &Worker{
-		RootPath:  nodeName,
-		DiskUUID:  "",
-		DiskPath:  "",
-		Subpath:   mountPath,
-		ReadOnly:  false,
-		AutoMount: false,
-
-		Filesystem: fs,
-	}, nil
-}
-
-/*
-func GetWorkerFromConfig(configFilePath string) (*Worker, error) {
-	randomRootPath := uuid.New().String()
-	thisWorker, _ := NewWorker("/" + randomRootPath)
-	configFile, err := os.ReadFile(configFilePath)
+	//Create the thumbnail store if it does not exist
+	os.MkdirAll(thumbnailStore, 0755)
+	thumbrender, err := bokothumb.CreateThumbnailRenderer(thumbnailStore, mountPath, nodeName, false)
 	if err != nil {
 		return nil, err
 	}
 
-	//parse the config file into thisWorker
-	err = json.Unmarshal(configFile, &thisWorker)
-	if err != nil {
-		return nil, err
-	}
+	return &Worker{
+		NodeName:  nodeName,
+		ServePath: mountPath,
 
-	//TODO: Start the worker
-	return thisWorker, nil
+		Filesystem: fs,
+		Thumbnails: thumbrender,
+	}, nil
 }
-*/

+ 61 - 15
mod/bokofs/router.go

@@ -2,6 +2,7 @@ package bokofs
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"os"
 	"path/filepath"
@@ -14,19 +15,39 @@ import (
 
 type RootRouter struct {
 	pathPrefix string
+	routerType RouterType
 	parent     *Server
 }
 
-// InitiateRootRouter create and prepare a virtual root file system for
-// this bokoFS instance
-func (s *Server) InitiateRootRouter() error {
-	s.RootRouter = &RootRouter{
-		pathPrefix: s.prefix,
-		parent:     s,
-	}
-	return nil
+type RouterType int
+
+const (
+	RouterType_FS RouterType = iota
+	RouterType_Thumb
+)
+
+type RouterDirHandler interface {
+	Mkdir(ctx context.Context, name string, perm os.FileMode) error
+	OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error)
+	RemoveAll(ctx context.Context, name string) error
+	Rename(ctx context.Context, oldName, newName string) error
+	Stat(ctx context.Context, name string) (os.FileInfo, error)
 }
 
+// RootRouter implements the webdav.FileSystem interface
+// It serves as the root of the file system, routing requests to the appropriate worker
+func NewRootRouter(p *Server, prefix string, rType RouterType) (*RootRouter, error) {
+	return &RootRouter{
+		pathPrefix: prefix,
+		routerType: rType,
+		parent:     p,
+	}, nil
+}
+
+/*
+	Router Internal Implementation
+*/
+
 // fixpath fix the path to be relative to the root path of this router
 func (r *RootRouter) fixpath(name string) string {
 	if name == r.pathPrefix || name == "" {
@@ -40,7 +61,8 @@ func (r *RootRouter) fixpath(name string) string {
 	return name
 }
 
-func (r *RootRouter) GetRootDir(name string) string {
+// getRootDir returns the root directory of the request
+func (r *RootRouter) getRootDir(name string) string {
 	if name == "" || name == "/" {
 		return "/"
 	}
@@ -53,8 +75,9 @@ func (r *RootRouter) GetRootDir(name string) string {
 	return name
 }
 
-func (r *RootRouter) GetWorkerByPath(name string) (*bokoworker.Worker, error) {
-	reqRootPath := r.GetRootDir(name)
+// GetFileSystemFromWorker returns the file system from the worker
+func (r *RootRouter) getWorkerByPath(name string) (*bokoworker.Worker, error) {
+	reqRootPath := r.getRootDir(name)
 	targetWorker, ok := r.parent.LoadedWorkers.Load(reqRootPath)
 	if !ok {
 		return nil, os.ErrNotExist
@@ -63,6 +86,20 @@ func (r *RootRouter) GetWorkerByPath(name string) (*bokoworker.Worker, error) {
 	return targetWorker.(*bokoworker.Worker), nil
 }
 
+// getFileSystemFromWorker returns the file system from the worker
+func (r *RootRouter) getFileSystemFromWorker(worker *bokoworker.Worker) (RouterDirHandler, error) {
+	if r.routerType == RouterType_FS {
+		return worker.Filesystem, nil
+	} else if r.routerType == RouterType_Thumb {
+		return worker.Thumbnails, nil
+	}
+	return nil, errors.New("Invalid router type")
+}
+
+/*
+	WebDAV FileSystem Interface Implementation
+*/
+
 func (r *RootRouter) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
 	// Implement the Mkdir method
 	name = r.fixpath(name)
@@ -87,12 +124,16 @@ func (r *RootRouter) OpenFile(ctx context.Context, name string, flag int, perm o
 		return thisVirtualObject, nil
 	}
 
-	targetWorker, err := r.GetWorkerByPath(name)
+	targetWorker, err := r.getWorkerByPath(name)
 	if err != nil {
 		return nil, err
 	}
 
-	return targetWorker.Filesystem.OpenFile(ctx, name, flag, perm)
+	targetFileSystem, err := r.getFileSystemFromWorker(targetWorker)
+	if err != nil {
+		return nil, err
+	}
+	return targetFileSystem.OpenFile(ctx, name, flag, perm)
 }
 
 func (r *RootRouter) RemoveAll(ctx context.Context, name string) error {
@@ -130,13 +171,18 @@ func (r *RootRouter) Stat(ctx context.Context, name string) (os.FileInfo, error)
 	}
 
 	//Load the target worker from the path
-	targetWorker, err := r.GetWorkerByPath(name)
+	targetWorker, err := r.getWorkerByPath(name)
 	fmt.Println("Target Worker: ", targetWorker, name)
 	if err != nil {
 		return nil, err
 	}
 
-	return targetWorker.Filesystem.Stat(ctx, name)
+	targetFileSystem, err := r.getFileSystemFromWorker(targetWorker)
+	if err != nil {
+		return nil, err
+	}
+
+	return targetFileSystem.Stat(ctx, name)
 }
 
 // Ensure RootRouter implements the FileSystem interface

+ 1 - 1
mod/bokofs/utils.go

@@ -9,7 +9,7 @@ func (s *Server) GetRegisteredRootFolders() ([]string, error) {
 	var rootFolders []string
 	s.LoadedWorkers.Range(func(key, value interface{}) bool {
 		thisWorker := value.(*bokoworker.Worker)
-		rootFolders = append(rootFolders, thisWorker.RootPath)
+		rootFolders = append(rootFolders, thisWorker.NodeName)
 		return true
 	})
 	return rootFolders, nil

+ 5 - 11
mod/bokofs/vobject.go

@@ -79,14 +79,14 @@ func (r *vObjectFileInfo) Sys() interface{} {
 
 /* File Interface */
 func (r *vObject) Close() error {
-	// Implement the Close method
-	fmt.Println("Close called")
+	//No need to implement this method as the vObject is not a file
+	//as there will be no file descriptor opened for the vObject
 	return nil
 }
 
 func (r *vObject) Read(p []byte) (n int, err error) {
-	// Implement the Read method
-	fmt.Println("Read called")
+	//No need to implement this method as the vObject is not a file
+	//It is a virtual object that serves as a directory or a file system root
 	return 0, nil
 }
 
@@ -115,20 +115,14 @@ func (r *vObject) Readdir(count int) ([]os.FileInfo, error) {
 }
 
 func (r *vObject) Seek(offset int64, whence int) (int64, error) {
-	// Implement the Seek method
-	fmt.Println("Seek called")
 	return 0, nil
 }
 
 func (r *vObject) Write(p []byte) (n int, err error) {
-	// Implement the Write method
-	fmt.Println("Write called")
-	return 0, nil
+	return 0, fmt.Errorf("write operation not allowed: this part of the file system is read-only")
 }
 
 func (r *vObject) Stat() (os.FileInfo, error) {
-	// Implement the Stat method
-	fmt.Println("Stat called")
 	return r.GetFileInfo(), nil
 }
 

+ 0 - 0
mod/middleware/SMART/extract.go → mod/disktool/SMART/extract.go


+ 0 - 0
mod/middleware/SMART/smart.go → mod/disktool/SMART/smart.go


+ 272 - 0
mod/disktool/diskfs/diskfs.go

@@ -0,0 +1,272 @@
+package diskfs
+
+import (
+	"bufio"
+	"bytes"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"log"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"regexp"
+	"strings"
+
+	"imuslab.com/bokofs/bokofsd/mod/utils"
+)
+
+/*
+	diskfs.go
+
+	This module handle file system creation and formatting
+*/
+
+// Storage Device meta was generated by lsblk
+// Partitions like sdX0
+type PartitionMeta struct {
+	Name       string `json:"name"`
+	MajMin     string `json:"maj:min"`
+	Rm         bool   `json:"rm"`
+	Size       int64  `json:"size"`
+	Ro         bool   `json:"ro"`
+	Type       string `json:"type"`
+	Mountpoint string `json:"mountpoint"`
+}
+
+// Block device, usually disk or rom, like sdX
+type BlockDeviceMeta struct {
+	Name       string          `json:"name"`
+	MajMin     string          `json:"maj:min"`
+	Rm         bool            `json:"rm"`
+	Size       int64           `json:"size"`
+	Ro         bool            `json:"ro"`
+	Type       string          `json:"type"`
+	Mountpoint string          `json:"mountpoint"`
+	Children   []PartitionMeta `json:"children,omitempty"`
+}
+
+// A collection of information for lsblk output
+type StorageDevicesMeta struct {
+	Blockdevices []BlockDeviceMeta `json:"blockdevices"`
+}
+
+// Check if the file format driver is installed on this host
+// if a format is supported, mkfs.(format) should be symlinked under /sbin
+func FormatPackageInstalled(fsType string) bool {
+	return utils.FileExists("/sbin/mkfs." + fsType)
+}
+
+// Create file system, support ntfs, ext4 and fat32 only
+func FormatStorageDevice(fsType string, devicePath string) error {
+	// Check if the filesystem type is supported
+	switch fsType {
+	case "ext4":
+		// Format the device with the specified filesystem type
+		cmd := exec.Command("sudo", "mkfs."+fsType, devicePath)
+		output, err := cmd.CombinedOutput()
+		if err != nil {
+			return errors.New("unable to format device: " + string(output))
+		}
+		return nil
+
+	case "vfat", "fat", "fat32":
+		//Check if mkfs.fat exists
+		if !FormatPackageInstalled("vfat") {
+			return errors.New("unable to format device as fat (vfat). dosfstools not installed?")
+		}
+
+		// Format the device with the specified filesystem type
+		cmd := exec.Command("sudo", "mkfs.vfat", devicePath)
+		output, err := cmd.CombinedOutput()
+		if err != nil {
+			return errors.New("unable to format device: " + string(output))
+		}
+		return nil
+
+	case "ntfs":
+		//Check if ntfs-3g exists
+		if !FormatPackageInstalled("ntfs") {
+			return errors.New("unable to format device as ntfs: ntfs-3g not installed?")
+		}
+
+		//Format the drive
+		cmd := exec.Command("sudo", "mkfs.ntfs", devicePath)
+		output, err := cmd.CombinedOutput()
+		if err != nil {
+			return errors.New("unable to format device: " + string(output))
+		}
+		return nil
+
+	default:
+		return fmt.Errorf("unsupported filesystem type: %s", fsType)
+	}
+}
+
+// List all the storage device in the system, set minSize to 0 for no filter
+func ListAllStorageDevices() (*StorageDevicesMeta, error) {
+	cmd := exec.Command("sudo", "lsblk", "-b", "--json")
+
+	output, err := cmd.CombinedOutput()
+	if err != nil {
+		return nil, fmt.Errorf("lsblk error: %v", err)
+	}
+
+	var devices StorageDevicesMeta
+	err = json.Unmarshal([]byte(output), &devices)
+	return &devices, err
+}
+
+// Get block device (e.g. /dev/sdX) info
+func GetBlockDeviceMeta(devicePath string) (*BlockDeviceMeta, error) {
+	//Trim the /dev/ part of the device path
+	deviceName := strings.TrimPrefix(devicePath, "/dev/")
+
+	if len(deviceName) == 0 {
+		return nil, errors.New("invalid device path given")
+	}
+
+	re := regexp.MustCompile(`\d+`)
+	if re.MatchString(deviceName) {
+		//This is a partition
+		return nil, errors.New("given device path is a partition not a block device")
+	}
+
+	storageMeta, err := ListAllStorageDevices()
+	if err != nil {
+		return nil, err
+	}
+
+	for _, blockdevice := range storageMeta.Blockdevices {
+		if blockdevice.Name == deviceName {
+			return &blockdevice, nil
+		}
+	}
+
+	return nil, errors.New("target block device not found")
+}
+
+// Get the disk UUID by current device path (e.g. /dev/sda)
+func GetDiskUUID(devicePath string) (string, error) {
+	cmd := exec.Command("sudo", "blkid", "-s", "UUID", "-o", "value", devicePath)
+	var out bytes.Buffer
+	cmd.Stdout = &out
+	err := cmd.Run()
+	if err != nil {
+		return "", err
+	}
+	uuid := strings.TrimSpace(out.String())
+	return uuid, nil
+}
+
+// Get partition information (e.g. /dev/sdX1)
+func GetPartitionMeta(devicePath string) (*PartitionMeta, error) {
+	//Trim the /dev/ part of the device path
+	deviceName := strings.TrimPrefix(devicePath, "/dev/")
+
+	if len(deviceName) == 0 {
+		return nil, errors.New("invalid device path given")
+	}
+
+	re := regexp.MustCompile(`\d+`)
+	if !re.MatchString(deviceName) {
+		//This is a partition
+		return nil, errors.New("given device path is a block device not a partition")
+	}
+
+	storageMeta, err := ListAllStorageDevices()
+	if err != nil {
+		return nil, err
+	}
+
+	for _, blockdevice := range storageMeta.Blockdevices {
+		if strings.Contains(deviceName, blockdevice.Name) {
+			//Matching block device. Check for if there are a matching child
+			for _, childPartition := range blockdevice.Children {
+				if childPartition.Name == deviceName {
+					return &childPartition, nil
+				}
+			}
+		}
+	}
+
+	return nil, errors.New("target partition not found")
+}
+
+// Check if a device is mounted given the path name, like /dev/sdc
+func DeviceIsMounted(devicePath string) (bool, error) {
+	// Open the mountinfo file
+	file, err := os.Open("/proc/mounts")
+	if err != nil {
+		return false, fmt.Errorf("error opening /proc/mounts: %v", err)
+	}
+	defer file.Close()
+
+	if !strings.HasPrefix(devicePath, "/dev/") {
+		devicePath = filepath.Join("/dev/", devicePath)
+	}
+	// Scan the mountinfo file line by line
+	scanner := bufio.NewScanner(file)
+	for scanner.Scan() {
+		line := scanner.Text()
+		fields := strings.Fields(line)
+		if strings.EqualFold(strings.TrimSpace(fields[0]), devicePath) {
+			// Device is mounted
+			return true, nil
+		}
+	}
+
+	// Device is not mounted
+	return false, nil
+}
+
+// UnmountDevice unmounts the specified device.
+// Remember to use full path (e.g. /dev/md0) in the devicePath
+func UnmountDevice(devicePath string) error {
+	// Construct the bash command to unmount the device
+	cmd := exec.Command("sudo", "bash", "-c", fmt.Sprintf("umount -l %s", devicePath))
+
+	// Run the command
+	output, err := cmd.CombinedOutput()
+	if err != nil {
+		log.Println("[RAID] Unable to unmount device: " + string(output))
+		return fmt.Errorf("error unmounting device: %v", err)
+	}
+
+	return nil
+}
+
+// Force Version of Unmount (dangerous)
+// Remember to use full path (e.g. /dev/md0) in the devicePath
+func ForceUnmountDevice(devicePath string) error {
+	// Construct the bash command to unmount the device
+	cmd := exec.Command("sudo", "bash", "-c", fmt.Sprintf("umount -l %s", devicePath))
+
+	// Run the command
+	err := cmd.Run()
+	if err != nil {
+		return fmt.Errorf("error unmounting device: %v", err)
+	}
+
+	return nil
+}
+
+// DANGER: Wipe the whole disk given the disk path
+func WipeDisk(diskPath string) error {
+	// Unmount the disk
+	isMounted, _ := DeviceIsMounted(diskPath)
+	if isMounted {
+		umountCmd := exec.Command("sudo", "umount", diskPath)
+		if err := umountCmd.Run(); err != nil {
+			return fmt.Errorf("error unmounting disk %s: %v", diskPath, err)
+		}
+	}
+
+	// Wipe all filesystem signatures on the entire disk
+	wipeCmd := exec.Command("sudo", "wipefs", "--all", "--force", diskPath)
+	if err := wipeCmd.Run(); err != nil {
+		return fmt.Errorf("error wiping filesystem signatures on %s: %v", diskPath, err)
+	}
+
+	return nil
+}

+ 48 - 0
mod/disktool/diskfs/disklb.go

@@ -0,0 +1,48 @@
+package diskfs
+
+import (
+	"encoding/json"
+	"fmt"
+	"os/exec"
+	"strings"
+)
+
+type BlockDeviceModelInfo struct {
+	Name     string                 `json:"name"`
+	Size     string                 `json:"size"`
+	Model    string                 `json:"model"`
+	Children []BlockDeviceModelInfo `json:"children"`
+}
+
+// Get disk model name by disk name (sdX, not /dev/sdX), return the model name (if any) and expected size (not actual)
+// return device labeled size, model and error if any
+func GetDiskModelByName(name string) (string, string, error) {
+	cmd := exec.Command("sudo", "lsblk", "--json", "-o", "NAME,SIZE,MODEL")
+
+	output, err := cmd.Output()
+	if err != nil {
+		return "", "", fmt.Errorf("error running lsblk: %v", err)
+	}
+
+	var blockDevices struct {
+		BlockDevices []BlockDeviceModelInfo `json:"blockdevices"`
+	}
+
+	if err := json.Unmarshal(output, &blockDevices); err != nil {
+		return "", "", fmt.Errorf("error parsing lsblk output: %v", err)
+	}
+
+	return findDiskInfo(blockDevices.BlockDevices, name)
+}
+
+func findDiskInfo(blockDevices []BlockDeviceModelInfo, name string) (string, string, error) {
+	for _, device := range blockDevices {
+		if device.Name == name {
+			return device.Size, device.Model, nil
+		}
+		if strings.HasPrefix(name, device.Name) {
+			return findDiskInfo(device.Children, name)
+		}
+	}
+	return "", "", fmt.Errorf("disk not found: %s", name)
+}

+ 681 - 0
mod/disktool/raid/handler.go

@@ -0,0 +1,681 @@
+package raid
+
+import (
+	"encoding/json"
+	"log"
+	"net/http"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"time"
+
+	"imuslab.com/bokofs/bokofsd/mod/disktool/diskfs"
+	"imuslab.com/bokofs/bokofsd/mod/utils"
+)
+
+/*
+	Handler.go
+
+	This module handle api call to the raid module
+*/
+
+// Handle remove a member disk (sdX) from RAID volume (mdX)
+func (m *Manager) HandleRemoveDiskFromRAIDVol(w http.ResponseWriter, r *http.Request) {
+	//mdadm --remove /dev/md0 /dev/sdb1
+	mdDev, err := utils.PostPara(r, "raidDev")
+	if err != nil {
+		utils.SendErrorResponse(w, "invalid raid device given")
+		return
+	}
+
+	sdXDev, err := utils.PostPara(r, "memDev")
+	if err != nil {
+		utils.SendErrorResponse(w, "invalid member device given")
+		return
+	}
+
+	//Check if target array exists
+	if !m.RAIDDeviceExists(mdDev) {
+		utils.SendErrorResponse(w, "target RAID array not exists")
+		return
+	}
+
+	//Check if this is the only disk in the array
+	if !m.IsSafeToRemove(mdDev, sdXDev) {
+		utils.SendErrorResponse(w, "removal of this device will cause data loss")
+		return
+	}
+
+	//Check if the disk is already failed
+	diskAlreadyFailed, err := m.DiskIsFailed(mdDev, sdXDev)
+	if err != nil {
+		log.Println("[RAID] Unable to validate if disk failed: " + err.Error())
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+	//Disk not failed. Mark it as failed
+	if !diskAlreadyFailed {
+		err = m.FailDisk(mdDev, sdXDev)
+		if err != nil {
+			utils.SendErrorResponse(w, err.Error())
+			return
+		}
+	}
+
+	//Add some delay for OS level to handle IO closing
+	time.Sleep(300 * time.Millisecond)
+
+	//Done. Remove the device from array
+	err = m.RemoveDisk(mdDev, sdXDev)
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+	log.Println("[RAID] Memeber disk " + sdXDev + " removed from RAID volume " + mdDev)
+	utils.SendOK(w)
+}
+
+// Handle adding a disk (mdX) to RAID volume (mdX)
+func (m *Manager) HandleAddDiskToRAIDVol(w http.ResponseWriter, r *http.Request) {
+	//mdadm --add /dev/md0 /dev/sdb1
+	mdDev, err := utils.PostPara(r, "raidDev")
+	if err != nil {
+		utils.SendErrorResponse(w, "invalid raid device given")
+		return
+	}
+
+	sdXDev, err := utils.PostPara(r, "memDev")
+	if err != nil {
+		utils.SendErrorResponse(w, "invalid member device given")
+		return
+	}
+
+	//Check if target array exists
+	if !m.RAIDDeviceExists(mdDev) {
+		utils.SendErrorResponse(w, "target RAID array not exists")
+		return
+	}
+
+	//Check if disk already in another RAID array or mounted
+	isMounted, err := diskfs.DeviceIsMounted(sdXDev)
+	if err != nil {
+		utils.SendErrorResponse(w, "unable to read device state")
+		return
+	}
+
+	if isMounted {
+		utils.SendErrorResponse(w, "target device is mounted")
+		return
+	}
+
+	diskUsedByAnotherRAID, err := m.DiskIsUsedInAnotherRAIDVol(sdXDev)
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+	}
+
+	if diskUsedByAnotherRAID {
+		utils.SendErrorResponse(w, "target device already been used by another RAID volume")
+		return
+	}
+
+	isOSDisk, err := m.DiskIsRoot(sdXDev)
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+	}
+
+	if isOSDisk {
+		utils.SendErrorResponse(w, "OS disk cannot be used as RAID member")
+		return
+	}
+
+	//OK! Clear the disk
+	err = m.ClearSuperblock(sdXDev)
+	if err != nil {
+		utils.SendErrorResponse(w, "unable to clear superblock of device")
+		return
+	}
+
+	//Add it to the target RAID array
+	err = m.AddDisk(mdDev, sdXDev)
+	if err != nil {
+		utils.SendErrorResponse(w, "adding disk to RAID volume failed")
+		return
+	}
+
+	log.Println("[RAID] Device " + sdXDev + " added to RAID volume " + mdDev)
+
+	utils.SendOK(w)
+}
+
+// Handle force flush reloading mdadm to solve the md0 become md127 problem
+func (m *Manager) HandleMdadmFlushReload(w http.ResponseWriter, r *http.Request) {
+	err := m.FlushReload()
+	if err != nil {
+		utils.SendErrorResponse(w, "reload failed: "+strings.ReplaceAll(err.Error(), "\n", " "))
+		return
+	}
+	utils.SendOK(w)
+}
+
+// Handle resolving the disk model label, might return null
+func (m *Manager) HandleResolveDiskModelLabel(w http.ResponseWriter, r *http.Request) {
+	devName, err := utils.GetPara(r, "devName")
+	if err != nil {
+		utils.SendErrorResponse(w, "invalid device name given")
+		return
+	}
+
+	//Function only accept sdX not /dev/sdX
+	devName = filepath.Base(devName)
+
+	labelSize, labelModel, err := diskfs.GetDiskModelByName(devName)
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	js, _ := json.Marshal([]string{labelModel, labelSize})
+	utils.SendJSONResponse(w, string(js))
+}
+
+// Handle force flush reloading mdadm to solve the md0 become md127 problem
+func (m *Manager) HandlListChildrenDeviceInfo(w http.ResponseWriter, r *http.Request) {
+	devName, err := utils.GetPara(r, "devName")
+	if err != nil {
+		utils.SendErrorResponse(w, "invalid device name given")
+		return
+	}
+
+	if !strings.HasPrefix(devName, "/dev/") {
+		devName = "/dev/" + devName
+	}
+
+	//Get the children devices for this RAID
+	raidDevice, err := m.GetRAIDDeviceByDevicePath(devName)
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	//Merge the child devices info into one array
+	results := map[string]*diskfs.BlockDeviceMeta{}
+	for _, blockdevice := range raidDevice.Members {
+		bdm, err := diskfs.GetBlockDeviceMeta("/dev/" + blockdevice.Name)
+		if err != nil {
+			log.Println("[RAID] Unable to load block device info: " + err.Error())
+			results[blockdevice.Name] = &diskfs.BlockDeviceMeta{
+				Name: blockdevice.Name,
+				Size: -1,
+			}
+
+			continue
+		}
+
+		results[blockdevice.Name] = bdm
+	}
+
+	js, _ := json.Marshal(results)
+	utils.SendJSONResponse(w, string(js))
+}
+
+// Handle list all the disks that is usable
+func (m *Manager) HandleListUsableDevices(w http.ResponseWriter, r *http.Request) {
+	storageDevices, err := diskfs.ListAllStorageDevices()
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	//Filter out the block devices that are disks
+	usableDisks := []diskfs.BlockDeviceMeta{}
+	for _, device := range storageDevices.Blockdevices {
+		if device.Type == "disk" {
+			usableDisks = append(usableDisks, device)
+		}
+	}
+
+	js, _ := json.Marshal(usableDisks)
+	utils.SendJSONResponse(w, string(js))
+
+}
+
+// Handle loading the detail of a given RAID array
+func (m *Manager) HandleLoadArrayDetail(w http.ResponseWriter, r *http.Request) {
+	devName, err := utils.GetPara(r, "devName")
+	if err != nil {
+		utils.SendErrorResponse(w, "invalid device name given")
+		return
+	}
+
+	if !strings.HasPrefix(devName, "/dev/") {
+		devName = "/dev/" + devName
+	}
+
+	//Check device exists
+	if !utils.FileExists(devName) {
+		utils.SendErrorResponse(w, "target device not exists")
+		return
+	}
+
+	//Get status of the array
+	targetRAIDInfo, err := m.GetRAIDInfo(devName)
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	js, _ := json.Marshal(targetRAIDInfo)
+	utils.SendJSONResponse(w, string(js))
+}
+
+// Handle formating a device
+func (m *Manager) HandleFormatRaidDevice(w http.ResponseWriter, r *http.Request) {
+	devName, err := utils.GetPara(r, "devName")
+	if err != nil {
+		utils.SendErrorResponse(w, "invalid device name given")
+		return
+	}
+
+	format, err := utils.GetPara(r, "format")
+	if err != nil {
+		utils.SendErrorResponse(w, "invalid device name given")
+		return
+	}
+
+	if !strings.HasPrefix(devName, "/dev/") {
+		devName = "/dev/" + devName
+	}
+
+	//Check if the target device exists
+	if !m.RAIDDeviceExists(devName) {
+		utils.SendErrorResponse(w, "target not exists or not a valid RAID device")
+		return
+	}
+
+	//Format the drive
+	err = diskfs.FormatStorageDevice(format, devName)
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	utils.SendOK(w)
+}
+
+// List all the raid device in this system
+func (m *Manager) HandleListRaidDevices(w http.ResponseWriter, r *http.Request) {
+	rdevs, err := m.GetRAIDDevicesFromProcMDStat()
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	results := []*RAIDInfo{}
+	for _, rdev := range rdevs {
+		arrayInfo, err := m.GetRAIDInfo("/dev/" + rdev.Name)
+		if err != nil {
+			continue
+		}
+
+		results = append(results, arrayInfo)
+	}
+
+	js, _ := json.Marshal(results)
+	utils.SendJSONResponse(w, string(js))
+}
+
+// Create a RAID storage pool
+func (m *Manager) HandleCreateRAIDDevice(w http.ResponseWriter, r *http.Request) {
+	devName, err := utils.PostPara(r, "devName")
+	if err != nil || devName == "" {
+		//Use auto generated one
+		devName, err = GetNextAvailableMDDevice()
+		if err != nil {
+			utils.SendErrorResponse(w, err.Error())
+			return
+		}
+	}
+	raidName, err := utils.PostPara(r, "raidName")
+	if err != nil {
+		utils.SendErrorResponse(w, "invalid raid storage name given")
+		return
+	}
+	raidLevelStr, err := utils.PostPara(r, "level")
+	if err != nil {
+		utils.SendErrorResponse(w, "invalid raid level given")
+		return
+	}
+
+	raidDevicesJSON, err := utils.PostPara(r, "raidDev")
+	if err != nil {
+		utils.SendErrorResponse(w, "invalid raid device array given")
+		return
+	}
+
+	spareDevicesJSON, err := utils.PostPara(r, "spareDev")
+	if err != nil {
+		utils.SendErrorResponse(w, "invalid spare device array given")
+		return
+	}
+
+	//Get if superblock require all zeroed (will also do formating after raid constructed)
+	zerosuperblock, err := utils.PostBool(r, "zerosuperblock")
+	if err != nil {
+		zerosuperblock = false
+	}
+
+	//Convert raidDevices and spareDevices ID into string slice
+	raidDevices := []string{}
+	spareDevices := []string{}
+
+	err = json.Unmarshal([]byte(raidDevicesJSON), &raidDevices)
+	if err != nil {
+		utils.SendErrorResponse(w, "unable to parse raid device into array")
+		return
+	}
+
+	err = json.Unmarshal([]byte(spareDevicesJSON), &spareDevices)
+	if err != nil {
+		utils.SendErrorResponse(w, "unable to parse spare devices into array")
+		return
+	}
+
+	//Make sure RAID Name do not contain spaces or werid charcters
+	if strings.Contains(raidName, " ") {
+		utils.SendErrorResponse(w, "raid name cannot contain space")
+		return
+	}
+
+	//Convert raidLevel to int
+	raidLevelStr = strings.TrimPrefix(raidLevelStr, "raid")
+	raidLevel, err := strconv.Atoi(raidLevelStr)
+	if err != nil {
+		utils.SendErrorResponse(w, "invalid raid level given")
+		return
+	}
+
+	if zerosuperblock {
+		//Format each drives
+		drivesToZeroblocks := []string{}
+		for _, raidDev := range raidDevices {
+			if !strings.HasPrefix(raidDev, "/dev/") {
+				//Prepend /dev/ to it if not set
+				raidDev = filepath.Join("/dev/", raidDev)
+			}
+
+			if !utils.FileExists(raidDev) {
+				//This disk not found
+				utils.SendErrorResponse(w, raidDev+" not found")
+				return
+			}
+
+			thisDisk := raidDev
+			drivesToZeroblocks = append(drivesToZeroblocks, thisDisk)
+		}
+
+		for _, spareDev := range spareDevices {
+			if !strings.HasPrefix(spareDev, "/dev/") {
+				//Prepend /dev/ to it if not set
+				spareDev = filepath.Join("/dev/", spareDev)
+			}
+
+			if !utils.FileExists(spareDev) {
+				//This disk not found
+				utils.SendErrorResponse(w, spareDev+" not found")
+				return
+			}
+
+			thisDisk := spareDev
+			drivesToZeroblocks = append(drivesToZeroblocks, thisDisk)
+		}
+
+		for _, clearPendingDisk := range drivesToZeroblocks {
+			//Format all drives
+			log.Println("RAID", "Clearning superblock for disk "+clearPendingDisk, nil)
+			err = m.ClearSuperblock(clearPendingDisk)
+			if err != nil {
+				log.Println("RAID", "Unable to format "+clearPendingDisk+": "+err.Error(), err)
+				utils.SendErrorResponse(w, err.Error())
+				return
+			}
+		}
+	}
+
+	//Create the RAID device
+	err = m.CreateRAIDDevice(devName, raidName, raidLevel, raidDevices, spareDevices)
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	//Update the mdadm config
+	err = m.UpdateMDADMConfig()
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	utils.SendOK(w)
+}
+
+// Request to reload the RAID manager and scan new / fix missing raid pools
+func (m *Manager) HandleRaidDevicesAssemble(w http.ResponseWriter, r *http.Request) {
+	err := m.RestartRAIDService()
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	utils.SendOK(w)
+}
+
+// Remove a given raid device with its name, USE WITH CAUTION
+func (m *Manager) HandleRemoveRaideDevice(w http.ResponseWriter, r *http.Request) {
+	//TODO: Add protection and switch to POST
+	targetDevice, err := utils.PostPara(r, "raidDev")
+	if err != nil {
+		utils.SendErrorResponse(w, "target device not given")
+		return
+	}
+
+	//Check if the raid device exists
+	if !m.RAIDDeviceExists(targetDevice) {
+		utils.SendErrorResponse(w, "target device not exists")
+		return
+	}
+
+	//Get the RAID device memeber disks
+	targetRAIDDevice, err := m.GetRAIDDeviceByDevicePath(targetDevice)
+	if err != nil {
+		utils.SendErrorResponse(w, "error occured when trying to load target RAID device info")
+		return
+	}
+
+	//Check if it is mounted. If yes, unmount it
+	if !strings.HasPrefix(targetDevice, "/dev/") {
+		targetDevice = filepath.Join("/dev/", targetDevice)
+	}
+
+	mounted, err := diskfs.DeviceIsMounted(targetDevice)
+	if err != nil {
+		log.Println("RAID", "Unmount failed: "+err.Error(), err)
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	if mounted {
+		log.Println("RAID", targetDevice+" is mounted. Trying to unmount...", nil)
+		err = diskfs.UnmountDevice(targetDevice)
+		if err != nil {
+			log.Println("[RAID] Unmount failed: " + err.Error())
+			utils.SendErrorResponse(w, err.Error())
+			return
+		}
+
+		//Wait for 3 seconds to check if it is still mounted
+		counter := 0
+		for counter < 3 {
+			mounted, _ := diskfs.DeviceIsMounted(targetDevice)
+			if mounted {
+				//Still not unmounted. Wait for it
+				log.Println("RAID", "Device still mounted. Retrying in 1 second", nil)
+				counter++
+				time.Sleep(1 * time.Second)
+			} else {
+				break
+			}
+		}
+
+		//Check if it is still mounted
+		mounted, _ = diskfs.DeviceIsMounted(targetDevice)
+		if mounted {
+			utils.SendErrorResponse(w, "unmount RAID partition failed: device is busy")
+			return
+		}
+	}
+
+	//Give it some time for the raid device to finish umount
+	time.Sleep(300 * time.Millisecond)
+
+	//Stop & Remove RAID service on the target device
+	err = m.StopRAIDDevice(targetDevice)
+	if err != nil {
+		log.Println("RAID", "Stop RAID partition failed: "+err.Error(), err)
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	//Zeroblock the RAID device member disks
+	for _, memberDisk := range targetRAIDDevice.Members {
+		//Member disk name do not contain full path
+		name := memberDisk.Name
+		if !strings.HasPrefix(name, "/dev/") {
+			name = filepath.Join("/dev/", name)
+		}
+
+		err = m.ClearSuperblock(name)
+		if err != nil {
+			log.Println("RAID", "Unable to clear superblock on device "+name, err)
+			continue
+		}
+	}
+
+	//Update the mdadm config
+	err = m.UpdateMDADMConfig()
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	//Done
+	utils.SendOK(w)
+}
+
+// Force reload all RAID config from file
+func (m *Manager) HandleForceAssembleReload(w http.ResponseWriter, r *http.Request) {
+	err := m.FlushReload()
+	if err != nil {
+		log.Println("RAID", "mdadm reload failed: "+err.Error(), err)
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	utils.SendOK(w)
+}
+
+// Grow the raid array to maxmium possible size of the current disks
+func (m *Manager) HandleGrowRAIDArray(w http.ResponseWriter, r *http.Request) {
+	deviceName, err := utils.PostPara(r, "raidDev")
+	if err != nil {
+		utils.SendErrorResponse(w, "raid device not given")
+		return
+	}
+
+	if !m.RAIDDeviceExists(deviceName) {
+		utils.SendErrorResponse(w, "target raid device not exists")
+		return
+	}
+
+	//Check the raid is healthy and ok for expansion
+	raidNotHealthy, err := m.RAIDArrayContainsFailedDisks(deviceName)
+	if err != nil {
+		utils.SendErrorResponse(w, "unable to check health state before expansion")
+		return
+	}
+	if raidNotHealthy {
+		utils.SendErrorResponse(w, "expand can only be performed on a healthy array")
+		return
+	}
+
+	//Expand the raid array
+	err = m.GrowRAIDDevice(deviceName)
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	err = m.RestartRAIDService()
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	utils.SendOK(w)
+}
+
+// HandleRenderOverview List the info and health of all loaded RAID array
+func (m *Manager) HandleRenderOverview(w http.ResponseWriter, r *http.Request) {
+	//Get all raid device from procmd
+	rdevs, err := m.GetRAIDDevicesFromProcMDStat()
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	type RaidHealthOverview struct {
+		Name      string
+		Status    string
+		Level     string
+		UsedSize  int64
+		TotalSize int64
+		IsHealthy bool
+	}
+
+	results := []*RaidHealthOverview{}
+
+	//Get RAID Status for each devices
+	for _, raidDev := range rdevs {
+		//Fill in the basic information
+		thisRaidOverview := RaidHealthOverview{
+			Name:      raidDev.Name,
+			Status:    raidDev.Status,
+			Level:     raidDev.Level,
+			UsedSize:  -1,
+			TotalSize: -1,
+			IsHealthy: false,
+		}
+
+		//Get health status of RAID
+		raidPath := filepath.Join("/dev/", strings.TrimPrefix(raidDev.Name, "/dev/"))
+		raidStatus, err := GetRAIDStatus(raidPath)
+		if err == nil {
+			thisRaidOverview.IsHealthy = raidStatus.isHealthy()
+		}
+
+		// Get RAID vol size and info
+		raidPartitionSize, err := GetRAIDPartitionSize(raidPath)
+		if err == nil {
+			thisRaidOverview.TotalSize = raidPartitionSize
+		}
+
+		raidUsedSize, err := GetRAIDUsedSize(raidPath)
+		if err == nil {
+			thisRaidOverview.UsedSize = raidUsedSize
+		}
+
+		results = append(results, &thisRaidOverview)
+	}
+
+	js, _ := json.Marshal(results)
+	utils.SendJSONResponse(w, string(js))
+}

+ 135 - 0
mod/disktool/raid/losetup.go

@@ -0,0 +1,135 @@
+package raid
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+)
+
+/*
+	losetup.go
+
+	This script handle losetup loopback interface setup and listing
+*/
+
+type LoopDevice struct {
+	Device         string
+	PartitionRange string
+	ImageFile      string
+}
+
+// List all the loop devices
+func ListAllLoopDevices() ([]*LoopDevice, error) {
+	cmd := exec.Command("sudo", "losetup", "-a")
+
+	output, err := cmd.CombinedOutput()
+	if err != nil {
+		return nil, fmt.Errorf("error running losetup -a command: %v", err)
+	}
+
+	//Example of returned values
+	// /dev/loop0: [2049]:265955 (/home/aroz/test/sdX.img)
+
+	// Split the output into lines and extract device names
+	lines := strings.Split(string(output), "\n")
+	var devices []*LoopDevice = []*LoopDevice{}
+	for _, line := range lines {
+		fields := strings.Fields(line)
+		if len(fields) >= 3 {
+			//As the image name contains a bracket, that needs to be trimmed off
+			imageName := strings.TrimPrefix(strings.TrimSpace(fields[2]), "(")
+			imageName = strings.TrimSuffix(imageName, ")")
+			devices = append(devices, &LoopDevice{
+				Device:         strings.TrimSuffix(fields[0], ":"),
+				PartitionRange: fields[1],
+				ImageFile:      imageName,
+			})
+		}
+	}
+
+	return devices, nil
+}
+
+// Mount an given image path as loopback device
+func MountImageAsLoopDevice(imagePath string) error {
+	cmd := exec.Command("sudo", "losetup", "-f", imagePath)
+
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+
+	err := cmd.Run()
+	if err != nil {
+		return fmt.Errorf("creating loopback device failed: %v", err)
+	}
+
+	return nil
+}
+
+// Unmount a loop device by the image path
+func UnmountLoopDeviceByImagePath(imagePath string) error {
+	imagePathIsMounted, err := ImageMountedAsLoopDevice(imagePath)
+	if err != nil {
+		return err
+	}
+
+	if !imagePathIsMounted {
+		//Image already unmounted. No need to unmount
+		return nil
+	}
+
+	loopDriveID, err := GetLoopDriveIDFromImagePath(imagePath)
+	if err != nil {
+		return err
+	}
+
+	//As we checked for mounted above, no need to check if loopDriveID is empty string
+	return UnmountLoopDeviceByID(loopDriveID)
+}
+
+// Unmount the loop device by id e.g. /dev/loop1
+func UnmountLoopDeviceByID(loopDevId string) error {
+	cmd := exec.Command("sudo", "losetup", "-d", loopDevId)
+
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+
+	err := cmd.Run()
+	if err != nil {
+		return fmt.Errorf("delete loopback device failed: %v", err)
+	}
+
+	return nil
+}
+
+// Get loopdrive ID (/dev/loop1) by the image path, return empty string if not found error if load failed
+func GetLoopDriveIDFromImagePath(imagePath string) (string, error) {
+	absPath, err := filepath.Abs(imagePath)
+	if err != nil {
+		return "", err
+	}
+
+	devs, err := ListAllLoopDevices()
+	if err != nil {
+		return "", err
+	}
+
+	for _, dev := range devs {
+		if filepath.ToSlash(dev.ImageFile) == filepath.ToSlash(absPath) {
+			//Found. already mounted
+			return dev.Device, nil
+		}
+	}
+
+	return "", nil
+}
+
+// Check if an image file is already mounted as loop drive
+func ImageMountedAsLoopDevice(imagePath string) (bool, error) {
+	loopDriveId, err := GetLoopDriveIDFromImagePath(imagePath)
+	if err != nil {
+		return false, err
+	}
+	return loopDriveId != "", nil
+}

+ 318 - 0
mod/disktool/raid/mdadm.go

@@ -0,0 +1,318 @@
+package raid
+
+import (
+	"errors"
+	"fmt"
+	"log"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"sort"
+	"strconv"
+	"strings"
+
+	"imuslab.com/bokofs/bokofsd/mod/utils"
+)
+
+/*
+	mdadm manager
+
+	This script handles the interaction with mdadm
+*/
+
+// RAIDDevice represents information about a RAID device.
+type RAIDMember struct {
+	Name   string //sdX
+	Seq    int    //Sequence in RAID arary
+	Failed bool   //If details output with (F) tag this will set to true
+}
+
+type RAIDDevice struct {
+	Name    string
+	Status  string
+	Level   string
+	Members []*RAIDMember
+}
+
+// Return the uuid of the disk by its path name (e.g. /dev/sda)
+func (m *Manager) GetDiskUUIDByPath(devicePath string) (string, error) {
+	cmd := exec.Command("sudo", "blkid", devicePath)
+
+	output, err := cmd.CombinedOutput()
+	if err != nil {
+		return "", fmt.Errorf("blkid error: %v", err)
+	}
+
+	// Parse the output to extract the UUID
+	fields := strings.Fields(string(output))
+	for _, field := range fields {
+		if strings.HasPrefix(field, "UUID=") {
+			uuid := strings.TrimPrefix(field, "UUID=\"")
+			uuid = strings.TrimSuffix(uuid, "\"")
+			return uuid, nil
+		}
+	}
+
+	return "", fmt.Errorf("UUID not found for device %s", devicePath)
+}
+
+// CreateRAIDDevice creates a RAID device using the mdadm command.
+func (m *Manager) CreateRAIDDevice(devName string, raidName string, raidLevel int, raidDeviceIds []string, spareDeviceIds []string) error {
+	//Calculate the size of the raid devices
+	raidDev := len(raidDeviceIds)
+	spareDevice := len(spareDeviceIds)
+
+	//Validate if raid level
+	if !IsValidRAIDLevel("raid" + strconv.Itoa(raidLevel)) {
+		return fmt.Errorf("invalid or unsupported raid level given: raid" + strconv.Itoa(raidLevel))
+	}
+
+	//Validate the number of disk is enough for the raid
+	if raidLevel == 0 && raidDev < 2 {
+		return fmt.Errorf("not enough disks for raid0")
+	} else if raidLevel == 1 && raidDev < 2 {
+		return fmt.Errorf("not enough disks for raid1")
+	} else if raidLevel == 5 && raidDev < 3 {
+		return fmt.Errorf("not enough disk for raid5")
+	} else if raidLevel == 6 && raidDev < 4 {
+		return fmt.Errorf("not enough disk for raid6")
+	}
+
+	//Append /dev to the name if missing
+	if !strings.HasPrefix(devName, "/dev/") {
+		devName = "/dev/" + devName
+	}
+
+	if utils.FileExists(devName) {
+		//RAID device already exists
+		return errors.New(devName + " already been used")
+	}
+
+	//Append /dev to the name of the raid device ids and spare device ids if missing
+	for i, raidDev := range raidDeviceIds {
+		if !strings.HasPrefix(raidDev, "/dev/") {
+			raidDeviceIds[i] = filepath.Join("/dev/", raidDev)
+		}
+	}
+	for i, spareDev := range spareDeviceIds {
+		if !strings.HasPrefix(spareDev, "/dev/") {
+			spareDeviceIds[i] = filepath.Join("/dev/", spareDev)
+		}
+	}
+
+	// Concatenate RAID and spare device arrays
+	allDeviceIds := append(raidDeviceIds, spareDeviceIds...)
+
+	// Build the mdadm command
+	mdadmCommand := fmt.Sprintf("yes | sudo mdadm --create %s --name %s --level=%d --raid-devices=%d --spare-devices=%d %s", devName, raidName, raidLevel, raidDev, spareDevice, strings.Join(allDeviceIds, " "))
+	if raidLevel == 0 {
+		//raid0 cannot use --spare-device command as there is no failover
+		mdadmCommand = fmt.Sprintf("yes | sudo mdadm --create %s --name %s --level=%d --raid-devices=%d %s", devName, raidName, raidLevel, raidDev, strings.Join(allDeviceIds, " "))
+	}
+	cmd := exec.Command("bash", "-c", mdadmCommand)
+
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+
+	err := cmd.Run()
+	if err != nil {
+		return fmt.Errorf("error running mdadm command: %v", err)
+	}
+
+	return nil
+}
+
+// GetRAIDDevicesFromProcMDStat retrieves information about RAID devices from /proc/mdstat.
+// if your RAID array is in auto-read-only mode, it is (usually) brand new
+func (m *Manager) GetRAIDDevicesFromProcMDStat() ([]RAIDDevice, error) {
+	// Execute the cat command to read /proc/mdstat
+	cmd := exec.Command("cat", "/proc/mdstat")
+
+	// Run the command and capture its output
+	output, err := cmd.Output()
+	if err != nil {
+		return nil, fmt.Errorf("error running cat command: %v", err)
+	}
+
+	// Convert the output to a string and split it into lines
+	lines := strings.Split(string(output), "\n")
+
+	// Initialize an empty slice to store RAID devices
+	raidDevices := make([]RAIDDevice, 0)
+
+	// Iterate over the lines, skipping the first line (Personalities)
+	// Lines usually looks like this
+	// md0 : active raid1 sdc[1] sdb[0]
+	for _, line := range lines[1:] {
+		// Skip empty lines
+		if line == "" {
+			continue
+		}
+
+		// Split the line by colon (:)
+		parts := strings.SplitN(line, " : ", 2)
+		if len(parts) != 2 {
+			continue
+		}
+
+		// Extract device name and status
+		deviceName := parts[0]
+
+		// Split the members string by space to get individual member devices
+		info := strings.Fields(parts[1])
+		if len(info) < 2 {
+			//Malform output
+			continue
+		}
+
+		deviceStatus := info[0]
+
+		//Raid level usually appears at position 1 - 2, check both
+		raidLevel := ""
+		if strings.HasPrefix(info[1], "raid") {
+			raidLevel = info[1]
+		} else if strings.HasPrefix(info[2], "raid") {
+			raidLevel = info[2]
+		}
+
+		//Get the members (disks) of the array
+		members := []*RAIDMember{}
+		for _, disk := range info[2:] {
+			if !strings.HasPrefix(disk, "sd") {
+				//Probably not a storage device
+				continue
+			}
+
+			//In sda[0] format, we need to split out the number from the disk seq
+			tmp := strings.Split(disk, "[")
+			if len(tmp) != 2 {
+				continue
+			}
+
+			//Convert the sequence to id
+			diskFailed := false
+			if strings.HasSuffix(strings.TrimSpace(tmp[1]), "(F)") {
+				//Trim off the Fail label
+				diskFailed = true
+				tmp[1] = strings.TrimSuffix(strings.TrimSpace(tmp[1]), "(F)")
+			}
+			seqInt, err := strconv.Atoi(strings.TrimSuffix(strings.TrimSpace(tmp[1]), "]"))
+			if err != nil {
+				//Not an integer?
+				log.Println("[RAID] Unable to parse " + disk + " sequence ID")
+				continue
+			}
+			member := RAIDMember{
+				Name:   strings.TrimSpace(tmp[0]),
+				Seq:    seqInt,
+				Failed: diskFailed,
+			}
+
+			members = append(members, &member)
+		}
+
+		//Sort the member disks
+		sort.Slice(members[:], func(i, j int) bool {
+			return members[i].Seq < members[j].Seq
+		})
+
+		// Create a RAIDDevice struct and append it to the slice
+		raidDevice := RAIDDevice{
+			Name:    deviceName,
+			Status:  deviceStatus,
+			Level:   raidLevel,
+			Members: members,
+		}
+		raidDevices = append(raidDevices, raidDevice)
+	}
+
+	return raidDevices, nil
+}
+
+// Check if a disk is failed in given array
+func (m *Manager) DiskIsFailed(mdDevice, diskPath string) (bool, error) {
+	raidDevices, err := m.GetRAIDDeviceByDevicePath(mdDevice)
+	if err != nil {
+		return false, err
+	}
+
+	diskName := filepath.Base(diskPath)
+
+	for _, disk := range raidDevices.Members {
+		if disk.Name == diskName {
+			return disk.Failed, nil
+		}
+	}
+
+	return false, errors.New("target disk not found in this array")
+}
+
+// FailDisk label a disk as failed
+func (m *Manager) FailDisk(mdDevice, diskPath string) error {
+	//mdadm commands require full path
+	if !strings.HasPrefix(diskPath, "/dev/") {
+		diskPath = filepath.Join("/dev/", diskPath)
+	}
+	if !strings.HasPrefix(mdDevice, "/dev/") {
+		mdDevice = filepath.Join("/dev/", mdDevice)
+	}
+
+	cmd := exec.Command("sudo", "mdadm", mdDevice, "--fail", diskPath)
+	if err := cmd.Run(); err != nil {
+		return fmt.Errorf("failed to fail disk: %v", err)
+	}
+	return nil
+}
+
+// RemoveDisk removes a failed disk from the specified RAID array using mdadm.
+// must be failed before remove
+func (m *Manager) RemoveDisk(mdDevice, diskPath string) error {
+	//mdadm commands require full path
+	if !strings.HasPrefix(diskPath, "/dev/") {
+		diskPath = filepath.Join("/dev/", diskPath)
+	}
+	if !strings.HasPrefix(mdDevice, "/dev/") {
+		mdDevice = filepath.Join("/dev/", mdDevice)
+	}
+
+	cmd := exec.Command("sudo", "mdadm", mdDevice, "--remove", diskPath)
+	if err := cmd.Run(); err != nil {
+		return fmt.Errorf("failed to remove disk: %v", err)
+	}
+	return nil
+}
+
+// Add disk to a given RAID array, must be unmounted and not in-use
+func (m *Manager) AddDisk(mdDevice, diskPath string) error {
+	//mdadm commands require full path
+	if !strings.HasPrefix(diskPath, "/dev/") {
+		diskPath = filepath.Join("/dev/", diskPath)
+	}
+	if !strings.HasPrefix(mdDevice, "/dev/") {
+		mdDevice = filepath.Join("/dev/", mdDevice)
+	}
+
+	cmd := exec.Command("sudo", "mdadm", mdDevice, "--add", diskPath)
+	if err := cmd.Run(); err != nil {
+		return fmt.Errorf("failed to add disk: %v", err)
+	}
+	return nil
+}
+
+// GrowRAIDDevice grows the specified RAID device to its maximum size
+func (m *Manager) GrowRAIDDevice(deviceName string) error {
+	//Prevent anyone passing /dev/md0 into the deviceName field
+	deviceName = strings.TrimPrefix(deviceName, "/dev/")
+
+	// Construct the mdadm command
+	cmd := exec.Command("sudo", "mdadm", "--grow", fmt.Sprintf("/dev/%s", deviceName), "--size=max")
+
+	// Execute the command
+	output, err := cmd.CombinedOutput()
+	if err != nil {
+		return fmt.Errorf("failed to grow RAID device: %v, output: %s", err, string(output))
+	}
+
+	fmt.Printf("[RAID] Successfully grew RAID device %s. Output: %s\n", deviceName, string(output))
+	return nil
+}

+ 205 - 0
mod/disktool/raid/mdadmConf.go

@@ -0,0 +1,205 @@
+package raid
+
+import (
+	"fmt"
+	"log"
+	"os"
+	"os/exec"
+	"strings"
+	"time"
+
+	"imuslab.com/bokofs/bokofsd/mod/disktool/diskfs"
+	"imuslab.com/bokofs/bokofsd/mod/utils"
+)
+
+/*
+	mdadmConf.go
+
+	This package handles the config modification and update for
+	the mdadm module
+
+
+*/
+
+// Force mdadm to stop all RAID and load fresh from config file
+// on some Linux distro this is required as mdadm start too early
+func (m *Manager) FlushReload() error {
+	//Get a list of currently running RAID devices
+	raidDevices, err := m.GetRAIDDevicesFromProcMDStat()
+	if err != nil {
+		return err
+	}
+
+	//Stop all of the running RAID devices
+	for _, rd := range raidDevices {
+
+		//Check if it is mounted. If yes, skip this
+		devMounted, err := diskfs.DeviceIsMounted("/dev/" + rd.Name)
+		if devMounted || err != nil {
+			log.Println("[RAID] " + "/dev/" + rd.Name + " is in use. Skipping.")
+			continue
+		}
+		log.Println("[RAID] Stopping " + rd.Name)
+
+		cmdMdadm := exec.Command("sudo", "mdadm", "--stop", "/dev/"+rd.Name)
+
+		// Run the command and capture its output
+		_, err = cmdMdadm.Output()
+		if err != nil {
+			log.Println("[RAID] Unable to stop " + rd.Name + ". Skipping")
+			continue
+		}
+	}
+
+	time.Sleep(300 * time.Millisecond)
+
+	//Assemble mdadm array again
+	err = m.RestartRAIDService()
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// removeDevicesEntry remove device hardcode from mdadm config file
+func removeDevicesEntry(configLine string) string {
+	// Split the config line by space character
+	tokens := strings.Fields(configLine)
+
+	// Iterate through the tokens to find and remove the devices=* part
+	for i, token := range tokens {
+		if strings.HasPrefix(token, "devices=") {
+			// Remove the devices=* part from the slice
+			tokens = append(tokens[:i], tokens[i+1:]...)
+			break
+		}
+	}
+
+	// Join the tokens back into a single string
+	updatedConfigLine := strings.Join(tokens, " ")
+
+	return updatedConfigLine
+}
+
+// Updates the mdadm configuration file with the details of RAID arrays
+// so the RAID drive will still be seen after a reboot (hopefully)
+// this will automatically add / remove config base on current runtime setup
+func (m *Manager) UpdateMDADMConfig() error {
+	cmdMdadm := exec.Command("sudo", "mdadm", "--detail", "--scan", "--verbose")
+
+	// Run the command and capture its output
+	output, err := cmdMdadm.Output()
+	if err != nil {
+		return fmt.Errorf("error running mdadm command: %v", err)
+	}
+
+	//Load the config from system
+	currentConfigBytes, err := os.ReadFile("/etc/mdadm/mdadm.conf")
+	if err != nil {
+		return fmt.Errorf("unable to open mdadm.conf: " + err.Error())
+	}
+	currentConf := string(currentConfigBytes)
+
+	//Check if the current config already contains the setting
+	newConfigLines := []string{}
+	uuidsInNewConfig := []string{}
+	arrayConfigs := strings.TrimSpace(string(output))
+	lines := strings.Split(arrayConfigs, "ARRAY")
+	for _, line := range lines {
+		//For each line, you should have something like this
+		//ARRAY /dev/md0 metadata=1.2 name=debian:0 UUID=cbc11a2b:fbd42653:99c1340b:9c4962fb
+		//   devices=/dev/sdb,/dev/sdc
+		//Building structure for RAID Config Record
+
+		line = strings.ReplaceAll(line, "\n", " ")
+		fields := strings.Fields(line)
+		if len(fields) < 5 {
+			continue
+		}
+		poolUUID := strings.TrimPrefix(fields[3], "UUID=")
+		uuidsInNewConfig = append(uuidsInNewConfig, poolUUID)
+		//Check if this uuid already in the config file
+		if strings.Contains(currentConf, poolUUID) {
+			continue
+		}
+
+		//This config not exists in the settings. Add it to append lines
+		log.Println("[RAID] Adding " + fields[0] + " (UUID=" + poolUUID + ") into mdadm config")
+		settingLine := "ARRAY " + strings.Join(fields, " ")
+
+		//Remove the device specific names
+		settingLine = removeDevicesEntry(settingLine)
+		newConfigLines = append(newConfigLines, settingLine)
+	}
+
+	originalConfigLines := strings.Split(strings.TrimSpace(currentConf), "\n")
+	poolUUIDToBeRemoved := []string{}
+	for _, line := range originalConfigLines {
+		lineFields := strings.Fields(line)
+		for _, thisField := range lineFields {
+			if strings.HasPrefix(thisField, "UUID=") {
+				//This is the UUID of this array. Check if it still exists in new storage config
+				thisPoolUUID := strings.TrimPrefix(thisField, "UUID=")
+				existsInNewConfig := utils.StringInArray(uuidsInNewConfig, thisPoolUUID)
+				if !existsInNewConfig {
+					//Label this UUID to be removed
+					poolUUIDToBeRemoved = append(poolUUIDToBeRemoved, thisPoolUUID)
+				}
+
+				//Skip scanning the remaining fields of this RAID pool
+				break
+			}
+		}
+	}
+
+	if len(poolUUIDToBeRemoved) > 0 {
+		//Remove the old UUIDs from config
+		for _, volumeUUID := range poolUUIDToBeRemoved {
+			err = m.RemoveVolumeFromMDADMConfig(volumeUUID)
+			if err != nil {
+				log.Println("[RAID] Error when trying to remove old RAID volume from config: " + err.Error())
+				return err
+			} else {
+				log.Println("[RAID] RAID volume " + volumeUUID + " removed from config file")
+			}
+		}
+
+	}
+
+	if len(newConfigLines) == 0 {
+		//Nothing to write
+		log.Println("[RAID] Nothing to write. Skipping mdadm config update.")
+		return nil
+	}
+
+	// Construct the bash command to append the line to mdadm.conf using echo and tee
+	for _, configLine := range newConfigLines {
+		cmd := exec.Command("bash", "-c", fmt.Sprintf(`echo "%s" | sudo tee -a /etc/mdadm/mdadm.conf`, configLine))
+
+		// Run the command
+		err := cmd.Run()
+		if err != nil {
+			return fmt.Errorf("error injecting line into mdadm.conf: %v", err)
+		}
+	}
+
+	return nil
+}
+
+// Removes a RAID volume from the mdadm configuration file given its volume UUID.
+// Note that this only remove a single line of config. If your line consists of multiple lines
+// you might need to remove it manually
+func (m *Manager) RemoveVolumeFromMDADMConfig(volumeUUID string) error {
+	// Construct the sed command to remove the line containing the volume UUID from mdadm.conf
+	sedCommand := fmt.Sprintf(`sudo sed -i '/UUID=%s/d' /etc/mdadm/mdadm.conf`, volumeUUID)
+
+	// Execute the sed command
+	cmd := exec.Command("bash", "-c", sedCommand)
+	err := cmd.Run()
+	if err != nil {
+		return fmt.Errorf("error removing volume from mdadm.conf: %v", err)
+	}
+
+	return nil
+}

+ 87 - 0
mod/disktool/raid/raid.go

@@ -0,0 +1,87 @@
+package raid
+
+import (
+	"errors"
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"runtime"
+)
+
+/*
+	RAID management package for handling RAID and Virtual Image Creation
+	for Linux with mdadm installed
+*/
+
+type Manager struct {
+}
+
+func PackageExists(packageName string) (bool, error) {
+	cmd := exec.Command("dpkg-query", "-W", "-f='${Status}'", packageName)
+	output, err := cmd.Output()
+	if err != nil {
+		if exitError, ok := err.(*exec.ExitError); ok && exitError.ExitCode() == 1 {
+			// Package not found
+			return false, nil
+		}
+		return false, fmt.Errorf("error checking package: %v", err)
+	}
+
+	// Check if the output contains "install ok installed"
+	return string(output) == "'install ok installed'", nil
+}
+
+// Create a new raid manager
+func NewRaidManager() (*Manager, error) {
+	//Check if platform is supported
+	if runtime.GOOS != "linux" {
+		return nil, errors.New("ArozOS do not support RAID management on this platform")
+	}
+
+	//Check if mdadm exists
+	mdadmExists, err := PackageExists("mdadm")
+	if err != nil || !mdadmExists {
+		return nil, errors.New("mdadm not installed on this host")
+	}
+	return &Manager{}, nil
+}
+
+// Create a virtual image partition at given path with given size
+func CreateVirtualPartition(imagePath string, totalSize int64) error {
+	cmd := exec.Command("sudo", "dd", "if=/dev/zero", "of="+imagePath, "bs=4M", "count="+fmt.Sprintf("%dM", totalSize/(4*1024*1024)))
+
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+
+	err := cmd.Run()
+	if err != nil {
+		return fmt.Errorf("dd error: %v", err)
+	}
+
+	return nil
+}
+
+// Format the given image file
+func FormatVirtualPartition(imagePath string) error {
+	//Check if image actually exists
+	if _, err := os.Stat(imagePath); os.IsNotExist(err) {
+		return errors.New("image file does not exist")
+	}
+
+	if filepath.Ext(imagePath) != ".img" {
+		return errors.New("given file is not an image path")
+	}
+
+	cmd := exec.Command("sudo", "mkfs.ext4", imagePath)
+
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+
+	err := cmd.Run()
+	if err != nil {
+		return fmt.Errorf("error running mkfs.ext4 command: %v", err)
+	}
+
+	return nil
+}

+ 76 - 0
mod/disktool/raid/raid_test.go

@@ -0,0 +1,76 @@
+package raid_test
+
+/*
+	RAID TEST SCRIPT
+
+	!!!! DO NOT RUN IN PRODUCTION !!!!
+	ONLY RUN IN VM ENVIRONMENT
+*/
+
+/*
+func TestRemoveRAIDFromConfig(t *testing.T) {
+	err := raid.RemoveVolumeFromMDADMConfig("cbc11a2b:fbd42653:99c1340b:9c4962fb")
+	if err != nil {
+		t.Errorf("Unexpected error: %v", err)
+		return
+	}
+}
+*/
+
+/*
+func TestAddRAIDToConfig(t *testing.T) {
+	err := raid.UpdateMDADMConfig()
+	if err != nil {
+		t.Errorf("Unexpected error: %v", err)
+		return
+	}
+}
+*/
+
+/*
+func TestReadRAIDInfo(t *testing.T) {
+	raidInfo, err := raid.GetRAIDInfo("/dev/md0")
+	if err != nil {
+		t.Errorf("Unexpected error: %v", err)
+		return
+	}
+
+	//Pretty print info for debug
+	raidInfo.PrettyPrintRAIDInfo()
+}
+
+*/
+
+/*
+func TestCreateRAIDDevice(t *testing.T) {
+	//Create an empty Manager
+	manager, _ := raid.NewRaidManager(raid.Options{})
+
+	// Make sure the sdb and sdc exists when running test case in VM
+	devName, _ := raid.GetNextAvailableMDDevice()
+	raidLevel := 1
+	raidDeviceIds := []string{"/dev/sdb", "/dev/sdc"}
+	spareDeviceIds := []string{}
+
+	//Format the drives
+	for _, partion := range raidDeviceIds {
+		fmt.Println("Wiping partition: " + partion)
+		err := manager.WipeDisk(partion)
+		if err != nil {
+			t.Errorf("Disk wipe error: %v", err)
+			return
+		}
+	}
+
+	// Call the function being tested
+	err := manager.CreateRAIDDevice(devName, raidLevel, raidDeviceIds, spareDeviceIds)
+	if err != nil {
+		t.Errorf("Unexpected error: %v", err)
+		return
+	}
+
+	fmt.Println("RAID array created")
+
+}
+
+*/

+ 176 - 0
mod/disktool/raid/raiddetails.go

@@ -0,0 +1,176 @@
+package raid
+
+import (
+	"fmt"
+	"os/exec"
+	"strconv"
+	"strings"
+	"time"
+)
+
+// RAIDInfo represents information about a RAID array.
+type RAIDInfo struct {
+	DevicePath     string
+	Version        string
+	CreationTime   time.Time
+	RaidLevel      string
+	ArraySize      int
+	UsedDevSize    int
+	RaidDevices    int
+	TotalDevices   int
+	Persistence    string
+	UpdateTime     time.Time
+	State          string
+	ActiveDevices  int
+	WorkingDevices int
+	FailedDevices  int
+	SpareDevices   int
+	Consistency    string
+	RebuildStatus  string
+	Name           string
+	UUID           string
+	Events         int
+	DeviceInfo     []DeviceInfo
+}
+
+// DeviceInfo represents information about a device in a RAID array.
+type DeviceInfo struct {
+	State      []string
+	DevicePath string
+	RaidDevice int //Sequence of the raid device?
+}
+
+// GetRAIDInfo retrieves information about a RAID array using the mdadm command.
+// arrayName must be in full path (e.g. /dev/md0)
+func (m *Manager) GetRAIDInfo(arrayName string) (*RAIDInfo, error) {
+	cmd := exec.Command("sudo", "mdadm", "--detail", arrayName)
+
+	output, err := cmd.Output()
+	if err != nil {
+		return nil, fmt.Errorf("error running mdadm command: %v", err)
+	}
+
+	info := parseRAIDInfo(string(output))
+
+	//Fill in the device path so other service can use it more easily
+	info.DevicePath = arrayName
+	return info, nil
+}
+
+// parseRAIDInfo parses the output of mdadm --detail command and returns the RAIDInfo struct.
+func parseRAIDInfo(output string) *RAIDInfo {
+	lines := strings.Split(output, "\n")
+
+	raidInfo := &RAIDInfo{}
+	for _, line := range lines {
+		fields := strings.Fields(line)
+		if len(fields) >= 2 {
+			switch fields[0] {
+			case "Version":
+				raidInfo.Version = fields[2]
+			case "Creation":
+				creationTimeStr := strings.Join(fields[3:], " ")
+				creationTime, _ := time.Parse(time.ANSIC, creationTimeStr)
+				raidInfo.CreationTime = creationTime
+			case "Raid":
+				if fields[1] == "Level" {
+					//Raid Level
+					raidInfo.RaidLevel = fields[3]
+				} else if fields[1] == "Devices" {
+					raidInfo.RaidDevices, _ = strconv.Atoi(fields[3])
+				}
+			case "Array":
+				raidInfo.ArraySize, _ = strconv.Atoi(fields[3])
+			case "Used":
+				raidInfo.UsedDevSize, _ = strconv.Atoi(fields[4])
+			case "Total":
+				raidInfo.TotalDevices, _ = strconv.Atoi(fields[3])
+			case "Persistence":
+				raidInfo.Persistence = strings.Join(fields[2:], " ")
+			case "Update":
+				updateTimeStr := strings.Join(fields[3:], " ")
+				updateTime, _ := time.Parse(time.ANSIC, updateTimeStr)
+				raidInfo.UpdateTime = updateTime
+			case "State":
+				raidInfo.State = strings.Join(fields[2:], " ")
+			case "Active":
+				raidInfo.ActiveDevices, _ = strconv.Atoi(fields[3])
+			case "Working":
+				raidInfo.WorkingDevices, _ = strconv.Atoi(fields[3])
+			case "Failed":
+				raidInfo.FailedDevices, _ = strconv.Atoi(fields[3])
+			case "Spare":
+				raidInfo.SpareDevices, _ = strconv.Atoi(fields[3])
+			case "Consistency":
+				raidInfo.Consistency = strings.Join(fields[3:], " ")
+			case "Rebuild":
+				raidInfo.RebuildStatus = strings.Join(fields[3:], " ")
+			case "Name":
+				raidInfo.Name = strings.Join(fields[2:], " ")
+			case "UUID":
+				raidInfo.UUID = fields[2]
+			case "Events":
+				raidInfo.Events, _ = strconv.Atoi(fields[2])
+			default:
+				if len(fields) >= 5 && fields[0] != "Number" {
+					deviceInfo := DeviceInfo{}
+
+					if len(fields) > 3 {
+						rdNo, err := strconv.Atoi(fields[3])
+						if err != nil {
+							rdNo = -1
+						}
+						deviceInfo.RaidDevice = rdNo
+
+					}
+
+					if len(fields) > 5 {
+						//Only active disks have fields > 5, e.g.
+						// 0       8       16        0      active sync   /dev/sdb
+						deviceInfo.State = fields[4 : len(fields)-1]
+						deviceInfo.DevicePath = fields[len(fields)-1]
+					} else {
+						//Failed disk, e.g.
+						//  -       0        0        1      removed
+
+						deviceInfo.State = fields[4:]
+						//TODO: Add custom tags
+					}
+
+					raidInfo.DeviceInfo = append(raidInfo.DeviceInfo, deviceInfo)
+				}
+			}
+		}
+	}
+
+	return raidInfo
+}
+
+// PrettyPrintRAIDInfo pretty prints the RAIDInfo struct.
+func (info *RAIDInfo) PrettyPrintRAIDInfo() {
+	fmt.Println("RAID Array Information:")
+	fmt.Printf("  Version: %s\n", info.Version)
+	fmt.Printf("  Creation Time: %s\n", info.CreationTime.Format("Mon Jan 02 15:04:05 2006"))
+	fmt.Printf("  Raid Level: %s\n", info.RaidLevel)
+	fmt.Printf("  Array Size: %d\n", info.ArraySize)
+	fmt.Printf("  Used Dev Size: %d\n", info.UsedDevSize)
+	fmt.Printf("  Raid Devices: %d\n", info.RaidDevices)
+	fmt.Printf("  Total Devices: %d\n", info.TotalDevices)
+	fmt.Printf("  Persistence: %s\n", info.Persistence)
+	fmt.Printf("  Update Time: %s\n", info.UpdateTime.Format("Mon Jan 02 15:04:05 2006"))
+	fmt.Printf("  State: %s\n", info.State)
+	fmt.Printf("  Active Devices: %d\n", info.ActiveDevices)
+	fmt.Printf("  Working Devices: %d\n", info.WorkingDevices)
+	fmt.Printf("  Failed Devices: %d\n", info.FailedDevices)
+	fmt.Printf("  Spare Devices: %d\n", info.SpareDevices)
+	fmt.Printf("  Consistency Policy: %s\n", info.Consistency)
+	fmt.Printf("  Name: %s\n", info.Name)
+	fmt.Printf("  UUID: %s\n", info.UUID)
+	fmt.Printf("  Events: %d\n", info.Events)
+
+	fmt.Println("\nDevice Information:")
+	fmt.Printf("%s %s\n", "State", "DevicePath")
+	for _, device := range info.DeviceInfo {
+		fmt.Printf("%s %s\n", strings.Join(device.State, ","), device.DevicePath)
+	}
+}

+ 291 - 0
mod/disktool/raid/raidutils.go

@@ -0,0 +1,291 @@
+package raid
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strconv"
+	"strings"
+
+	"imuslab.com/bokofs/bokofsd/mod/disktool/diskfs"
+)
+
+// Get the next avaible RAID array name
+func GetNextAvailableMDDevice() (string, error) {
+	for i := 0; i < 100; i++ {
+		mdDevice := fmt.Sprintf("/dev/md%d", i)
+		if _, err := os.Stat(mdDevice); os.IsNotExist(err) {
+			return mdDevice, nil
+		}
+	}
+
+	return "", fmt.Errorf("no available /dev/mdX devices found")
+}
+
+// Check if the given device is safe to remove from the array without losing data
+func (m *Manager) IsSafeToRemove(mdDev string, sdXDev string) bool {
+	targetRAIDVol, err := m.GetRAIDDeviceByDevicePath(mdDev)
+	if err != nil {
+		return false
+	}
+
+	//Trim off the /dev/ part if exists
+	sdXDev = filepath.Base(sdXDev)
+
+	//Check how many members left if this is removed
+	remainingMemebers := 0
+	for _, member := range targetRAIDVol.Members {
+		if member.Name != sdXDev {
+			remainingMemebers++
+		}
+	}
+
+	//Check if removal of sdX will cause data loss
+	if strings.EqualFold(targetRAIDVol.Level, "raid0") {
+		return false
+	} else if strings.EqualFold(targetRAIDVol.Level, "raid1") {
+		//In raid1, you need at least 1 disk to hold data
+		return remainingMemebers >= 1
+	} else if strings.EqualFold(targetRAIDVol.Level, "raid5") {
+		//In raid 5, at least 2 disk is needed before data loss
+		return remainingMemebers >= 2
+	} else if strings.EqualFold(targetRAIDVol.Level, "raid6") {
+		//In raid 6, you need 6 disks with max loss = 2 disks
+		return remainingMemebers >= 2
+	}
+
+	return true
+}
+
+// Check if the given disk (sdX) is currently used in any volume
+func (m *Manager) DiskIsUsedInAnotherRAIDVol(sdXDev string) (bool, error) {
+	raidPools, err := m.GetRAIDDevicesFromProcMDStat()
+	if err != nil {
+		return false, errors.New("unable to access RAID controller state")
+	}
+
+	for _, md := range raidPools {
+		for _, member := range md.Members {
+			if member.Name == filepath.Base(sdXDev) {
+				return true, nil
+			}
+		}
+	}
+
+	return false, nil
+}
+
+// Check if the given disk (sdX) is root drive (the disk that install the OS, aka /)
+func (m *Manager) DiskIsRoot(sdXDev string) (bool, error) {
+	bdMeta, err := diskfs.GetBlockDeviceMeta(sdXDev)
+	if err != nil {
+		return false, err
+	}
+
+	for _, partition := range bdMeta.Children {
+		if partition.Mountpoint == "/" {
+			//Root
+			return true, nil
+		}
+	}
+	return false, nil
+}
+
+// ClearSuperblock clears the superblock of the specified disk so it can be used safely
+func (m *Manager) ClearSuperblock(devicePath string) error {
+	isMounted, err := diskfs.DeviceIsMounted(devicePath)
+	if err != nil {
+		return errors.New("unable to validate if the device is unmounted: " + err.Error())
+	}
+	if isMounted {
+		return errors.New("target device is mounted. Make sure it is unmounted before clearing")
+	}
+
+	//Make sure there are /dev/ in front of the device path
+	if !strings.HasPrefix(devicePath, "/dev/") {
+		devicePath = filepath.Join("/dev/", devicePath)
+	}
+	cmd := exec.Command("sudo", "mdadm", "--zero-superblock", devicePath)
+
+	err = cmd.Run()
+	if err != nil {
+		return fmt.Errorf("error clearing superblock: %v", err)
+	}
+
+	return nil
+}
+
+// Use to restart any not-removed RAID device
+func (m *Manager) RestartRAIDService() error {
+	cmd := exec.Command("sudo", "mdadm", "--assemble", "--scan")
+
+	// Run the command
+	output, err := cmd.CombinedOutput()
+	if err != nil {
+		if string(output) == "" {
+			//Nothing updated in config.
+			return nil
+		}
+		return fmt.Errorf("error restarting RAID device: %s", strings.TrimSpace(string(output)))
+	}
+
+	return nil
+}
+
+// Stop RAID device with given path
+func (m *Manager) StopRAIDDevice(devicePath string) error {
+	cmd := exec.Command("sudo", "mdadm", "--stop", devicePath)
+
+	// Run the command
+	err := cmd.Run()
+	if err != nil {
+		return fmt.Errorf("error stopping RAID device: %v", err)
+	}
+
+	return nil
+}
+
+// RemoveRAIDDevice removes the specified RAID device member (disk).
+func (m *Manager) RemoveRAIDMember(devicePath string) error {
+	// Construct the mdadm command to remove the RAID device
+	cmd := exec.Command("sudo", "mdadm", "--remove", devicePath)
+
+	// Run the command
+	output, err := cmd.CombinedOutput()
+	if err != nil {
+		// If there was an error, return the combined output and the error message
+		return fmt.Errorf("error removing RAID device: %s", strings.TrimSpace(string(output)))
+	}
+
+	return nil
+}
+
+// IsValidRAIDLevel checks if the given RAID level is valid.
+func IsValidRAIDLevel(level string) bool {
+	// List of valid RAID levels
+	validLevels := []string{"raid1", "raid0", "raid6", "raid5", "raid4", "raid10"}
+
+	// Convert the RAID level to lowercase and remove any surrounding whitespace
+	level = strings.TrimSpace(strings.ToLower(level))
+
+	// Check if the level exists in the list of valid levels
+	for _, validLevel := range validLevels {
+		if level == validLevel {
+			return true
+		}
+	}
+
+	// Return false if the level is not found in the list of valid levels
+	return false
+}
+
+// Get RAID device info from device path
+func (m *Manager) GetRAIDDeviceByDevicePath(devicePath string) (*RAIDDevice, error) {
+	//Strip the /dev/ part if it was accidentally passed in
+	devicePath = filepath.Base(devicePath)
+
+	//Get all the raid devices
+	rdevs, err := m.GetRAIDDevicesFromProcMDStat()
+	if err != nil {
+		return nil, err
+	}
+
+	//Check for match
+	for _, rdev := range rdevs {
+		if rdev.Name == devicePath {
+			return &rdev, nil
+		}
+	}
+
+	return nil, errors.New("target RAID device not found")
+}
+
+// Check if a RAID device exists, e.g. md0
+func (m *Manager) RAIDDeviceExists(devicePath string) bool {
+	_, err := m.GetRAIDDeviceByDevicePath(devicePath)
+	return err == nil
+}
+
+// Check if a RAID contain disk that failed or degraded given the devicePath, e.g. md0 or /dev/md0
+func (m *Manager) RAIDArrayContainsFailedDisks(devicePath string) (bool, error) {
+	raidDeviceInfo, err := m.GetRAIDInfo(devicePath)
+	if err != nil {
+		return false, err
+	}
+	return strings.Contains(raidDeviceInfo.State, "degraded") || strings.Contains(raidDeviceInfo.State, "faulty"), nil
+}
+
+// GetRAIDPartitionSize returns the size of the RAID partition in bytes as an int64
+func GetRAIDPartitionSize(devicePath string) (int64, error) {
+	// Ensure devicePath is formatted correctly
+	if !strings.HasPrefix(devicePath, "/dev/") {
+		devicePath = "/dev/" + devicePath
+	}
+
+	// Execute the df command with the device path
+	cmd := exec.Command("df", "--block-size=1", devicePath)
+	var out bytes.Buffer
+	cmd.Stdout = &out
+	if err := cmd.Run(); err != nil {
+		return 0, fmt.Errorf("failed to execute df command: %v", err)
+	}
+
+	// Parse the output to find the size
+	lines := strings.Split(out.String(), "\n")
+	if len(lines) < 2 {
+		return 0, fmt.Errorf("unexpected df output: %s", out.String())
+	}
+
+	// The second line should contain the relevant information
+	fields := strings.Fields(lines[1])
+	if len(fields) < 2 {
+		return 0, fmt.Errorf("unexpected df output: %s", lines[1])
+	}
+
+	// The second field should be the size in bytes
+	size, err := strconv.ParseInt(fields[1], 10, 64)
+	if err != nil {
+		return 0, fmt.Errorf("failed to parse size: %v", err)
+	}
+
+	return size, nil
+}
+
+// GetRAIDUsedSize returns the used size of the RAID array in bytes as an int64
+func GetRAIDUsedSize(devicePath string) (int64, error) {
+	// Ensure devicePath is formatted correctly
+	if !strings.HasPrefix(devicePath, "/dev/") {
+		devicePath = "/dev/" + devicePath
+	}
+
+	// Execute the df command with the device path
+	cmd := exec.Command("df", "--block-size=1", devicePath)
+	var out bytes.Buffer
+	cmd.Stdout = &out
+	if err := cmd.Run(); err != nil {
+		return 0, fmt.Errorf("failed to execute df command: %v", err)
+	}
+
+	// Parse the output to find the used size
+	lines := strings.Split(out.String(), "\n")
+	if len(lines) < 2 {
+		return 0, fmt.Errorf("unexpected df output: %s", out.String())
+	}
+
+	// The second line should contain the relevant information
+	fields := strings.Fields(lines[1])
+	if len(fields) < 3 {
+		return 0, fmt.Errorf("unexpected df output: %s", lines[1])
+	}
+
+	// The third field should be the used size in bytes
+	usedSize, err := strconv.ParseInt(fields[2], 10, 64)
+	if err != nil {
+		return 0, fmt.Errorf("failed to parse used size: %v", err)
+	}
+
+	return usedSize, nil
+}

+ 75 - 0
mod/disktool/raid/status.go

@@ -0,0 +1,75 @@
+package raid
+
+import (
+	"fmt"
+	"os/exec"
+)
+
+// RAIDStatus represents the status of a RAID array.
+type RAIDStatus int
+
+const (
+	RAIDStatusNormal    RAIDStatus = 0
+	RAIDStatusOneFailed RAIDStatus = 1
+	RAIDStatusUnusable  RAIDStatus = 2
+	RAIDStatusError     RAIDStatus = 4
+	RAIDStatusUnknown   RAIDStatus = -1
+)
+
+// GetRAIDStatus scans and checks a given RAID array and returns the array status.
+func GetRAIDStatus(arrayName string) (RAIDStatus, error) {
+	cmd := exec.Command("mdadm", "--detail", arrayName)
+
+	output, err := cmd.CombinedOutput()
+	if err != nil {
+		// Error occurred while getting information about the array
+		return RAIDStatusError, fmt.Errorf("error getting RAID array status: %v", err)
+	}
+
+	exitStatus := cmd.ProcessState.ExitCode()
+	switch exitStatus {
+	case 0:
+		// The array is functioning normally
+		return RAIDStatusNormal, nil
+	case 1:
+		// The array has at least one failed device
+		return RAIDStatusOneFailed, nil
+	case 2:
+		// The array has multiple failed devices such that it is unusable
+		return RAIDStatusUnusable, nil
+	case 4:
+		// There was an error while trying to get information about the device
+		return RAIDStatusError, fmt.Errorf("error getting information about the RAID array: %s", string(output))
+	default:
+		// Unknown exit status
+		return RAIDStatusUnknown, fmt.Errorf("unknown exit status: %d", exitStatus)
+	}
+}
+
+// toString returns the string representation of the RAIDStatus.
+func (status RAIDStatus) toString() string {
+	switch status {
+	case RAIDStatusNormal:
+		return "Normal"
+	case RAIDStatusOneFailed:
+		return "One Failed Device"
+	case RAIDStatusUnusable:
+		return "Unusable (Multiple Failed Devices)"
+	case RAIDStatusError:
+		return "Error"
+	case RAIDStatusUnknown:
+		return "Unknown"
+	default:
+		return "Invalid Status"
+	}
+}
+
+// Report if the RAID array is healthy
+func (status RAIDStatus) isHealthy() bool {
+	switch status {
+	case RAIDStatusNormal:
+		return true
+	default:
+		return false
+	}
+}

+ 132 - 0
mod/hardwareinfo/hardwareinfo.go

@@ -0,0 +1,132 @@
+package hardwareinfo
+
+import (
+	"encoding/json"
+	"log"
+	"net/http"
+	"os/exec"
+	"strings"
+
+	"imuslab.com/bokofs/bokofsd/mod/utils"
+)
+
+/*
+	Hardware Info
+	author: tobychui
+
+	This module is a migrated module from the original system.info.go script
+
+*/
+
+type CPUInfo struct {
+	Model       string
+	Freq        string
+	Instruction string
+	Hardware    string
+	Revision    string
+}
+
+type LogicalDisk struct {
+	DriveLetter string
+	FileSystem  string
+	FreeSpace   string
+}
+
+type ArOZInfo struct {
+	BuildVersion string
+	DeviceVendor string
+	DeviceModel  string
+	VendorIcon   string
+	SN           string
+	HostOS       string
+	CPUArch      string
+	HostName     string
+}
+
+type Server struct {
+	hostInfo ArOZInfo
+}
+
+func NewInfoServer(a ArOZInfo) *Server {
+	return &Server{
+		hostInfo: a,
+	}
+}
+
+/*
+PrintSystemHardwareDebugMessage print system information on Windows.
+Which is lagging but helpful for debugging wmic on Windows
+*/
+func PrintSystemHardwareDebugMessage() {
+	log.Println("Windows Version: " + wmicGetinfo("os", "Caption")[0])
+	log.Println("Total Memory: " + wmicGetinfo("ComputerSystem", "TotalPhysicalMemory")[0] + "B")
+	log.Println("Processor: " + wmicGetinfo("cpu", "Name")[0])
+	log.Println("Following disk was detected:")
+	for _, info := range wmicGetinfo("diskdrive", "Model") {
+		log.Println(info)
+	}
+}
+
+func (s *Server) GetArOZInfo(w http.ResponseWriter, r *http.Request) {
+	var jsonData []byte
+	jsonData, err := json.Marshal(s.hostInfo)
+	if err != nil {
+		log.Println(err)
+		return
+	}
+
+	loadImage, _ := utils.GetPara(r, "icon")
+	if loadImage != "true" {
+		t := ArOZInfo{}
+		json.Unmarshal(jsonData, &t)
+		t.VendorIcon = ""
+		jsonData, _ = json.Marshal(t)
+	}
+
+	utils.SendJSONResponse(w, string(jsonData))
+}
+
+func wmicGetinfo(wmicName string, itemName string) []string {
+	//get systeminfo
+	var InfoStorage []string
+
+	cmd := exec.Command("chcp", "65001")
+
+	cmd = exec.Command("wmic", wmicName, "list", "full", "/format:list")
+	if wmicName == "os" {
+		cmd = exec.Command("wmic", wmicName, "get", "*", "/format:list")
+	}
+
+	if len(wmicName) > 6 {
+		if wmicName[0:6] == "Win32_" {
+			cmd = exec.Command("wmic", "path", wmicName, "get", "*", "/format:list")
+		}
+	}
+	out, _ := cmd.CombinedOutput()
+	strOut := string(out)
+
+	strSplitedOut := strings.Split(strOut, "\n")
+	for _, strConfig := range strSplitedOut {
+		if strings.Contains(strConfig, "=") {
+			strSplitedConfig := strings.SplitN(strConfig, "=", 2)
+			if strSplitedConfig[0] == itemName {
+				strSplitedConfigReplaced := strings.Replace(strSplitedConfig[1], "\r", "", -1)
+				InfoStorage = append(InfoStorage, strSplitedConfigReplaced)
+			}
+		}
+
+	}
+	if len(InfoStorage) == 0 {
+		InfoStorage = append(InfoStorage, "Undefined")
+	}
+	return InfoStorage
+}
+
+func filterGrepResults(result string, sep string) string {
+	if strings.Contains(result, sep) == false {
+		return result
+	}
+	tmp := strings.Split(result, sep)
+	resultString := tmp[1]
+	return strings.TrimSpace(resultString)
+}

+ 204 - 0
mod/hardwareinfo/sysinfo.go

@@ -0,0 +1,204 @@
+//go:build linux
+// +build linux
+
+package hardwareinfo
+
+import (
+	"encoding/json"
+
+	"log"
+	"net/http"
+	"os/exec"
+	"strconv"
+	"strings"
+
+	"imuslab.com/bokofs/bokofsd/mod/utils"
+)
+
+func Ifconfig(w http.ResponseWriter, r *http.Request) {
+	cmdin := `ip link show`
+	cmd := exec.Command("bash", "-c", cmdin)
+	networkInterfaces, err := cmd.CombinedOutput()
+	if err != nil {
+		networkInterfaces = []byte{}
+	}
+
+	nic := strings.Split(string(networkInterfaces), "\n")
+
+	var arr []string
+	for _, info := range nic {
+		thisInfo := string(info)
+		arr = append(arr, thisInfo)
+	}
+
+	var jsonData []byte
+	jsonData, err = json.Marshal(arr)
+	if err != nil {
+		log.Println(err)
+	}
+	utils.SendTextResponse(w, string(jsonData))
+}
+
+func GetDriveStat(w http.ResponseWriter, r *http.Request) {
+	//Get drive status using df command
+	cmdin := `df -k | sed -e /Filesystem/d`
+	cmd := exec.Command("bash", "-c", cmdin)
+	dev, err := cmd.Output()
+	if err != nil {
+		dev = []byte{}
+	}
+
+	drives := strings.Split(string(dev), "\n")
+
+	if len(drives) == 0 {
+		utils.SendErrorResponse(w, "Invalid disk information")
+		return
+	}
+
+	var arr []LogicalDisk
+	for _, driveInfo := range drives {
+		if driveInfo == "" {
+			continue
+		}
+		for strings.Contains(driveInfo, "  ") {
+			driveInfo = strings.Replace(driveInfo, "  ", " ", -1)
+		}
+		driveInfoChunk := strings.Split(driveInfo, " ")
+		tmp, _ := strconv.Atoi(driveInfoChunk[3])
+		freespaceInByte := int64(tmp)
+
+		LogicalDisk := LogicalDisk{
+			DriveLetter: driveInfoChunk[5],
+			FileSystem:  driveInfoChunk[0],
+			FreeSpace:   strconv.FormatInt(freespaceInByte*1024, 10), //df show disk space in 1KB blocks
+		}
+		arr = append(arr, LogicalDisk)
+	}
+
+	var jsonData []byte
+	jsonData, err = json.Marshal(arr)
+	if err != nil {
+		log.Println(err)
+	}
+	utils.SendTextResponse(w, string(jsonData))
+
+}
+
+func GetUSB(w http.ResponseWriter, r *http.Request) {
+	cmdin := `lsusb`
+	cmd := exec.Command("bash", "-c", cmdin)
+	usbd, err := cmd.CombinedOutput()
+	if err != nil {
+		usbd = []byte{}
+	}
+
+	usbDrives := strings.Split(string(usbd), "\n")
+
+	var arr []string
+	for _, info := range usbDrives {
+		arr = append(arr, info)
+	}
+
+	var jsonData []byte
+	jsonData, err = json.Marshal(arr)
+	if err != nil {
+		log.Println(err)
+	}
+	utils.SendTextResponse(w, string(jsonData))
+}
+
+func GetCPUInfo(w http.ResponseWriter, r *http.Request) {
+	cmdin := `cat /proc/cpuinfo | grep -m1 "model name"`
+	cmd := exec.Command("bash", "-c", cmdin)
+	hardware, err := cmd.CombinedOutput()
+	if err != nil {
+		hardware = []byte("??? ")
+	}
+
+	cmdin = `lscpu | grep -m1 "Model name"`
+	cmd = exec.Command("bash", "-c", cmdin)
+	cpuModel, err := cmd.CombinedOutput()
+	if err != nil {
+		cpuModel = []byte("Generic Processor")
+	}
+
+	cmdin = `lscpu | grep "CPU max MHz"`
+	cmd = exec.Command("bash", "-c", cmdin)
+	speed, err := cmd.CombinedOutput()
+	if err != nil {
+		cmdin = `cat /proc/cpuinfo | grep -m1 "cpu MHz"`
+		cmd = exec.Command("bash", "-c", cmdin)
+		intelSpeed, err := cmd.CombinedOutput()
+		if err != nil {
+			speed = []byte("??? ")
+		}
+		speed = intelSpeed
+	}
+
+	cmdin = `cat /proc/cpuinfo | grep -m1 "Hardware"`
+	cmd = exec.Command("bash", "-c", cmdin)
+	cpuhardware, err := cmd.CombinedOutput()
+	if err != nil {
+
+	} else {
+		hardware = cpuhardware
+	}
+
+	//On ARM
+	cmdin = `cat /proc/cpuinfo | grep -m1 "Revision"`
+	cmd = exec.Command("bash", "-c", cmdin)
+	revision, err := cmd.CombinedOutput()
+	if err != nil {
+		//On x64
+		cmdin = `cat /proc/cpuinfo | grep -m1 "family"`
+		cmd = exec.Command("bash", "-c", cmdin)
+		intelrev, err := cmd.CombinedOutput()
+		if err != nil {
+			revision = []byte("??? ")
+		} else {
+			revision = intelrev
+		}
+	}
+
+	//Get Arch
+	cmdin = `uname --m`
+	cmd = exec.Command("bash", "-c", cmdin)
+	arch, err := cmd.CombinedOutput()
+	if err != nil {
+		arch = []byte("??? ")
+	}
+
+	CPUInfo := CPUInfo{
+		Freq:        filterGrepResults(string(speed), ":"),
+		Hardware:    filterGrepResults(string(hardware), ":"),
+		Instruction: filterGrepResults(string(arch), ":"),
+		Model:       filterGrepResults(string(cpuModel), ":"),
+		Revision:    filterGrepResults(string(revision), ":"),
+	}
+
+	var jsonData []byte
+	jsonData, err = json.Marshal(CPUInfo)
+	if err != nil {
+		log.Println(err)
+	}
+	utils.SendTextResponse(w, string(jsonData))
+}
+
+func GetRamInfo(w http.ResponseWriter, r *http.Request) {
+	cmd := exec.Command("grep", "MemTotal", "/proc/meminfo")
+	out, _ := cmd.CombinedOutput()
+	strOut := string(out)
+	strOut = strings.ReplaceAll(strOut, "MemTotal:", "")
+	strOut = strings.ReplaceAll(strOut, "kB", "")
+	strOut = strings.ReplaceAll(strOut, " ", "")
+	strOut = strings.ReplaceAll(strOut, "\n", "")
+	ramSize, _ := strconv.ParseInt(strOut, 10, 64)
+	ramSizeInt := ramSize * 1000
+
+	var jsonData []byte
+	jsonData, err := json.Marshal(ramSizeInt)
+	if err != nil {
+		log.Println(err)
+	}
+	utils.SendTextResponse(w, string(jsonData))
+}

+ 69 - 0
mod/renderer/audio.go

@@ -0,0 +1,69 @@
+package renderer
+
+import (
+	"bytes"
+	"errors"
+	"image"
+	"image/jpeg"
+	"os"
+	"path/filepath"
+
+	"github.com/dhowden/tag"
+	"github.com/nfnt/resize"
+	"github.com/oliamb/cutter"
+)
+
+// Generate thumbnail for audio. Output file will have the same name as the input file with .jpg extension
+func generateThumbnailForAudio(inputFile string, outputFolder string) error {
+	//This extension is supported by id4. Call to library
+	f, err := os.Open(inputFile)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+	m, err := tag.ReadFrom(f)
+	if err != nil {
+		return err
+	}
+
+	if m.Picture() != nil {
+		//Convert the picture bytecode to image object
+		img, _, err := image.Decode(bytes.NewReader(m.Picture().Data))
+		if err != nil {
+			//Fail to convert this image. Continue next one
+			return err
+		}
+
+		//Create an empty file
+		outputFilename := filepath.Join(outputFolder, filepath.Base(inputFile))
+		out, err := os.Create(outputFilename + ".jpg")
+		if err != nil {
+			return err
+		}
+		defer out.Close()
+
+		b := img.Bounds()
+		imgWidth := b.Max.X
+		imgHeight := b.Max.Y
+
+		//Resize the albumn image
+		var m image.Image
+		if imgWidth > imgHeight {
+			m = resize.Resize(0, 480, img, resize.Lanczos3)
+		} else {
+			m = resize.Resize(480, 0, img, resize.Lanczos3)
+		}
+
+		//Crop out the center
+		croppedImg, _ := cutter.Crop(m, cutter.Config{
+			Width:  480,
+			Height: 480,
+			Mode:   cutter.Centered,
+		})
+
+		//Write the cache image to disk
+		jpeg.Encode(out, croppedImg, nil)
+
+	}
+	return errors.New("no image found")
+}

+ 71 - 0
mod/renderer/image.go

@@ -0,0 +1,71 @@
+package renderer
+
+import (
+	"errors"
+	"image"
+	"image/jpeg"
+	"os"
+	"path/filepath"
+
+	"github.com/nfnt/resize"
+	"github.com/oliamb/cutter"
+)
+
+// Generate thumbnail for image. Output file will have the same name as the input file with .jpg extension
+func generateThumbnailForImage(inputFile string, outputFolder string) error {
+
+	var img image.Image
+	var err error
+
+	fileInfo, err := os.Stat(inputFile)
+	if err != nil {
+		return errors.New("failed to get file info: " + err.Error())
+	}
+	if fileInfo.Size() > (25 << 20) {
+		// Maximum image size to be converted is 25MB
+		return errors.New("image file too large")
+	}
+
+	srcImage, err := os.OpenFile(inputFile, os.O_RDONLY, 0775)
+	if err != nil {
+		return err
+	}
+	defer srcImage.Close()
+	img, _, err = image.Decode(srcImage)
+	if err != nil {
+		return err
+	}
+
+	//Resize to desiered width
+	//Check boundary to decide resize mode
+	b := img.Bounds()
+	imgWidth := b.Max.X
+	imgHeight := b.Max.Y
+
+	var m image.Image
+	if imgWidth > imgHeight {
+		m = resize.Resize(0, 480, img, resize.Lanczos3)
+	} else {
+		m = resize.Resize(480, 0, img, resize.Lanczos3)
+	}
+
+	//Crop out the center
+	croppedImg, err := cutter.Crop(m, cutter.Config{
+		Width:  480,
+		Height: 480,
+		Mode:   cutter.Centered,
+	})
+
+	//Create the thumbnail
+	outputFilename := filepath.Join(outputFolder, filepath.Base(inputFile))
+	out, err := os.Create(outputFilename + ".jpg")
+	if err != nil {
+		return err
+	}
+
+	// write new image to file
+	jpeg.Encode(out, croppedImg, nil)
+	out.Close()
+
+	return nil
+}

+ 45 - 0
mod/renderer/model.go

@@ -0,0 +1,45 @@
+package renderer
+
+import (
+	"errors"
+	"image/jpeg"
+	"os"
+	"path/filepath"
+)
+
+// Generate thumbnail for 3D model. Output file will have the same name as the input file with .jpg extension
+func generateThumbnailForModel(inputFile string, outputFolder string) error {
+	if _, err := os.Stat(inputFile); os.IsNotExist(err) {
+		return errors.New("input file does not exist")
+	}
+
+	//Generate a render of the 3d model
+	outputFile := filepath.Join(outputFolder, filepath.Base(inputFile)+".jpg")
+	r := New3DRenderer(RenderOption{
+		Color:           "#f2f542",
+		BackgroundColor: "#ffffff",
+		Width:           480,
+		Height:          480,
+	})
+
+	img, err := r.RenderModel(inputFile)
+	if err != nil {
+		return err
+	}
+
+	opt := jpeg.Options{
+		Quality: 90,
+	}
+
+	f, err := os.Create(outputFile)
+	if err != nil {
+		return err
+	}
+
+	err = jpeg.Encode(f, img, &opt)
+	if err != nil {
+		return err
+	}
+	f.Close()
+	return nil
+}

+ 69 - 0
mod/renderer/psd.go

@@ -0,0 +1,69 @@
+package renderer
+
+import (
+	"errors"
+	"image"
+	"image/jpeg"
+	"os"
+	"path/filepath"
+
+	"github.com/nfnt/resize"
+	"github.com/oliamb/cutter"
+	_ "github.com/oov/psd"
+)
+
+func generateThumbnailForPSD(inputFile string, outputFolder string) error {
+	if _, err := os.Stat(inputFile); os.IsNotExist(err) {
+		return errors.New("Input file does not exist")
+	}
+
+	outputFile := filepath.Join(outputFolder, filepath.Base(inputFile)+".jpg")
+
+	f, err := os.Open(outputFile)
+	if err != nil {
+		return err
+	}
+
+	//Decode the image content with PSD decoder
+	img, _, err := image.Decode(f)
+	if err != nil {
+		return err
+	}
+
+	f.Close()
+
+	//Check boundary to decide resize mode
+	b := img.Bounds()
+	imgWidth := b.Max.X
+	imgHeight := b.Max.Y
+
+	var m image.Image
+	if imgWidth > imgHeight {
+		m = resize.Resize(0, 480, img, resize.Lanczos3)
+	} else {
+		m = resize.Resize(480, 0, img, resize.Lanczos3)
+	}
+
+	//Crop out the center
+	croppedImg, err := cutter.Crop(m, cutter.Config{
+		Width:  480,
+		Height: 480,
+		Mode:   cutter.Centered,
+	})
+
+	outf, err := os.Create(outputFile)
+	if err != nil {
+		return err
+	}
+	opt := jpeg.Options{
+		Quality: 90,
+	}
+	err = jpeg.Encode(outf, croppedImg, &opt)
+	if err != nil {
+		return err
+	}
+	outf.Close()
+
+	return nil
+
+}

+ 105 - 0
mod/renderer/render3d.go

@@ -0,0 +1,105 @@
+package renderer
+
+import (
+	. "github.com/fogleman/fauxgl"
+	"github.com/nfnt/resize"
+
+	"errors"
+	"image"
+	"log"
+	"os"
+	"path/filepath"
+	"strings"
+)
+
+const (
+	scale  = 1    // optional supersampling
+	width  = 1000 // output width in pixels
+	height = 1000 // output height in pixels
+	fovy   = 10   // vertical field of view in degrees
+	near   = 1    // near clipping plane
+	far    = 40   // far clipping plane
+)
+
+var (
+	eye        = V(-6, -6, 5)             // camera position
+	center     = V(0, -0.07, 0)           // view center position
+	up         = V(0, 0, 1)               // up vector
+	light      = V(-1, -2, 5).Normalize() // light direction
+	color      = ("#42f5b3")              // object color
+	background = HexColor("#e0e0e0")      //Background color
+)
+
+type RenderOption struct {
+	Color           string
+	BackgroundColor string
+	Width           int
+	Height          int
+}
+
+type Renderer struct {
+	Option RenderOption
+}
+
+func New3DRenderer(option RenderOption) *Renderer {
+	return &Renderer{
+		Option: option,
+	}
+}
+
+func (r *Renderer) RenderModel(filename string) (image.Image, error) {
+	// load a mesh
+	var mesh *Mesh
+	if strings.ToLower(filepath.Ext(filename)) == ".stl" {
+		m, err := LoadSTL(filename)
+		if err != nil {
+			return nil, err
+		}
+		mesh = m
+	} else if strings.ToLower(filepath.Ext(filename)) == ".obj" {
+		m, err := LoadOBJ(filename)
+		if err != nil {
+			return nil, err
+		}
+		mesh = m
+	} else {
+		log.Println("Not supported format, given: " + filepath.Ext(filename))
+		return nil, errors.New("Not supported model format")
+	}
+
+	// fit mesh in a bi-unit cube centered at the origin
+	mesh.UnitCube()
+	//log.Println(mesh.BoundingBox(), filename)
+	// smooth the normals
+	mesh.SmoothNormalsThreshold(Radians(30))
+
+	// create a rendering context
+	context := NewContext(r.Option.Width*scale, r.Option.Height*scale)
+	context.ClearColorBufferWith(HexColor(r.Option.BackgroundColor))
+
+	// create transformation matrix and light direction
+	aspect := float64(width) / float64(height)
+	matrix := LookAt(eye, center, up).Perspective(fovy, aspect, near, far)
+
+	// use builtin phong shader
+	shader := NewPhongShader(matrix, light, eye)
+	shader.ObjectColor = HexColor(r.Option.Color)
+	context.Shader = shader
+
+	// render
+	context.DrawMesh(mesh)
+
+	// downsample image for antialiasing
+	image := context.Image()
+	image = resize.Resize(width, height, image, resize.Bilinear)
+
+	return image, nil
+}
+
+func fileExists(filename string) bool {
+	info, err := os.Stat(filename)
+	if os.IsNotExist(err) {
+		return false
+	}
+	return !info.IsDir()
+}

+ 132 - 0
mod/renderer/renderer.go

@@ -0,0 +1,132 @@
+package renderer
+
+import (
+	"encoding/base64"
+	"errors"
+	"log"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync"
+)
+
+/*
+	This package is used to extract meta data from files like mp3 and mp4
+	Also support image caching
+
+*/
+
+type RenderHandler struct {
+	renderingFiles  sync.Map
+	renderingFolder sync.Map
+}
+
+// Create a new RenderHandler
+func NewRenderHandler() *RenderHandler {
+	return &RenderHandler{
+		renderingFiles:  sync.Map{},
+		renderingFolder: sync.Map{},
+	}
+}
+
+// RenderThumbnail generates a thumbnail for the given input file and saves it to the output folder
+func (rh *RenderHandler) RenderThumbnail(inputFile string, outputFolder string) error {
+	if rh.fileIsBusy(inputFile) {
+		return errors.New("file is rendering")
+	}
+
+	// Check if the cache file exists and is newer than the input file
+	cacheFile := filepath.Join(outputFolder, filepath.Base(inputFile)+".jpg")
+	cacheInfo, err := os.Stat(cacheFile)
+	if err == nil {
+		inputInfo, err := os.Stat(inputFile)
+		if err != nil {
+			// File not found, return error
+			return err
+		}
+		if cacheInfo.ModTime().After(inputInfo.ModTime()) {
+			// Cache file is newer, return the base64 encoded image
+			return nil
+		}
+	}
+
+	//Cache image not exists. Set this file to busy
+	rh.renderingFiles.Store(inputFile, "busy")
+	inputFileExt := strings.ToLower(filepath.Ext(inputFile))
+	//That object not exists. Generate cache image
+	//Audio formats that might contains id4 thumbnail
+	id4Formats := []string{".mp3", ".ogg", ".flac"}
+	if stringInSlice(inputFileExt, id4Formats) {
+		err := generateThumbnailForAudio(inputFile, outputFolder)
+		rh.renderingFiles.Delete(inputFileExt)
+		return err
+	}
+
+	//Generate resized image for images
+	imageFormats := []string{".png", ".jpeg", ".jpg"}
+	if stringInSlice(inputFileExt, imageFormats) {
+		err := generateThumbnailForImage(inputFile, outputFolder)
+		rh.renderingFiles.Delete(inputFileExt)
+		return err
+	}
+
+	//Video formats, extract from the 5 sec mark
+	vidFormats := []string{".mkv", ".mp4", ".webm", ".ogv", ".avi", ".rmvb"}
+	if stringInSlice(inputFileExt, vidFormats) {
+		err := generateThumbnailForVideo(inputFile, outputFolder)
+		rh.renderingFiles.Delete(inputFileExt)
+		return err
+	}
+
+	//3D Model Formats
+	modelFormats := []string{".stl", ".obj"}
+	if stringInSlice(inputFileExt, modelFormats) {
+		err := generateThumbnailForModel(inputFile, outputFolder)
+		rh.renderingFiles.Delete(inputFileExt)
+		return err
+	}
+
+	//Photoshop file
+	if inputFileExt == ".psd" {
+		err := generateThumbnailForPSD(inputFile, outputFolder)
+		rh.renderingFiles.Delete(inputFileExt)
+		return err
+	}
+
+	//Other filters
+	rh.renderingFiles.Delete(inputFileExt)
+	return errors.New("No supported format")
+}
+
+func stringInSlice(path string, slice []string) bool {
+	for _, item := range slice {
+		if item == path {
+			return true
+		}
+	}
+	return false
+}
+
+func (rh *RenderHandler) fileIsBusy(path string) bool {
+	if rh == nil {
+		log.Println("RenderHandler is null!")
+		return true
+	}
+	_, ok := rh.renderingFiles.Load(path)
+	if !ok {
+		//File path is not being process by another process
+		return false
+	} else {
+		return true
+	}
+}
+
+// Get the image as base64 string
+func getImageAsBase64(inputFile string) (string, error) {
+	content, err := os.ReadFile(inputFile)
+	if err != nil {
+		return "", err
+	}
+	encoded := base64.StdEncoding.EncodeToString(content)
+	return string(encoded), nil
+}

+ 72 - 0
mod/renderer/video.go

@@ -0,0 +1,72 @@
+package renderer
+
+import (
+	"bytes"
+	"errors"
+	"image"
+	"image/jpeg"
+	"os"
+	"os/exec"
+	"path/filepath"
+
+	"github.com/oliamb/cutter"
+)
+
+func generateThumbnailForVideo(inputFile string, outputFolder string) error {
+	if _, err := os.Stat(inputFile); os.IsNotExist(err) {
+		// The user removed this file before the thumbnail is finished
+		return errors.New("source not exists")
+	}
+
+	outputFile := filepath.Join(outputFolder, filepath.Base(inputFile)+".jpg")
+
+	absInputFile, err := filepath.Abs(inputFile)
+	if err != nil {
+		return errors.New("failed to get absolute path of input file")
+	}
+
+	absOutputFile, err := filepath.Abs(outputFile)
+	if err != nil {
+		return errors.New("failed to get absolute path of output file")
+	}
+
+	//Get the first thumbnail using ffmpeg
+	cmd := exec.Command("ffmpeg", "-i", absInputFile, "-ss", "00:00:05.000", "-vframes", "1", "-vf", "scale=-1:480", absOutputFile)
+	_, err = cmd.CombinedOutput()
+	if err != nil {
+		return err
+	}
+
+	//Resize and crop the output image
+	if _, err := os.Stat(outputFile); err == nil {
+		imageBytes, err := os.ReadFile(outputFile)
+		if err != nil {
+			return err
+		}
+		os.Remove(outputFile)
+		img, _, err := image.Decode(bytes.NewReader(imageBytes))
+		if err != nil {
+			return err
+		} else {
+			//Crop out the center
+			croppedImg, err := cutter.Crop(img, cutter.Config{
+				Width:  480,
+				Height: 480,
+				Mode:   cutter.Centered,
+			})
+
+			if err == nil {
+				//Write it back to the original file
+				out, _ := os.Create(outputFile)
+				jpeg.Encode(out, croppedImg, nil)
+				out.Close()
+
+			} else {
+				//log.Println(err)
+			}
+		}
+
+	}
+	return nil
+
+}

+ 116 - 0
mod/transcoder/transcoder.go

@@ -0,0 +1,116 @@
+package transcoder
+
+/*
+	Transcoder.go
+
+	This module handle real-time transcoding of media files
+	that is not supported by playing on web.
+*/
+
+import (
+	"io"
+	"log"
+	"net/http"
+	"os/exec"
+	"time"
+)
+
+type TranscodeOutputResolution string
+
+const (
+	TranscodeResolution_360p     TranscodeOutputResolution = "360p"
+	TranscodeResolution_720p     TranscodeOutputResolution = "720p"
+	TranscodeResolution_1080p    TranscodeOutputResolution = "1280p"
+	TranscodeResolution_original TranscodeOutputResolution = ""
+)
+
+// Transcode and stream the given file. Make sure ffmpeg is installed before calling to transcoder.
+func TranscodeAndStream(w http.ResponseWriter, r *http.Request, inputFile string, resolution TranscodeOutputResolution) {
+	// Build the FFmpeg command based on the resolution parameter
+	var cmd *exec.Cmd
+
+	transcodeFormatArgs := []string{"-f", "mp4", "-vcodec", "libx264", "-preset", "superfast", "-g", "60", "-movflags", "frag_keyframe+empty_moov+faststart", "pipe:1"}
+	var args []string
+	switch resolution {
+	case "360p":
+		args = append([]string{"-i", inputFile, "-vf", "scale=-1:360"}, transcodeFormatArgs...)
+	case "720p":
+		args = append([]string{"-i", inputFile, "-vf", "scale=-1:720"}, transcodeFormatArgs...)
+	case "1080p":
+		args = append([]string{"-i", inputFile, "-vf", "scale=-1:1080"}, transcodeFormatArgs...)
+	case "":
+		// Original resolution
+		args = append([]string{"-i", inputFile}, transcodeFormatArgs...)
+	default:
+		http.Error(w, "Invalid resolution parameter", http.StatusBadRequest)
+		return
+	}
+	cmd = exec.Command("ffmpeg", args...)
+
+	// Set response headers for streaming MP4 video
+	w.Header().Set("Content-Type", "video/mp4")
+	w.Header().Set("Transfer-Encoding", "chunked")
+	w.Header().Set("Cache-Control", "public, max-age=3600, s-maxage=3600, must-revalidate")
+	w.Header().Set("Accept-Ranges", "bytes")
+
+	// Get the command output pipe
+	stdout, err := cmd.StdoutPipe()
+	if err != nil {
+		http.Error(w, "Failed to create output pipe", http.StatusInternalServerError)
+		return
+	}
+
+	// Get the command error pipe to capture standard error
+	stderr, err := cmd.StderrPipe()
+	if err != nil {
+		http.Error(w, "Failed to create error pipe", http.StatusInternalServerError)
+		log.Printf("Failed to create error pipe: %v", err)
+		return
+	}
+
+	// Start the command
+	if err := cmd.Start(); err != nil {
+		http.Error(w, "Failed to start FFmpeg", http.StatusInternalServerError)
+		return
+	}
+
+	// Create a channel to signal when the client disconnects
+	done := make(chan struct{})
+
+	// Monitor client connection close
+	go func() {
+		<-r.Context().Done()
+		time.Sleep(300 * time.Millisecond)
+		cmd.Process.Kill() // Kill the FFmpeg process when client disconnects
+		done <- struct{}{}
+		//close(done)
+	}()
+
+	// Copy the command output to the HTTP response in a separate goroutine
+	go func() {
+		if _, err := io.Copy(w, stdout); err != nil {
+			// End of video or client disconnected
+			cmd.Process.Kill()
+			return
+		}
+	}()
+
+	// Read and log the command standard error
+	go func() {
+		errOutput, _ := io.ReadAll(stderr)
+		if len(errOutput) > 0 {
+			log.Printf("FFmpeg error output: %s", string(errOutput))
+		}
+	}()
+
+	go func() {
+		if err := cmd.Wait(); err != nil {
+			log.Printf("FFmpeg process exited: %v", err)
+			return
+		}
+	}()
+
+	// Wait for the command to finish or client disconnect
+	<-done
+	log.Println("[Media Server] Transcode client disconnected")
+}

+ 105 - 0
mod/utils/conv.go

@@ -0,0 +1,105 @@
+package utils
+
+import (
+	"archive/zip"
+	"io"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+)
+
+func StringToInt64(number string) (int64, error) {
+	i, err := strconv.ParseInt(number, 10, 64)
+	if err != nil {
+		return -1, err
+	}
+	return i, nil
+}
+
+func Int64ToString(number int64) string {
+	convedNumber := strconv.FormatInt(number, 10)
+	return convedNumber
+}
+
+func ReplaceSpecialCharacters(filename string) string {
+	replacements := map[string]string{
+		"#":  "%pound%",
+		"&":  "%amp%",
+		"{":  "%left_cur%",
+		"}":  "%right_cur%",
+		"\\": "%backslash%",
+		"<":  "%left_ang%",
+		">":  "%right_ang%",
+		"*":  "%aster%",
+		"?":  "%quest%",
+		" ":  "%space%",
+		"$":  "%dollar%",
+		"!":  "%exclan%",
+		"'":  "%sin_q%",
+		"\"": "%dou_q%",
+		":":  "%colon%",
+		"@":  "%at%",
+		"+":  "%plus%",
+		"`":  "%backtick%",
+		"|":  "%pipe%",
+		"=":  "%equal%",
+		".":  "_",
+		"/":  "-",
+	}
+
+	for char, replacement := range replacements {
+		filename = strings.ReplaceAll(filename, char, replacement)
+	}
+
+	return filename
+}
+
+/* Zip File Handler */
+// zipFiles compresses multiple files into a single zip archive file
+func ZipFiles(filename string, files ...string) error {
+	newZipFile, err := os.Create(filename)
+	if err != nil {
+		return err
+	}
+	defer newZipFile.Close()
+
+	zipWriter := zip.NewWriter(newZipFile)
+	defer zipWriter.Close()
+
+	for _, file := range files {
+		if err := addFileToZip(zipWriter, file); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// addFileToZip adds an individual file to a zip archive
+func addFileToZip(zipWriter *zip.Writer, filename string) error {
+	fileToZip, err := os.Open(filename)
+	if err != nil {
+		return err
+	}
+	defer fileToZip.Close()
+
+	info, err := fileToZip.Stat()
+	if err != nil {
+		return err
+	}
+
+	header, err := zip.FileInfoHeader(info)
+	if err != nil {
+		return err
+	}
+
+	header.Name = filepath.Base(filename)
+	header.Method = zip.Deflate
+
+	writer, err := zipWriter.CreateHeader(header)
+	if err != nil {
+		return err
+	}
+	_, err = io.Copy(writer, fileToZip)
+	return err
+}

+ 19 - 0
mod/utils/template.go

@@ -0,0 +1,19 @@
+package utils
+
+import (
+	"net/http"
+)
+
+/*
+	Web Template Generator
+
+	This is the main system core module that perform function similar to what PHP did.
+	To replace part of the content of any file, use {{paramter}} to replace it.
+
+
+*/
+
+func SendHTMLResponse(w http.ResponseWriter, msg string) {
+	w.Header().Set("Content-Type", "text/html")
+	w.Write([]byte(msg))
+}

+ 202 - 0
mod/utils/utils.go

@@ -0,0 +1,202 @@
+package utils
+
+import (
+	"errors"
+	"log"
+	"net"
+	"net/http"
+	"os"
+	"strconv"
+	"strings"
+	"time"
+)
+
+/*
+	Common
+
+	Some commonly used functions in ArozOS
+
+*/
+
+// Response related
+func SendTextResponse(w http.ResponseWriter, msg string) {
+	w.Write([]byte(msg))
+}
+
+// Send JSON response, with an extra json header
+func SendJSONResponse(w http.ResponseWriter, json string) {
+	w.Header().Set("Content-Type", "application/json")
+	w.Write([]byte(json))
+}
+
+func SendErrorResponse(w http.ResponseWriter, errMsg string) {
+	w.Header().Set("Content-Type", "application/json")
+	w.Write([]byte("{\"error\":\"" + errMsg + "\"}"))
+}
+
+func SendOK(w http.ResponseWriter) {
+	w.Header().Set("Content-Type", "application/json")
+	w.Write([]byte("\"OK\""))
+}
+
+// Get GET parameter
+func GetPara(r *http.Request, key string) (string, error) {
+	// Get first value from the URL query
+	value := r.URL.Query().Get(key)
+	if len(value) == 0 {
+		return "", errors.New("invalid " + key + " given")
+	}
+	return value, nil
+}
+
+// Get GET paramter as boolean, accept 1 or true
+func GetBool(r *http.Request, key string) (bool, error) {
+	x, err := GetPara(r, key)
+	if err != nil {
+		return false, err
+	}
+
+	// Convert to lowercase and trim spaces just once to compare
+	switch strings.ToLower(strings.TrimSpace(x)) {
+	case "1", "true", "on":
+		return true, nil
+	case "0", "false", "off":
+		return false, nil
+	}
+
+	return false, errors.New("invalid boolean given")
+}
+
+// Get POST parameter
+func PostPara(r *http.Request, key string) (string, error) {
+	// Try to parse the form
+	if err := r.ParseForm(); err != nil {
+		return "", err
+	}
+	// Get first value from the form
+	x := r.Form.Get(key)
+	if len(x) == 0 {
+		return "", errors.New("invalid " + key + " given")
+	}
+	return x, nil
+}
+
+// Get POST paramter as boolean, accept 1 or true
+func PostBool(r *http.Request, key string) (bool, error) {
+	x, err := PostPara(r, key)
+	if err != nil {
+		return false, err
+	}
+
+	// Convert to lowercase and trim spaces just once to compare
+	switch strings.ToLower(strings.TrimSpace(x)) {
+	case "1", "true", "on":
+		return true, nil
+	case "0", "false", "off":
+		return false, nil
+	}
+
+	return false, errors.New("invalid boolean given")
+}
+
+// Get POST paramter as int
+func PostInt(r *http.Request, key string) (int, error) {
+	x, err := PostPara(r, key)
+	if err != nil {
+		return 0, err
+	}
+
+	x = strings.TrimSpace(x)
+	rx, err := strconv.Atoi(x)
+	if err != nil {
+		return 0, err
+	}
+
+	return rx, nil
+}
+
+func FileExists(filename string) bool {
+	_, err := os.Stat(filename)
+	if err == nil {
+		// File exists
+		return true
+	} else if errors.Is(err, os.ErrNotExist) {
+		// File does not exist
+		return false
+	}
+	// Some other error
+	return false
+}
+
+func IsDir(path string) bool {
+	if !FileExists(path) {
+		return false
+	}
+	fi, err := os.Stat(path)
+	if err != nil {
+		log.Fatal(err)
+		return false
+	}
+	switch mode := fi.Mode(); {
+	case mode.IsDir():
+		return true
+	case mode.IsRegular():
+		return false
+	}
+	return false
+}
+
+func TimeToString(targetTime time.Time) string {
+	return targetTime.Format("2006-01-02 15:04:05")
+}
+
+// Check if given string in a given slice
+func StringInArray(arr []string, str string) bool {
+	for _, a := range arr {
+		if a == str {
+			return true
+		}
+	}
+	return false
+}
+
+func StringInArrayIgnoreCase(arr []string, str string) bool {
+	smallArray := []string{}
+	for _, item := range arr {
+		smallArray = append(smallArray, strings.ToLower(item))
+	}
+
+	return StringInArray(smallArray, strings.ToLower(str))
+}
+
+// Validate if the listening address is correct
+func ValidateListeningAddress(address string) bool {
+	// Check if the address starts with a colon, indicating it's just a port
+	if strings.HasPrefix(address, ":") {
+		return true
+	}
+
+	// Split the address into host and port parts
+	host, port, err := net.SplitHostPort(address)
+	if err != nil {
+		// Try to parse it as just a port
+		if _, err := strconv.Atoi(address); err == nil {
+			return false // It's just a port number
+		}
+		return false // It's an invalid address
+	}
+
+	// Check if the port part is a valid number
+	if _, err := strconv.Atoi(port); err != nil {
+		return false
+	}
+
+	// Check if the host part is a valid IP address or empty (indicating any IP)
+	if host != "" {
+		if net.ParseIP(host) == nil {
+			return false
+		}
+	}
+
+	return true
+}

BIN
test/104673594_p19_master1200.jpg


BIN
test/12668203_169165166787316_1525118736_n.mp4


BIN
test/subfolder/144385-1776325172-1girl, neuro-sama, (yuzu modoki_1.22), on head, animal on head, cowboy shot, blue eyes, aqua bow, yellow cardigan, open cardigan.png


BIN
test/subfolder/1aaqk8tppa3e1.jpeg


BIN
test/subfolder/1wmix0w9c61e1.jpeg


BIN
test/subfolder/bjuqre6c3f6e1.jpeg


BIN
test/subfolder/my-new-mini-lab-2-0-remember-waf-v0-ujzzdx5vvp0e1.webp


BIN
test/subfolder/wkuq1a6svy9e1.png


BIN
test/will be fine.mp3


BIN
tmp/test/104673594_p19_master1200.jpg.jpg


BIN
tmp/test/12668203_169165166787316_1525118736_n.mp4.jpg


BIN
tmp/test/subfolder/144385-1776325172-1girl, neuro-sama, (yuzu modoki_1.22), on head, animal on head, cowboy shot, blue eyes, aqua bow, yellow cardigan, open cardigan.png.jpg


BIN
tmp/test/subfolder/1aaqk8tppa3e1.jpeg.jpg


BIN
tmp/test/subfolder/1wmix0w9c61e1.jpeg.jpg


BIN
tmp/test/subfolder/bjuqre6c3f6e1.jpeg.jpg


BIN
tmp/test/subfolder/wkuq1a6svy9e1.png.jpg


BIN
tmp/test/will be fine.mp3.jpg