mirror of
https://github.com/nielsAD/autoindex.git
synced 2026-01-18 11:22:03 +01:00
498 lines
10 KiB
Go
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)
|
|
}
|
|
}
|