Files
autoindex/fs.go
2022-06-12 14:08:48 +02:00

498 lines
10 KiB
Go

// Author: Niels A.D.
// Project: autoindex (https://github.com/nielsAD/autoindex)
// License: Mozilla Public License, v2.0
package main
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
"sync/atomic"
"time"
_ "github.com/mattn/go-sqlite3"
"github.com/nielsAD/autoindex/walk"
)
// CachedFS struct
type CachedFS struct {
ql *sql.Stmt
qd *sql.Stmt
qs *sql.Stmt
db *sql.DB
dbr int32
dbp string
Root string
Cached bool
Timeout time.Duration
}
// New CachedFS
func New(dbp string, root string) (*CachedFS, error) {
r, err := filepath.Abs(root)
if err != nil {
return nil, err
}
db, err := sql.Open("sqlite3", dbp)
if err != nil {
return nil, err
}
if _, err := db.Exec(`
CREATE TABLE IF NOT EXISTS dirs (path TEXT);
CREATE TABLE IF NOT EXISTS files (root INTEGER, name TEXT, dir BOOLEAN)
`); err != nil {
db.Close()
return nil, err
}
ql, err := db.Prepare("SELECT dirs.path FROM dirs LIMIT 50000")
if err != nil {
db.Close()
return nil, err
}
qd, err := db.Prepare("SELECT dirs.rowid FROM dirs WHERE path GLOB ? LIMIT 1")
if err != nil {
db.Close()
return nil, err
}
qs, err := db.Prepare("SELECT dirs.path, files.name, files.dir FROM files LEFT JOIN dirs ON files.root = dirs.rowid WHERE files.root IN (SELECT rowid FROM dirs WHERE path GLOB ?) AND files.name LIKE ? ESCAPE '`' LIMIT 1000")
if err != nil {
db.Close()
return nil, err
}
fs := CachedFS{
ql: ql,
qd: qd,
qs: qs,
db: db,
dbp: dbp,
Root: r,
}
// Check if database already has root entry
var id int64
if fs.qd.QueryRow("/").Scan(&id) == nil {
fs.dbr++
}
return &fs, nil
}
// Close closes the database, releasing any open resources.
func (fs *CachedFS) Close() error {
return fs.db.Close()
}
const (
insDir = "INSERT INTO dirs_tmp (path) VALUES (?)"
insFile = "INSERT INTO files_tmp (root, name, dir) VALUES (?, ?, ?)"
)
// Fill database
func (fs *CachedFS) Fill() (int, error) {
if _, err := fs.db.Exec(`
DROP TABLE IF EXISTS dirs_tmp;
DROP TABLE IF EXISTS files_tmp;
CREATE TABLE dirs_tmp (path TEXT);
CREATE TABLE files_tmp (root INTEGER, name TEXT, dir BOOLEAN)
`); err != nil {
return 0, err
}
tx, err := fs.db.Begin()
if err != nil {
return 0, err
}
idir, err := tx.Prepare(insDir)
if err != nil {
return 0, err
}
ifile, err := tx.Prepare(insFile)
if err != nil {
return 0, err
}
cnt := 0
dirs := []int64{0}
root := fs.Root
trim := len(fs.Root)
if strings.HasSuffix(root, string(filepath.Separator)) {
trim--
} else {
root += string(filepath.Separator)
}
err = walk.Walk(fs.Root, &walk.Options{
Error: func(r string, e *walk.Dirent, err error) error {
logErr.Printf("Error iterating \"%s\": %s\n", r, err.Error())
return nil
},
Visit: func(r string, e *walk.Dirent) error {
// Skip root
if cnt == 0 {
cnt++
return nil
}
n := e.Name()
if n == "" || strings.HasPrefix(n, ".") {
return nil
}
if _, err := ifile.Exec(dirs[len(dirs)-1], n, e.IsDir()); err != nil {
return err
}
cnt++
if cnt%16384 == 0 {
if err := tx.Commit(); err != nil {
return err
}
tx, err = fs.db.Begin()
if err != nil {
return err
}
idir, err = tx.Prepare(insDir)
if err != nil {
return err
}
ifile, err = tx.Prepare(insFile)
if err != nil {
return err
}
}
return nil
},
Enter: func(r string, e *walk.Dirent) error {
if strings.HasPrefix(e.Name(), ".") {
return filepath.SkipDir
}
if e.IsSymlink() {
e, err := filepath.EvalSymlinks(r)
if err != nil {
return err
}
a, err := filepath.Abs(e)
if err != nil {
return err
}
if strings.HasPrefix(a, root) {
logErr.Printf("Skipping symlink relative to root (%s)\n", r)
return filepath.SkipDir
}
}
dir := filepath.ToSlash(r[trim:])
if dir != "/" {
dir += "/"
}
row, err := idir.Exec(dir)
if err != nil {
return err
}
id, err := row.LastInsertId()
if err != nil {
return err
}
dirs = append(dirs, id)
return nil
},
Leave: func(r string, e *walk.Dirent, err error) error {
dirs = dirs[:len(dirs)-1]
return err
},
})
if err != nil {
tx.Rollback()
return 0, err
}
if _, err := tx.Exec(`
DROP TABLE IF EXISTS dirs;
DROP TABLE IF EXISTS files;
ALTER TABLE dirs_tmp RENAME TO dirs;
ALTER TABLE files_tmp RENAME TO files;
CREATE INDEX idx_dirs ON dirs (path);
CREATE INDEX idx_files ON files (root);
`); err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
fs.db.Exec("VACUUM; PRAGMA shrink_memory")
atomic.AddInt32(&fs.dbr, 1)
return cnt, nil
}
// DBReady returns whether the DB is ready for querying
func (fs *CachedFS) DBReady() bool {
return fs.db != nil && atomic.LoadInt32(&fs.dbr) != 0
}
var (
escGlob = regexp.MustCompile(`[][*?]`)
escLike = regexp.MustCompile("[%_`]")
escSpace = regexp.MustCompile(`\s+`)
)
func escapeGlob(s string) string {
return escGlob.ReplaceAllStringFunc(s, func(m string) string { return "[" + m + "]" })
}
func escapeLike(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return "%"
}
s = escLike.ReplaceAllStringFunc(s, func(m string) string { return "`" + m })
s = escSpace.ReplaceAllString(s, "%")
return "%" + s + "%"
}
func escapeRegex(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ".*"
}
s = regexp.QuoteMeta(s)
s = escSpace.ReplaceAllString(s, ".*")
return "(?i).*" + s + ".*"
}
func cleanPath(p string) string {
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
p = path.Clean(p)
if !strings.HasSuffix(p, "/") {
p += "/"
}
return p
}
// File data sent to client
type File struct {
Name string `json:"name"`
Type string `json:"type"`
}
// Files list (sortable)
type Files []File
func (f Files) Len() int { return len(f) }
func (f Files) Swap(i, j int) { f[i], f[j] = f[j], f[i] }
func (f Files) Less(i, j int) bool {
if f[i].Type == f[j].Type {
return strings.ToLower(f[i].Name) < strings.ToLower(f[j].Name)
}
return f[i].Type < f[j].Type
}
func (fs *CachedFS) serveCache(w http.ResponseWriter, r *http.Request) {
if !fs.DBReady() {
http.Error(w, "503 Service Unavailable", http.StatusServiceUnavailable)
return
}
ctx, cancel := context.WithTimeout(r.Context(), fs.Timeout)
defer cancel()
p := cleanPath(r.URL.Path)
trim := len(p)
p = escapeGlob(p)
if r.URL.Query().Get("r") != "" {
p += "*"
}
var id int64
if err := fs.qd.QueryRowContext(ctx, p).Scan(&id); err == sql.ErrNoRows {
http.NotFound(w, r)
return
} else if err != nil {
logError(http.StatusInternalServerError, err, w, r)
return
}
resp := make(Files, 0)
search := escapeLike(r.URL.Query().Get("q"))
rows, err := fs.qs.QueryContext(ctx, p, search)
if err != nil {
logError(http.StatusInternalServerError, err, w, r)
return
}
defer rows.Close()
for rows.Next() {
var root string
var name string
var dir bool
if err := rows.Scan(&root, &name, &dir); err != nil {
logError(http.StatusInternalServerError, err, w, r)
return
}
f := File{Name: root[trim:] + name}
if dir {
f.Type = "d"
} else {
f.Type = "f"
}
resp = append(resp, f)
}
if err := rows.Err(); err != nil {
logError(http.StatusInternalServerError, err, w, r)
return
}
sort.Sort(resp)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "max-age=60")
json.NewEncoder(w).Encode(resp)
}
func (fs *CachedFS) serveLive(w http.ResponseWriter, r *http.Request) {
p := filepath.Join(fs.Root, filepath.FromSlash(r.URL.Path), "_")
p = p[:len(p)-1]
resp := make(Files, 0)
search, err := regexp.Compile(escapeRegex(r.URL.Query().Get("q")))
if err == nil {
trim := len(p)
depth := 0
err = walk.Walk(p, &walk.Options{
Error: func(r string, e *walk.Dirent, err error) error {
logErr.Printf("Error iterating \"%s\": %s\n", r, err.Error())
return nil
},
Visit: func(r string, e *walk.Dirent) error {
if depth == 0 {
return nil
}
n := e.Name()
if n == "" || strings.HasPrefix(n, ".") || !search.MatchString(n) {
return nil
}
f := File{Name: filepath.ToSlash(r[trim:])}
if e.IsDir() {
f.Type = "d"
} else {
f.Type = "f"
}
resp = append(resp, f)
return nil
},
Enter: func(r string, e *walk.Dirent) error {
if depth >= 1 {
return filepath.SkipDir
}
depth++
return nil
},
Leave: func(r string, e *walk.Dirent, err error) error {
depth--
return err
},
})
}
if err == walk.ErrNonDir || os.IsNotExist(err) || os.IsPermission(err) {
http.NotFound(w, r)
return
} else if err != nil {
logError(http.StatusInternalServerError, err, w, r)
return
}
sort.Sort(resp)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "max-age=60")
json.NewEncoder(w).Encode(resp)
}
func (fs *CachedFS) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if fs.Cached || r.URL.Query().Get("r") != "" {
fs.serveCache(w, r)
} else {
fs.serveLive(w, r)
}
}
// Sitemap serves a list of all directories
func (fs *CachedFS) Sitemap(w http.ResponseWriter, r *http.Request) {
if !fs.DBReady() {
http.Error(w, "503 Service Unavailable", http.StatusServiceUnavailable)
return
}
ctx, cancel := context.WithTimeout(r.Context(), fs.Timeout)
defer cancel()
u, err := url.Parse("https://" + r.Host)
if err != nil {
logError(http.StatusInternalServerError, err, w, r)
return
}
rows, err := fs.ql.QueryContext(ctx)
if err != nil {
logError(http.StatusInternalServerError, err, w, r)
return
}
defer rows.Close()
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "max-age=3600")
for rows.Next() {
var path string
if err := rows.Scan(&path); err != nil {
logError(http.StatusInternalServerError, err, w, r)
return
}
u.Path = path[:len(path)-1]
w.Write([]byte(u.String()))
w.Write([]byte{'\n'})
}
if err := rows.Err(); err != nil {
logError(http.StatusInternalServerError, err, w, r)
}
}