mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
add filesystem support for file previews
This commit is contained in:
parent
1a3a93ef8d
commit
9bfa72a668
2 changed files with 149 additions and 0 deletions
|
|
@ -1,8 +1,12 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -15,6 +19,13 @@ type FSEntry struct {
|
||||||
ModTime time.Time `json:"mod_time"`
|
ModTime time.Time `json:"mod_time"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FileInfo struct {
|
||||||
|
FSEntry
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
Readable bool `json:"readable"`
|
||||||
|
Executable bool `json:"executable"`
|
||||||
|
}
|
||||||
|
|
||||||
func BrowseDirectory(path string) ([]FSEntry, error) {
|
func BrowseDirectory(path string) ([]FSEntry, error) {
|
||||||
absPath, err := filepath.Abs(path)
|
absPath, err := filepath.Abs(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -47,3 +58,78 @@ func BrowseDirectory(path string) ([]FSEntry, error) {
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetFileInfo returns detailed information about a file
|
||||||
|
func GetFileInfo(path string) (*FileInfo, error) {
|
||||||
|
// Prevent path traversal attacks
|
||||||
|
cleanPath := filepath.Clean(path)
|
||||||
|
if strings.Contains(cleanPath, "..") {
|
||||||
|
return nil, fmt.Errorf("invalid path: path traversal detected")
|
||||||
|
}
|
||||||
|
|
||||||
|
absPath, err := filepath.Abs(cleanPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to resolve path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to stat file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil, fmt.Errorf("path is a directory, not a file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect MIME type
|
||||||
|
mimeType := "application/octet-stream"
|
||||||
|
file, err := os.Open(absPath)
|
||||||
|
if err == nil {
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Read first 512 bytes for content detection
|
||||||
|
buffer := make([]byte, 512)
|
||||||
|
n, _ := file.Read(buffer)
|
||||||
|
if n > 0 {
|
||||||
|
mimeType = http.DetectContentType(buffer[:n])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check permissions
|
||||||
|
mode := info.Mode()
|
||||||
|
readable := mode&0400 != 0
|
||||||
|
executable := mode&0100 != 0
|
||||||
|
|
||||||
|
return &FileInfo{
|
||||||
|
FSEntry: FSEntry{
|
||||||
|
Name: info.Name(),
|
||||||
|
Path: absPath,
|
||||||
|
IsDir: false,
|
||||||
|
Size: info.Size(),
|
||||||
|
Mode: mode.String(),
|
||||||
|
ModTime: info.ModTime(),
|
||||||
|
},
|
||||||
|
MimeType: mimeType,
|
||||||
|
Readable: readable,
|
||||||
|
Executable: executable,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadFile opens a file for reading with security checks
|
||||||
|
func ReadFile(path string) (io.ReadCloser, *FileInfo, error) {
|
||||||
|
fileInfo, err := GetFileInfo(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !fileInfo.Readable {
|
||||||
|
return nil, nil, fmt.Errorf("file is not readable")
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(fileInfo.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to open file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return file, fileInfo, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,8 @@ func (s *Server) createHandler() http.Handler {
|
||||||
api.HandleFunc("/sessions/multistream", s.handleMultistream).Methods("GET")
|
api.HandleFunc("/sessions/multistream", s.handleMultistream).Methods("GET")
|
||||||
api.HandleFunc("/cleanup-exited", s.handleCleanupExited).Methods("POST")
|
api.HandleFunc("/cleanup-exited", s.handleCleanupExited).Methods("POST")
|
||||||
api.HandleFunc("/fs/browse", s.handleBrowseFS).Methods("GET")
|
api.HandleFunc("/fs/browse", s.handleBrowseFS).Methods("GET")
|
||||||
|
api.HandleFunc("/fs/read", s.handleReadFile).Methods("GET")
|
||||||
|
api.HandleFunc("/fs/info", s.handleFileInfo).Methods("GET")
|
||||||
api.HandleFunc("/mkdir", s.handleMkdir).Methods("POST")
|
api.HandleFunc("/mkdir", s.handleMkdir).Methods("POST")
|
||||||
|
|
||||||
// Ngrok endpoints
|
// Ngrok endpoints
|
||||||
|
|
@ -1065,3 +1067,64 @@ func findVTBinary() string {
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleFileInfo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := r.URL.Query().Get("path")
|
||||||
|
if path == "" {
|
||||||
|
http.Error(w, "Path parameter is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInfo, err := GetFileInfo(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
http.Error(w, "File not found", http.StatusNotFound)
|
||||||
|
} else if strings.Contains(err.Error(), "path traversal") {
|
||||||
|
http.Error(w, "Invalid path", http.StatusBadRequest)
|
||||||
|
} else {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(fileInfo); err != nil {
|
||||||
|
log.Printf("Failed to encode file info: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleReadFile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := r.URL.Query().Get("path")
|
||||||
|
if path == "" {
|
||||||
|
http.Error(w, "Path parameter is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, fileInfo, err := ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
http.Error(w, "File not found", http.StatusNotFound)
|
||||||
|
} else if strings.Contains(err.Error(), "path traversal") {
|
||||||
|
http.Error(w, "Invalid path", http.StatusBadRequest)
|
||||||
|
} else if strings.Contains(err.Error(), "not readable") {
|
||||||
|
http.Error(w, "File is not readable", http.StatusForbidden)
|
||||||
|
} else {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Set appropriate headers
|
||||||
|
w.Header().Set("Content-Type", fileInfo.MimeType)
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", fileInfo.Name))
|
||||||
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", fileInfo.Size))
|
||||||
|
|
||||||
|
// Add cache headers for static files
|
||||||
|
if strings.HasPrefix(fileInfo.MimeType, "image/") || strings.HasPrefix(fileInfo.MimeType, "application/pdf") {
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Support range requests for large files
|
||||||
|
http.ServeContent(w, r, fileInfo.Name, fileInfo.ModTime, file.(io.ReadSeeker))
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue