package main import ( "bytes" "crypto/sha1" "database/sql" "encoding/hex" "errors" "fmt" "html/template" "io" "io/fs" "log" "net" "net/http" "os" "os/exec" "path/filepath" "regexp" "strings" "time" _ "github.com/mattn/go-sqlite3" ) var ( db *sql.DB // sqlite database gd string // gallery directory ) func openDB(directory string) error { var err error db, err = sql.Open("sqlite3", "file:"+filepath.Join(directory, "gallery.db?_foreign_keys=1")) gd = directory return err } func imagePath(sha1 string) string { return filepath.Join(gd, "images", sha1[:2], sha1) } func thumbPath(sha1 string) string { return filepath.Join(gd, "thumbs", sha1[:2], sha1+".webp") } func dbCollectStrings(query string) ([]string, error) { rows, err := db.Query(query) if err != nil { return nil, err } defer rows.Close() var result []string for rows.Next() { var sha1 string if err := rows.Scan(&sha1); err != nil { return nil, err } result = append(result, sha1) } if err := rows.Err(); err != nil { return nil, err } return result, nil } type directoryManager struct { cache map[string]int64 // Unix-style paths to directory.id } func (dm *directoryManager) IDForDirectoryPath(path string) (int64, error) { // Relative paths could be handled differently, // but right now, they're assumed to start at the root. list := strings.Split(filepath.ToSlash(filepath.Clean(path)), "/") if len(list) > 1 && list[0] == "" { list = list[1:] } if len(list) == 0 { return 0, nil } var parent sql.NullInt64 for _, name := range list { if err := db.QueryRow( `SELECT id FROM directory WHERE name = ? AND parent IS ?`, name, parent).Scan(&parent); err == nil { continue } else if !errors.Is(err, sql.ErrNoRows) { return 0, err } if result, err := db.Exec( `INSERT INTO directory(name, parent) VALUES (?, ?)`, name, parent); err != nil { return 0, err } else if id, err := result.LastInsertId(); err != nil { return 0, err } else { parent = sql.NullInt64{Int64: id, Valid: true} } } return parent.Int64, nil } // cmdInit initializes a "gallery directory" that contains gallery.sqlite, // images, thumbs. func cmdInit(args []string) error { if len(args) != 1 { return errors.New("usage: GD") } if err := openDB(args[0]); err != nil { return err } if _, err := db.Exec(initializeSQL); err != nil { return err } // XXX: There's technically no reason to keep images as symlinks, // we might just keep absolute paths in the database as well. if err := os.MkdirAll(filepath.Join(gd, "images"), 0755); err != nil { return err } if err := os.MkdirAll(filepath.Join(gd, "thumbs"), 0755); err != nil { return err } return nil } var hashRE = regexp.MustCompile(`^/.*?/([0-9a-f]{40})$`) var staticHandler http.Handler var page = template.Must(template.New("/").Parse(` Gallery {{ . }} `)) func handleRequest(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { staticHandler.ServeHTTP(w, r) return } // TODO: Include the most elementary contents first. if err := page.Execute(w, "Hello world"); err != nil { http.Error(w, err.Error(), 500) } } func handleImages(w http.ResponseWriter, r *http.Request) { if m := hashRE.FindStringSubmatch(r.URL.Path); m == nil { http.NotFound(w, r) } else { http.ServeFile(w, r, imagePath(m[1])) } } func handleThumbs(w http.ResponseWriter, r *http.Request) { if m := hashRE.FindStringSubmatch(r.URL.Path); m == nil { http.NotFound(w, r) } else { http.ServeFile(w, r, thumbPath(m[1])) } } // cmdRun runs a web UI against GD on ADDRESS. func cmdRun(args []string) error { if len(args) != 2 { return errors.New("usage: GD ADDRESS") } if err := openDB(args[0]); err != nil { return err } address := args[1] // This separation is not strictly necessary, // but having an elementary level of security doesn't hurt either. staticHandler = http.FileServer(http.Dir("public")) http.HandleFunc("/", handleRequest) http.HandleFunc("/images/", handleImages) http.HandleFunc("/thumbs/", handleThumbs) // TODO: Add a few API endpoints. host, port, err := net.SplitHostPort(address) if err != nil { log.Println(err) } else if host == "" { log.Println("http://" + net.JoinHostPort("localhost", port)) } else { log.Println("http://" + address) } s := &http.Server{ Addr: address, ReadTimeout: 60 * time.Second, WriteTimeout: 60 * time.Second, MaxHeaderBytes: 32 << 10, } return s.ListenAndServe() } func isImage(path string) (bool, error) { cmd := exec.Command("xdg-mime", "query", "filetype", path) stdout, err := cmd.StdoutPipe() if err != nil { return false, err } if err := cmd.Start(); err != nil { return false, err } out, err := io.ReadAll(stdout) if err != nil { return false, err } if err := cmd.Wait(); err != nil { return false, err } return bytes.HasPrefix(out, []byte("image/")), nil } func importFunc(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return err } // The input may be a relative path, and we want to remember it as such, // but symlinks for the images must be absolute. absPath, err := filepath.Abs(path) if err != nil { return err } // Skip videos, which ImageMagick can process, but we don't want it to, // so that they're not converted 1:1 to WebP. pathIsImage, err := isImage(path) if err != nil { return err } if !pathIsImage { return nil } f, err := os.Open(path) if err != nil { return err } defer f.Close() s, err := f.Stat() if err != nil { return err } hash := sha1.New() _, err = io.CopyBuffer(hash, f, make([]byte, 65536)) if err != nil { return err } hexSHA1 := hex.EncodeToString(hash.Sum(nil)) pathImage := imagePath(hexSHA1) imageDirname, _ := filepath.Split(pathImage) if err := os.MkdirAll(imageDirname, 0755); err != nil { return err } if err := os.Symlink(absPath, pathImage); err != nil && !errors.Is(err, fs.ErrExist) { return err } // TODO: This should all run in a transaction. if _, err = db.Exec(`INSERT INTO image(sha1) VALUES (?) ON CONFLICT(sha1) DO NOTHING`, hexSHA1); err != nil { return err } // TODO: Maintain the cache across calls. dm := directoryManager{} dbDirname, dbBasename := filepath.Split(path) dbParent, err := dm.IDForDirectoryPath(dbDirname) if err != nil { return err } _, err = db.Exec(`INSERT INTO entry( parent, name, mtime, sha1 ) VALUES (?, ?, ?, ?)`, dbParent, dbBasename, s.ModTime().Unix(), hexSHA1) return err } // cmdImport adds files to the "entry" table. func cmdImport(args []string) error { if len(args) < 1 { return errors.New("usage: GD ROOT...") } if err := openDB(args[0]); err != nil { return err } // TODO: This would better be done in parallel (making hashes). // TODO: Show progress in some manner. Perhaps port my propeller code. for _, name := range args[1:] { if err := filepath.WalkDir(name, importFunc); err != nil { return err } } return nil } // cmdSync is like import, but clears the "entry" table beforehands. func cmdSync(args []string) error { if len(args) < 1 { return errors.New("usage: GD ROOT...") } if err := openDB(args[0]); err != nil { return err } // TODO return nil } // cmdCheck checks if all files tracked in the DB are accessible. func cmdCheck(args []string) error { if len(args) != 1 { return errors.New("usage: GD") } if err := openDB(args[0]); err != nil { return err } // TODO: Check if all hashes of DB entries have a statable image file, // and that all images with thumb{w,h} have a thumbnail file. Perhaps. return nil } func makeThumbnail(pathImage, pathThumb string) (int, int, error) { thumbDirname, _ := filepath.Split(pathThumb) if err := os.MkdirAll(thumbDirname, 0755); err != nil { return 0, 0, err } // Create a normalized thumbnail. Since we don't particularly need // any complex processing, such as surrounding of metadata, // simply push it through ImageMagick. // // - http://www.ericbrasseur.org/gamma.html // - https://www.imagemagick.org/Usage/thumbnails/ // - https://imagemagick.org/script/command-line-options.php#layers // // TODO: See if we can optimize resulting WebP animations. // (Do -layers optimize* apply to this format at all?) cmd := exec.Command("convert", pathImage, "-coalesce", "-colorspace", "RGB", "-auto-orient", "-strip", "-resize", "256x128>", "-colorspace", "sRGB", "-format", "%w %h", "+write", "info:", pathThumb) stdout, err := cmd.StdoutPipe() if err != nil { return 0, 0, err } if err := cmd.Start(); err != nil { return 0, 0, err } out, err := io.ReadAll(stdout) if err != nil { return 0, 0, err } if err := cmd.Wait(); err != nil { return 0, 0, err } var w, h int _, err = fmt.Fscanf(bytes.NewReader(out), "%d %d", &w, &h) return w, h, err } // cmdThumbnail generates missing thumbnails, in parallel. func cmdThumbnail(args []string) error { if len(args) < 1 { return errors.New("usage: GD [SHA1...]") } if err := openDB(args[0]); err != nil { return err } hexSHA1 := args[1:] if len(hexSHA1) == 0 { // Get all unique images in the database with no thumbnail. var err error hexSHA1, err = dbCollectStrings(`SELECT sha1 FROM image WHERE thumbw IS NULL OR thumbh IS NULL`) if err != nil { return err } } // TODO: Try to run the thumbnailer in parallel, somehow. // Then run convert with `-limit thread 1`. // TODO: Show progress in some manner. Perhaps port my propeller code. for _, sha1 := range hexSHA1 { pathImage := imagePath(sha1) pathThumb := thumbPath(sha1) w, h, err := makeThumbnail(pathImage, pathThumb) if err != nil { return err } _, err = db.Exec(`UPDATE image SET thumbw = ?, thumbh = ? WHERE sha1 = ?`, w, h, sha1) if err != nil { return err } } return nil } func makeDhash(hasher, pathThumb string) (uint64, error) { cmd := exec.Command(hasher, pathThumb) stdout, err := cmd.StdoutPipe() if err != nil { return 0, err } if err := cmd.Start(); err != nil { return 0, err } out, err := io.ReadAll(stdout) if err != nil { return 0, err } if err := cmd.Wait(); err != nil { return 0, err } var hash uint64 _, err = fmt.Fscanf(bytes.NewReader(out), "%x", &hash) return hash, err } // cmdDhash generates perceptual hash from thumbnails. func cmdDhash(args []string) error { if len(args) < 1 { return errors.New("usage: GD HASHER [SHA1...]") } if err := openDB(args[0]); err != nil { return err } hasher, hexSHA1 := args[1], args[2:] if len(hexSHA1) == 0 { var err error hexSHA1, err = dbCollectStrings(`SELECT sha1 FROM image WHERE dhash IS NULL`) if err != nil { return err } } // TODO: Try to run the hasher in parallel, somehow. // TODO: Show progress in some manner. Perhaps port my propeller code. for _, sha1 := range hexSHA1 { pathThumb := thumbPath(sha1) hash, err := makeDhash(hasher, pathThumb) if err != nil { return err } _, err = db.Exec(`UPDATE image SET dhash = ? WHERE sha1 = ?`, int64(hash), sha1) if err != nil { return err } } return nil } var commands = map[string]struct { handler func(args []string) error }{ "init": {cmdInit}, "run": {cmdRun}, "import": {cmdImport}, "sync": {cmdSync}, "check": {cmdCheck}, "thumbnail": {cmdThumbnail}, "dhash": {cmdDhash}, } func main() { if len(os.Args) <= 2 { log.Fatalln("Missing arguments") } cmd, ok := commands[os.Args[1]] if !ok { log.Fatalln("Unknown command: " + os.Args[1]) } err := cmd.handler(os.Args[2:]) // Note that the database object has a closing finalizer, // we just additionally print any errors coming from there. if db != nil { if err := db.Close(); err != nil { log.Println(err) } } if err != nil { log.Fatalln(err) } }