@@ -3,12 +3,20 @@ package resources
33import (
44 "encoding/json"
55 "fmt"
6+ "mime"
67 "net/http"
8+ "path/filepath"
9+ "sort"
10+ "strings"
711 "time"
812
913 "github.com/gin-gonic/gin"
1014 "github.com/samber/lo"
1115 "github.com/zxh326/kite/pkg/cluster"
16+ "github.com/zxh326/kite/pkg/common"
17+ "github.com/zxh326/kite/pkg/kube"
18+ "github.com/zxh326/kite/pkg/model"
19+ "github.com/zxh326/kite/pkg/rbac"
1220 corev1 "k8s.io/api/core/v1"
1321 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1422 "k8s.io/apimachinery/pkg/watch"
@@ -169,9 +177,241 @@ func (h *PodHandler) List(c *gin.Context) {
169177func (h * PodHandler ) registerCustomRoutes (group * gin.RouterGroup ) {
170178 // watch pods in namespace (or _all)
171179 group .GET ("/:namespace/watch" , h .Watch )
180+ filesGroup := group .Group ("/:namespace/:name/files" )
181+ filesGroup .Use (func (c * gin.Context ) {
182+ user := c .MustGet ("user" ).(model.User )
183+ cs := c .MustGet ("cluster" ).(* cluster.ClientSet )
184+ namespace := c .Param ("namespace" )
185+ if ! rbac .CanAccess (user , "pods" , string (common .VerbExec ), cs .Name , namespace ) {
186+ c .JSON (http .StatusForbidden , gin.H {"error" : rbac .NoAccess (user .Key (), string (common .VerbExec ), "pods" , namespace , cs .Name )})
187+ c .Abort ()
188+ return
189+ }
190+ c .Next ()
191+ })
192+ filesGroup .GET ("" , h .ListFiles )
193+ filesGroup .GET ("/preview" , h .PreviewFile )
194+ filesGroup .GET ("/download" , h .DownloadFile )
195+ filesGroup .PUT ("/upload" , h .UploadFile )
196+ }
197+
198+ type FileInfo struct {
199+ Name string `json:"name"`
200+ IsDir bool `json:"isDir"`
201+ Size string `json:"size"`
202+ ModTime string `json:"modTime"`
203+ Mode string `json:"mode"`
204+ UID string `json:"uid,omitempty"`
205+ GID string `json:"gid,omitempty"`
206+ }
207+
208+ func (h * PodHandler ) ListFiles (c * gin.Context ) {
209+ namespace := c .Param ("namespace" )
210+ podName := c .Param ("name" )
211+ container := c .Query ("container" )
212+ path := c .Query ("path" )
213+ if path == "" {
214+ path = "/"
215+ }
216+ cs := c .MustGet ("cluster" ).(* cluster.ClientSet )
217+ cmd := []string {"ls" , "-lah" , "--full-time" , path }
218+ stdout , stderr , err := cs .K8sClient .ExecCommandBuffered (c .Request .Context (), namespace , podName , container , cmd )
219+ if err != nil {
220+ if strings .Contains (err .Error (), "not found" ) || strings .Contains (stderr , "not found" ) {
221+ c .JSON (http .StatusBadRequest , gin.H {
222+ "error" : fmt .Sprintf ("File browsing is not supported for %s container (missing 'ls' command)" , container ),
223+ })
224+ return
225+ }
226+ c .JSON (http .StatusOK , nil )
227+ return
228+ }
229+
230+ files := parseLsOutput (stdout )
231+ c .JSON (http .StatusOK , files )
232+ }
233+
234+ func parseLsOutput (output string ) []FileInfo {
235+ lines := strings .Split (output , "\n " )
236+ files := make ([]FileInfo , 0 )
237+ for _ , line := range lines {
238+ if strings .HasPrefix (line , "total" ) || strings .TrimSpace (line ) == "" {
239+ continue
240+ }
241+ parts := strings .Fields (line )
242+ if len (parts ) < 9 {
243+ continue
244+ }
245+
246+ mode := parts [0 ]
247+ isDir := strings .HasPrefix (mode , "d" )
248+
249+ uid := parts [2 ]
250+ gid := parts [3 ]
251+ size := parts [4 ]
252+
253+ rawDate := strings .Join (parts [5 :7 ], " " )
254+ modTime := rawDate
255+ name := strings .Join (parts [8 :], " " )
256+ // Skip . and ..
257+ if name == "." || name == ".." {
258+ continue
259+ }
260+ files = append (files , FileInfo {
261+ Name : name ,
262+ IsDir : isDir ,
263+ Size : size ,
264+ ModTime : modTime ,
265+ Mode : mode ,
266+ UID : uid ,
267+ GID : gid ,
268+ })
269+ }
270+ sort .Slice (files , func (i , j int ) bool {
271+ // Directories first
272+ if files [i ].IsDir && ! files [j ].IsDir {
273+ return true
274+ }
275+ if ! files [i ].IsDir && files [j ].IsDir {
276+ return false
277+ }
278+ return strings .ToLower (files [i ].Name ) < strings .ToLower (files [j ].Name )
279+ })
280+ return files
281+ }
282+
283+ func (h * PodHandler ) PreviewFile (c * gin.Context ) {
284+ namespace := c .Param ("namespace" )
285+ podName := c .Param ("name" )
286+ container := c .Query ("container" )
287+ path := c .Query ("path" )
288+ cs := c .MustGet ("cluster" ).(* cluster.ClientSet )
289+ if path == "" {
290+ c .JSON (http .StatusBadRequest , gin.H {"error" : "path is required" })
291+ return
292+ }
293+ if strings .Contains (path , "->" ) {
294+ path = strings .TrimSpace (strings .SplitN (path , "->" , 2 )[0 ])
295+ }
296+
297+ cmd := []string {"cat" , path }
298+
299+ contentType := mime .TypeByExtension (filepath .Ext (path ))
300+ if contentType == "" {
301+ contentType = "text/plain; charset=utf-8"
302+ }
303+ c .Header ("Content-Type" , contentType )
304+ c .Header ("Content-Disposition" , fmt .Sprintf ("inline; filename=\" %s\" " , filepath .Base (path )))
305+
306+ err := cs .K8sClient .ExecCommand (c .Request .Context (), kube.ExecOptions {
307+ Namespace : namespace ,
308+ PodName : podName ,
309+ ContainerName : container ,
310+ Command : cmd ,
311+ Stdout : c .Writer ,
312+ Stderr : nil ,
313+ TTY : false ,
314+ })
315+
316+ if err != nil {
317+ klog .Errorf ("Failed to preview file: %v" , err )
318+ }
319+ }
320+
321+ func (h * PodHandler ) DownloadFile (c * gin.Context ) {
322+ namespace := c .Param ("namespace" )
323+ podName := c .Param ("name" )
324+ container := c .Query ("container" )
325+ path := c .Query ("path" )
326+
327+ cs := c .MustGet ("cluster" ).(* cluster.ClientSet )
328+
329+ if path == "" {
330+ c .JSON (http .StatusBadRequest , gin.H {"error" : "path is required" })
331+ return
332+ }
333+ if strings .Contains (path , "->" ) {
334+ path = strings .TrimSpace (strings .SplitN (path , "->" , 2 )[0 ])
335+ }
336+ _ , _ , err := cs .K8sClient .ExecCommandBuffered (c .Request .Context (), namespace , podName , container , []string {"test" , "-d" , path })
337+ isDir := err == nil
338+
339+ var cmd []string
340+ if isDir {
341+ c .Header ("Content-Disposition" , fmt .Sprintf ("attachment; filename=\" %s.tar\" " , filepath .Base (path )))
342+ c .Header ("Content-Type" , "application/x-tar" )
343+ cmd = []string {"tar" , "cf" , "-" , path }
344+ } else {
345+ c .Header ("Content-Disposition" , fmt .Sprintf ("attachment; filename=\" %s\" " , filepath .Base (path )))
346+ c .Header ("Content-Type" , "application/octet-stream" )
347+ cmd = []string {"cat" , path }
348+ }
349+
350+ err = cs .K8sClient .ExecCommand (c .Request .Context (), kube.ExecOptions {
351+ Namespace : namespace ,
352+ PodName : podName ,
353+ ContainerName : container ,
354+ Command : cmd ,
355+ Stdout : c .Writer ,
356+ Stderr : nil ,
357+ TTY : false ,
358+ })
359+
360+ if err != nil {
361+ klog .Errorf ("Failed to download file: %v" , err )
362+ }
363+ }
364+
365+ func (h * PodHandler ) UploadFile (c * gin.Context ) {
366+ namespace := c .Param ("namespace" )
367+ podName := c .Param ("name" )
368+ container := c .Query ("container" )
369+ path := c .Query ("path" )
370+ cs := c .MustGet ("cluster" ).(* cluster.ClientSet )
371+ if path == "" {
372+ c .JSON (http .StatusBadRequest , gin.H {"error" : "path is required" })
373+ return
374+ }
375+
376+ file , header , err := c .Request .FormFile ("file" )
377+ if err != nil {
378+ c .JSON (http .StatusBadRequest , gin.H {"error" : "failed to get file from request" })
379+ return
380+ }
381+ defer func () {
382+ if err := file .Close (); err != nil {
383+ klog .Errorf ("failed to close uploaded file: %v" , err )
384+ }
385+ }()
386+
387+ filename := filepath .Base (header .Filename )
388+ if filename == "." || filename == ".." || strings .Contains (filename , "/" ) || strings .Contains (filename , "\\ " ) {
389+ c .JSON (http .StatusBadRequest , gin.H {"error" : "invalid filename" })
390+ return
391+ }
392+
393+ destPath := filepath .Join (path , header .Filename )
394+ cmd := []string {"tee" , destPath }
395+
396+ err = cs .K8sClient .ExecCommand (c .Request .Context (), kube.ExecOptions {
397+ Namespace : namespace ,
398+ PodName : podName ,
399+ ContainerName : container ,
400+ Command : cmd ,
401+ Stdin : file , // Stream file content directly
402+ Stdout : nil ,
403+ Stderr : nil ,
404+ TTY : false ,
405+ })
406+
407+ if err != nil {
408+ c .JSON (http .StatusInternalServerError , gin.H {"error" : fmt .Sprintf ("failed to upload file: %v" , err )})
409+ return
410+ }
411+
412+ c .JSON (http .StatusOK , gin.H {"message" : "file uploaded successfully" })
172413}
173414
174- // writeSSE writes a single SSE event with the given name and payload
175415func writeSSE (c * gin.Context , event string , payload any ) error {
176416 c .Writer .Header ().Set ("Content-Type" , "text/event-stream" )
177417 c .Writer .Header ().Set ("Cache-Control" , "no-cache, no-transform" )
0 commit comments