commit f363cf43f9a46f0d7aee0047e1197bc2cd7757a0 Author: Adien Akhmad Date: Fri Feb 28 22:43:52 2025 +0700 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc74839 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Compiled binaries +*.exe +*.dll +*.so +*.dylib +*.test +*.out + +# Build output directory +bin/ +dist/ + +# Test artifacts +coverage.txt + +# Go workspace files +go.work + +# System metadata +.DS_Store +s._* +.Spotlight-V100 +.Trashes + +# Thumbnail cache +Thumbs.db + +# IDE/Editor (VS Code example) +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +*.code-workspace diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e38d545 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module singefile-server + +go 1.24 diff --git a/main.go b/main.go new file mode 100644 index 0000000..e271a6f --- /dev/null +++ b/main.go @@ -0,0 +1,228 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" +) + +// Config holds the application configuration +type Config struct { + Port string `json:"port"` + AuthToken string `json:"authToken"` + SaveBaseDir string `json:"saveBaseDir"` + DataField string `json:"dataField"` + URLField string `json:"urlField"` +} + +// Default returns a Config with default values +func (c *Config) Default() Config { + return Config{ + Port: "3600", + AuthToken: "change-me", + SaveBaseDir: "./archives", + DataField: "data", + URLField: "url", + } +} + +func main() { + // Define command-line flags + configFile := flag.String("config", "", "Path to config file (JSON)") + flag.Parse() + + // Load configuration + config := loadConfig(*configFile) + + // Create the base directory if it doesn't exist + if err := os.MkdirAll(config.SaveBaseDir, 0755); err != nil { + log.Fatalf("Failed to create archives directory: %v", err) + } + + // Set up HTTP routes + http.HandleFunc("/archive", func(w http.ResponseWriter, r *http.Request) { + handleArchive(w, r, config) + }) + + // Start the server + log.Printf("Server starting on port %s", config.Port) + log.Fatal(http.ListenAndServe(":"+config.Port, nil)) +} + +// loadConfig loads configuration from file or uses defaults +func loadConfig(configFile string) Config { + var config Config + config = config.Default() + + if configFile != "" { + file, err := os.Open(configFile) + if err != nil { + log.Printf("Failed to open config file: %v, using defaults", err) + return config + } + defer func(file *os.File) { + err := file.Close() + if err != nil { + log.Printf("Failed to close config file: %v", err) + } + }(file) + + decoder := json.NewDecoder(file) + if err := decoder.Decode(&config); err != nil { + log.Printf("Failed to parse config file: %v, using defaults", err) + return config.Default() + } + } + + // Log the configuration being used + log.Printf("Using configuration: Port=%s, SaveBaseDir=%s", config.Port, config.SaveBaseDir) + return config +} + +func handleArchive(w http.ResponseWriter, r *http.Request, config Config) { + // Check if request method is POST + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Check bearer token authentication + authHeader := r.Header.Get("Authorization") + if !validateBearerToken(authHeader, config.AuthToken) { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Parse multipart form + err := r.ParseMultipartForm(10 << 20) // 10 MB max memory + if err != nil { + http.Error(w, "Failed to parse multipart form: "+err.Error(), http.StatusBadRequest) + return + } + + // Extract URL from form + url := r.FormValue(config.URLField) + if url == "" { + http.Error(w, "URL field is required", http.StatusBadRequest) + return + } + + // Get the file from form data + file, fileHeader, err := r.FormFile(config.DataField) + if err != nil { + http.Error(w, "Failed to get file from form: "+err.Error(), http.StatusBadRequest) + return + } + defer func(file io.ReadCloser) { + err := file.Close() + if err != nil { + log.Println("Failed to close multipart form file") + } + }(file) + + // Read file content + contentBytes, err := io.ReadAll(file) + if err != nil { + http.Error(w, "Failed to read file content: "+err.Error(), http.StatusInternalServerError) + return + } + + if len(contentBytes) == 0 { + http.Error(w, "Uploaded file is empty", http.StatusBadRequest) + return + } + + // Determine the file path to save to + var filePath string + + if fileHeader != nil && fileHeader.Filename != "" { + // Use the uploaded filename + // Sanitize the filename to make it safe for filesystem + filename := sanitizeFilename(fileHeader.Filename) + filePath = filepath.Join(config.SaveBaseDir, filename) + + // Make sure the file has an .html extension if it doesn't already have one + if !strings.HasSuffix(strings.ToLower(filePath), ".html") { + filePath += ".html" + } + } else { + // Fall back to URL-based filename if no uploaded filename is available + filePath, err = getSanitizedFilePath(url, config.SaveBaseDir) + if err != nil { + http.Error(w, "Failed to process URL: "+err.Error(), http.StatusInternalServerError) + return + } + } + + // Save the content to the filesystem + if err := os.WriteFile(filePath, contentBytes, 0644); err != nil { + http.Error(w, "Failed to save file: "+err.Error(), http.StatusInternalServerError) + return + } + + // Return success response as JSON + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) // Using 201 as it's one of the expected success codes + + // Create a response struct and marshal to JSON + response := map[string]string{ + "message": "Page archived successfully", + "path": filePath, + } + + if err := json.NewEncoder(w).Encode(response); err != nil { + log.Printf("Error encoding response: %v", err) + } +} + +// validateBearerToken checks if the authorization header contains a valid bearer token +func validateBearerToken(authHeader string, authToken string) bool { + // Check if the header starts with "Bearer " + if !strings.HasPrefix(authHeader, "Bearer ") { + return false + } + + // Extract the token + token := strings.TrimPrefix(authHeader, "Bearer ") + + // Validate the token + return token == authToken +} + +// getSanitizedFilePath converts a URL into a safe flat filesystem path +func getSanitizedFilePath(url string, saveBaseDir string) (string, error) { + // Remove protocol (http:// or https://) + url = regexp.MustCompile(`^https?://`).ReplaceAllString(url, "") + + // Replace all slashes and invalid characters with underscores + url = regexp.MustCompile(`[/<>:"\\|?*]`).ReplaceAllString(url, "_") + + // Create a flat filename + fileName := url + + // Ensure the directory exists + if err := os.MkdirAll(saveBaseDir, 0755); err != nil { + return "", fmt.Errorf("failed to create directory: %w", err) + } + + // Make sure the file has an .html extension + fullPath := filepath.Join(saveBaseDir, fileName) + if !strings.HasSuffix(fullPath, ".html") { + fullPath += ".html" + } + + return fullPath, nil +} + +// sanitizeFilename cleans a filename to make it safe for filesystem storage +func sanitizeFilename(filename string) string { + // Replace any potentially unsafe characters with underscores + return regexp.MustCompile(`[/<>:"\\|?*]`).ReplaceAllString(filename, "_") +}