Skip to content

Commit a9b3982

Browse files
committed
add file handler api
1 parent a4dbff1 commit a9b3982

File tree

5 files changed

+332
-1
lines changed

5 files changed

+332
-1
lines changed

pkg/api/api.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Package api provides an HTTP API for listing, fetching, and deleting files
2+
// written by the UDP ingest path. All operations are scoped to output_path.
3+
package api
4+
5+
import (
6+
"net/http"
7+
"path/filepath"
8+
9+
"github.com/gorilla/mux"
10+
)
11+
12+
// API handles HTTP routes for the file management API.
13+
type API struct {
14+
outputPath string
15+
password string
16+
}
17+
18+
// New returns an API instance rooted at outputPath.
19+
// password is compared against the X-Api-Key request header; an empty string disables auth.
20+
func New(outputPath, password string) *API {
21+
return &API{
22+
outputPath: filepath.Clean(outputPath),
23+
password: password,
24+
}
25+
}
26+
27+
// Register mounts all API routes onto smuthe server muxx and is intended to be passed as the
28+
// register callback to httpserver.New.
29+
func (a *API) Register(smx *http.ServeMux) {
30+
router := mux.NewRouter()
31+
apiRouter := router.PathPrefix("/api").Subrouter()
32+
apiRouter.Use(a.authenticate)
33+
34+
apiRouter.HandleFunc("/list/{path:.+}", a.listHandler).Methods(http.MethodGet)
35+
apiRouter.HandleFunc("/file/{path:.+}", a.fileHandler).Methods(http.MethodGet)
36+
apiRouter.HandleFunc("/all", a.deleteAllHandler).Methods(http.MethodDelete)
37+
apiRouter.HandleFunc("/{path:.+}", a.deleteHandler).Methods(http.MethodDelete)
38+
39+
smx.Handle("/api/", router)
40+
smx.Handle("/api", router)
41+
}

pkg/api/handlers.go

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"fmt"
7+
"net/http"
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
"time"
12+
13+
"github.com/gorilla/mux"
14+
)
15+
16+
// Entry describes a file or directory returned by the list endpoint.
17+
type Entry struct {
18+
Path string `json:"path"`
19+
Size int64 `json:"size"`
20+
IsDir bool `json:"isDir"`
21+
ModTime time.Time `json:"modTime"`
22+
}
23+
24+
// DeleteResult describes the outcome of a delete request.
25+
type DeleteResult struct {
26+
Deleted int `json:"deleted"`
27+
Paths []string `json:"paths"`
28+
}
29+
30+
// listHandler handles GET /api/list/{path}.
31+
// It returns the contents of all directories matching the (possibly glob) path.
32+
// A final /* is always appended so the response contains entries rather than
33+
// the matched directories themselves — clients should omit the trailing wildcard.
34+
func (a *API) listHandler(resp http.ResponseWriter, r *http.Request) {
35+
rawPath := mux.Vars(r)["path"]
36+
37+
pattern, err := a.safePath(rawPath)
38+
if err != nil {
39+
writeError(resp, http.StatusBadRequest, err.Error())
40+
return
41+
}
42+
43+
// Append /* to list contents of each matched directory.
44+
pattern = filepath.Join(pattern, "*")
45+
46+
matches, err := a.globSafe(pattern)
47+
if err != nil {
48+
writeError(resp, http.StatusInternalServerError, err.Error())
49+
return
50+
}
51+
52+
entries := make([]Entry, 0, len(matches))
53+
54+
for _, match := range matches {
55+
info, statErr := os.Lstat(match)
56+
if statErr != nil {
57+
continue
58+
}
59+
60+
relPath := strings.TrimPrefix(match, a.outputPath+string(filepath.Separator))
61+
entries = append(entries, Entry{
62+
Path: relPath,
63+
Size: info.Size(),
64+
IsDir: info.IsDir(),
65+
ModTime: info.ModTime().UTC(),
66+
})
67+
}
68+
69+
writeJSON(resp, http.StatusOK, entries)
70+
}
71+
72+
// fileHandler handles GET /api/file/{path} and streams the file contents.
73+
// Wildcards are not permitted; use /api/list/ first to find file paths.
74+
func (a *API) fileHandler(resp http.ResponseWriter, req *http.Request) {
75+
rawPath := mux.Vars(req)["path"]
76+
77+
filePath, err := a.safePath(rawPath)
78+
if err != nil {
79+
writeError(resp, http.StatusBadRequest, err.Error())
80+
return
81+
}
82+
83+
if strings.ContainsRune(filePath, '*') {
84+
writeError(resp, http.StatusBadRequest, "wildcards not allowed for file fetch; use /api/list/ first")
85+
return
86+
}
87+
88+
info, err := os.Stat(filePath)
89+
if err != nil {
90+
if errors.Is(err, os.ErrNotExist) {
91+
writeError(resp, http.StatusNotFound, "not found: "+filePath)
92+
} else {
93+
writeError(resp, http.StatusInternalServerError, "stat: "+err.Error())
94+
}
95+
96+
return
97+
}
98+
99+
if info.IsDir() {
100+
writeError(resp, http.StatusBadRequest, "path is a directory; use /api/list/")
101+
return
102+
}
103+
104+
fileHandle, err := os.Open(filePath) //nolint:gosec // path validated against outputPath
105+
if err != nil {
106+
writeError(resp, http.StatusInternalServerError, "opening file: "+err.Error())
107+
return
108+
}
109+
defer fileHandle.Close()
110+
111+
http.ServeContent(resp, req, info.Name(), info.ModTime(), fileHandle)
112+
}
113+
114+
// deleteHandler handles DELETE /api/{path} and removes all files or directories
115+
// matching the path. The path may contain * wildcards in any segment.
116+
func (a *API) deleteHandler(resp http.ResponseWriter, r *http.Request) {
117+
rawPath := mux.Vars(r)["path"]
118+
119+
pattern, err := a.safePath(rawPath)
120+
if err != nil {
121+
writeError(resp, http.StatusBadRequest, err.Error())
122+
return
123+
}
124+
125+
matches, err := a.resolveForDelete(pattern)
126+
if err != nil {
127+
writeError(resp, http.StatusInternalServerError, err.Error())
128+
return
129+
}
130+
131+
deleted := make([]string, 0, len(matches))
132+
133+
for _, match := range matches {
134+
err = os.RemoveAll(match)
135+
if err == nil {
136+
deleted = append(deleted, strings.TrimPrefix(match, a.outputPath+string(filepath.Separator)))
137+
}
138+
}
139+
140+
writeJSON(resp, http.StatusOK, DeleteResult{Deleted: len(deleted), Paths: deleted})
141+
}
142+
143+
// resolveForDelete returns the filesystem paths to act on for the given pattern.
144+
// When the pattern contains no wildcard it returns the pattern if it exists.
145+
func (a *API) resolveForDelete(pattern string) ([]string, error) {
146+
if !strings.ContainsRune(pattern, '*') {
147+
_, statErr := os.Stat(pattern)
148+
149+
if errors.Is(statErr, os.ErrNotExist) {
150+
return nil, nil
151+
}
152+
153+
if statErr != nil {
154+
return nil, fmt.Errorf("stat: %w", statErr)
155+
}
156+
157+
return []string{pattern}, nil
158+
}
159+
160+
return a.globSafe(pattern)
161+
}
162+
163+
// deleteAllHandler handles DELETE /api/all and removes every entry directly
164+
// inside outputPath, including directories.
165+
func (a *API) deleteAllHandler(resp http.ResponseWriter, _ *http.Request) {
166+
entries, err := os.ReadDir(a.outputPath)
167+
if err != nil && !errors.Is(err, os.ErrNotExist) {
168+
writeError(resp, http.StatusInternalServerError, "reading output path: "+err.Error())
169+
return
170+
}
171+
172+
var count int
173+
174+
for _, entry := range entries {
175+
removeErr := os.RemoveAll(filepath.Join(a.outputPath, entry.Name()))
176+
if removeErr == nil {
177+
count++
178+
}
179+
}
180+
181+
writeJSON(resp, http.StatusOK, map[string]int{"deleted": count})
182+
}
183+
184+
// writeJSON writes data as a JSON response body with the given status code.
185+
func writeJSON(resp http.ResponseWriter, status int, data any) {
186+
body, err := json.Marshal(data)
187+
if err != nil {
188+
http.Error(resp, `{"error":"internal server error"}`, http.StatusInternalServerError)
189+
return
190+
}
191+
192+
resp.Header().Set("Content-Type", "application/json")
193+
resp.WriteHeader(status)
194+
_, _ = resp.Write(body)
195+
}
196+
197+
// writeError writes a JSON error response.
198+
func writeError(resp http.ResponseWriter, status int, msg string) {
199+
writeJSON(resp, status, map[string]string{"error": msg})
200+
}

pkg/api/middleware.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package api
2+
3+
import "net/http"
4+
5+
// authenticate validates the X-Api-Key header against the configured password.
6+
// When password is empty, all requests are allowed through.
7+
func (a *API) authenticate(next http.Handler) http.Handler {
8+
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
9+
if a.password != "" && req.Header.Get("X-Api-Key") != a.password {
10+
writeError(resp, http.StatusUnauthorized, "invalid or missing X-Api-Key")
11+
return
12+
}
13+
14+
next.ServeHTTP(resp, req)
15+
})
16+
}

pkg/api/paths.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package api
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"path/filepath"
7+
"slices"
8+
"strings"
9+
)
10+
11+
// Sentinel errors returned by path validation.
12+
var (
13+
errPathTraversal = errors.New("path traversal not allowed")
14+
errPathEscapes = errors.New("path escapes output directory")
15+
)
16+
17+
// safePath joins rawPath with outputPath and validates that the result stays
18+
// within outputPath. For glob paths (containing *), the static prefix before
19+
// the first wildcard is validated.
20+
func (a *API) safePath(rawPath string) (string, error) {
21+
// Strip leading slashes so filepath.Join does not treat rawPath as absolute.
22+
rawPath = strings.TrimLeft(rawPath, "/")
23+
24+
if slices.Contains(strings.Split(rawPath, "/"), "..") {
25+
return "", errPathTraversal
26+
}
27+
28+
joined := filepath.Join(a.outputPath, rawPath)
29+
prefix, _, hasWildcard := strings.Cut(joined, "*")
30+
31+
if !hasWildcard {
32+
clean := filepath.Clean(joined)
33+
34+
if !a.isUnder(clean) {
35+
return "", errPathEscapes
36+
}
37+
38+
return clean, nil
39+
}
40+
41+
// For glob paths, verify the static prefix before the first wildcard.
42+
if !a.isUnder(filepath.Clean(prefix)) {
43+
return "", errPathEscapes
44+
}
45+
46+
return joined, nil
47+
}
48+
49+
// isUnder reports whether path equals outputPath or is directly beneath it.
50+
func (a *API) isUnder(path string) bool {
51+
return path == a.outputPath ||
52+
strings.HasPrefix(path, a.outputPath+string(filepath.Separator))
53+
}
54+
55+
// globSafe expands pattern with filepath.Glob and returns only results that
56+
// are confirmed to be under outputPath.
57+
func (a *API) globSafe(pattern string) ([]string, error) {
58+
matches, err := filepath.Glob(pattern)
59+
if err != nil {
60+
return nil, fmt.Errorf("glob pattern: %w", err)
61+
}
62+
63+
result := make([]string, 0, len(matches))
64+
65+
for _, match := range matches {
66+
if a.isUnder(filepath.Clean(match)) {
67+
result = append(result, match)
68+
}
69+
}
70+
71+
return result, nil
72+
}

pkg/fog/start.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"sync"
1010
"time"
1111

12+
"github.com/Notifiarr/fogwillow/pkg/api"
1213
"github.com/Notifiarr/fogwillow/pkg/buf"
1314
"github.com/Notifiarr/fogwillow/pkg/httpserver"
1415
"github.com/Notifiarr/fogwillow/pkg/metrics"
@@ -102,7 +103,8 @@ func (c *Config) Start() error {
102103
go c.packetListener(idx)
103104
}
104105

105-
c.httpSrv = httpserver.New(c.HTTPServer, nil)
106+
fogAPI := api.New(c.OutputPath, c.Password)
107+
c.httpSrv = httpserver.New(c.HTTPServer, fogAPI.Register)
106108

107109
err = c.httpSrv.ListenAndServe()
108110
if err != nil {

0 commit comments

Comments
 (0)