Files
kenaito-kopsc/internal/executor/nethttp.go
T

282 lines
6.6 KiB
Go

package executor
import (
"context"
"fmt"
"io"
"mime"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// runNetHTTP implements `kopsc net http`: start a simple HTTP file server.
func runNetHTTP(ctx context.Context, req BuiltinRequest) error {
opts, err := parseNetHTTPArgs(req.Args)
if err != nil {
return err
}
dir := opts.dir
if dir == "" {
dir = "."
}
absDir, err := filepath.Abs(dir)
if err != nil {
return fmt.Errorf("无法解析路径 %q: %w", dir, err)
}
if info, err := os.Stat(absDir); err != nil {
return fmt.Errorf("目录 %q 不存在: %w", dir, err)
} else if !info.IsDir() {
return fmt.Errorf("%q 不是目录", dir)
}
port := opts.port
addr := fmt.Sprintf(":%d", port)
// Check if port is available
listener, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("端口 %d 已被占用: %w", port, err)
}
// Get local IP addresses
ips := getLocalIPs()
fmt.Fprintf(req.Stdout, "=== HTTP 文件服务器 ===\n\n")
fmt.Fprintf(req.Stdout, "目录: %s\n", absDir)
fmt.Fprintf(req.Stdout, "地址:\n")
fmt.Fprintf(req.Stdout, " http://localhost:%d\n", port)
for _, ip := range ips {
fmt.Fprintf(req.Stdout, " http://%s:%d\n", ip, port)
}
if opts.upload {
fmt.Fprintf(req.Stdout, "\n上传功能已启用\n")
fmt.Fprintf(req.Stdout, "POST /upload 可上传文件\n")
}
fmt.Fprintf(req.Stdout, "\n按 Ctrl+C 停止\n")
mux := http.NewServeMux()
if opts.upload {
mux.HandleFunc("/upload", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "请使用 POST 方法上传", http.StatusMethodNotAllowed)
return
}
handleUpload(w, r, absDir, req)
})
}
// File server with directory listing
fileServer := http.FileServer(http.Dir(absDir))
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Log request
fmt.Fprintf(req.Stdout, "[%s] %s %s\n", time.Now().Format("15:04:05"), r.Method, r.URL.Path)
fileServer.ServeHTTP(w, r)
}))
server := &http.Server{
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
}
// Handle context cancellation
go func() {
<-ctx.Done()
server.Shutdown(context.Background())
}()
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
return err
}
return nil
}
type netHTTPOptions struct {
dir string
port int
upload bool
}
func parseNetHTTPArgs(args []string) (netHTTPOptions, error) {
opts := netHTTPOptions{
port: 8000, // default port
}
for i := 0; i < len(args); i++ {
arg := args[i]
switch {
case arg == "--":
// Ignore everything after --
return opts, nil
case arg == "--dir" || arg == "-d":
if i+1 >= len(args) {
return opts, fmt.Errorf("%s 缺少目录参数", arg)
}
i++
opts.dir = args[i]
case strings.HasPrefix(arg, "--dir="):
opts.dir = strings.TrimPrefix(arg, "--dir=")
case arg == "--port" || arg == "-p":
if i+1 >= len(args) {
return opts, fmt.Errorf("%s 缺少端口参数", arg)
}
i++
port, err := strconv.Atoi(args[i])
if err != nil || port < 1 || port > 65535 {
return opts, fmt.Errorf("无效的端口号: %q", args[i])
}
opts.port = port
case strings.HasPrefix(arg, "--port="):
portStr := strings.TrimPrefix(arg, "--port=")
port, err := strconv.Atoi(portStr)
if err != nil || port < 1 || port > 65535 {
return opts, fmt.Errorf("无效的端口号: %q", portStr)
}
opts.port = port
case arg == "--upload" || arg == "-u":
opts.upload = true
case strings.HasPrefix(arg, "-") && arg != "-":
return opts, fmt.Errorf("未知选项 %q", arg)
default:
// Treat as directory
opts.dir = arg
}
}
return opts, nil
}
func getLocalIPs() []string {
var ips []string
ifaces, err := net.Interfaces()
if err != nil {
return ips
}
for _, iface := range ifaces {
// Skip down interfaces and loopback
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
if ip == nil || ip.IsLoopback() {
continue
}
// Only IPv4 for simplicity
if ip.To4() != nil {
ips = append(ips, ip.String())
}
}
}
return ips
}
func handleUpload(w http.ResponseWriter, r *http.Request, dir string, req BuiltinRequest) {
// Parse multipart form (max 100MB)
if err := r.ParseMultipartForm(100 << 20); err != nil {
http.Error(w, fmt.Sprintf("解析表单失败: %v", err), http.StatusBadRequest)
return
}
files := r.MultipartForm.File["file"]
if len(files) == 0 {
http.Error(w, "没有选择文件", http.StatusBadRequest)
return
}
uploaded := 0
for _, fileHeader := range files {
file, err := fileHeader.Open()
if err != nil {
http.Error(w, fmt.Sprintf("打开文件失败: %v", err), http.StatusInternalServerError)
return
}
defer file.Close()
// Sanitize filename
filename := filepath.Base(fileHeader.Filename)
destPath := filepath.Join(dir, filename)
// Check if file exists
if _, err := os.Stat(destPath); err == nil {
// Add suffix to avoid overwrite
ext := filepath.Ext(filename)
base := strings.TrimSuffix(filename, ext)
for i := 1; ; i++ {
newName := fmt.Sprintf("%s_%d%s", base, i, ext)
destPath = filepath.Join(dir, newName)
if _, err := os.Stat(destPath); os.IsNotExist(err) {
break
}
}
}
dest, err := os.Create(destPath)
if err != nil {
http.Error(w, fmt.Sprintf("创建文件失败: %v", err), http.StatusInternalServerError)
return
}
if _, err := io.Copy(dest, file); err != nil {
dest.Close()
http.Error(w, fmt.Sprintf("写入文件失败: %v", err), http.StatusInternalServerError)
return
}
dest.Close()
fmt.Fprintf(req.Stdout, "[上传] %s -> %s\n", fileHeader.Filename, destPath)
uploaded++
}
// Set content type based on Accept header
accept := r.Header.Get("Accept")
if strings.Contains(accept, "application/json") {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"success": true, "uploaded": %d}`, uploaded)
} else {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, "<html><body><h2>上传成功</h2><p>已上传 %d 个文件</p><a href='/'>返回</a></body></html>", uploaded)
}
}
// detectContentType tries to detect the MIME type of a file
func detectContentType(path string) string {
ext := filepath.Ext(path)
if ext == "" {
return "application/octet-stream"
}
mimeType := mime.TypeByExtension(ext)
if mimeType != "" {
return mimeType
}
return "application/octet-stream"
}