|
1 | 1 | package web |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "encoding/json" |
4 | 5 | "fmt" |
5 | 6 | "net/http" |
6 | 7 | "os" |
7 | 8 | "path/filepath" |
8 | 9 | "strings" |
| 10 | + "time" |
9 | 11 |
|
10 | 12 | "github.com/susamn/obsidian-web/internal/db" |
11 | 13 | "github.com/susamn/obsidian-web/internal/logger" |
@@ -801,3 +803,156 @@ func (s *Server) extractVaultIDFromPath(urlPath, prefix string) string { |
801 | 803 | path = strings.TrimSuffix(path, "/") |
802 | 804 | return path |
803 | 805 | } |
| 806 | + |
| 807 | +// CreateFileRequest represents a request to create a file or folder |
| 808 | +type CreateFileRequest struct { |
| 809 | + VaultID string `json:"vault_id"` |
| 810 | + ParentID string `json:"parent_id,omitempty"` // Optional parent folder ID |
| 811 | + Name string `json:"name"` // File or folder name |
| 812 | + IsFolder bool `json:"is_folder"` // true for folder, false for file |
| 813 | + Content string `json:"content,omitempty"` // File content (only for files) |
| 814 | +} |
| 815 | + |
| 816 | +// handleCreateFile godoc |
| 817 | +// @Summary Create a new file or folder |
| 818 | +// @Description Create a new file or folder in a vault |
| 819 | +// @Tags files |
| 820 | +// @Accept json |
| 821 | +// @Produce json |
| 822 | +// @Param request body CreateFileRequest true "Create file request" |
| 823 | +// @Success 200 {object} object{message=string,id=string,path=string} |
| 824 | +// @Failure 400 {object} ErrorResponse |
| 825 | +// @Failure 404 {object} ErrorResponse |
| 826 | +// @Failure 405 {object} ErrorResponse |
| 827 | +// @Failure 503 {object} ErrorResponse |
| 828 | +// @Router /api/v1/file/create [post] |
| 829 | +func (s *Server) handleCreateFile(w http.ResponseWriter, r *http.Request) { |
| 830 | + if r.Method != http.MethodPost { |
| 831 | + writeError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| 832 | + return |
| 833 | + } |
| 834 | + |
| 835 | + // Parse request body |
| 836 | + var req CreateFileRequest |
| 837 | + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| 838 | + writeError(w, http.StatusBadRequest, fmt.Sprintf("Invalid request body: %v", err)) |
| 839 | + return |
| 840 | + } |
| 841 | + |
| 842 | + // Validate required fields |
| 843 | + if req.VaultID == "" { |
| 844 | + writeError(w, http.StatusBadRequest, "vault_id is required") |
| 845 | + return |
| 846 | + } |
| 847 | + if req.Name == "" { |
| 848 | + writeError(w, http.StatusBadRequest, "name is required") |
| 849 | + return |
| 850 | + } |
| 851 | + |
| 852 | + // Validate vault and get services |
| 853 | + v, dbService, ok := s.validateAndGetVaultWithDB(w, req.VaultID) |
| 854 | + if !ok { |
| 855 | + return |
| 856 | + } |
| 857 | + |
| 858 | + // Build the file path |
| 859 | + var targetPath string |
| 860 | + if req.ParentID != "" { |
| 861 | + // Get parent folder entry to build path |
| 862 | + parentEntry, err := dbService.GetFileEntryByID(req.ParentID) |
| 863 | + if err != nil || parentEntry == nil { |
| 864 | + writeError(w, http.StatusNotFound, "Parent folder not found") |
| 865 | + return |
| 866 | + } |
| 867 | + if !parentEntry.IsDir { |
| 868 | + writeError(w, http.StatusBadRequest, "Parent must be a directory") |
| 869 | + return |
| 870 | + } |
| 871 | + targetPath = filepath.Join(parentEntry.Path, req.Name) |
| 872 | + } else { |
| 873 | + // Create at root |
| 874 | + targetPath = req.Name |
| 875 | + } |
| 876 | + |
| 877 | + // Security: prevent directory traversal |
| 878 | + if strings.Contains(targetPath, "..") { |
| 879 | + writeError(w, http.StatusBadRequest, "Invalid path: directory traversal not allowed") |
| 880 | + return |
| 881 | + } |
| 882 | + |
| 883 | + // Auto-add .md extension for files if not present |
| 884 | + if !req.IsFolder && !strings.HasSuffix(strings.ToLower(req.Name), ".md") { |
| 885 | + targetPath += ".md" |
| 886 | + req.Name += ".md" |
| 887 | + } |
| 888 | + |
| 889 | + // Build full filesystem path |
| 890 | + fullPath := s.buildVaultFilePath(v, targetPath) |
| 891 | + if fullPath == "" { |
| 892 | + writeError(w, http.StatusInternalServerError, "Failed to build file path") |
| 893 | + return |
| 894 | + } |
| 895 | + |
| 896 | + // Check if file/folder already exists |
| 897 | + if _, err := os.Stat(fullPath); err == nil { |
| 898 | + writeError(w, http.StatusConflict, fmt.Sprintf("%s already exists", map[bool]string{true: "Folder", false: "File"}[req.IsFolder])) |
| 899 | + return |
| 900 | + } |
| 901 | + |
| 902 | + // Create the file or folder |
| 903 | + if req.IsFolder { |
| 904 | + // Create directory |
| 905 | + if err := os.MkdirAll(fullPath, 0755); err != nil { |
| 906 | + writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create folder: %v", err)) |
| 907 | + return |
| 908 | + } |
| 909 | + } else { |
| 910 | + // Ensure parent directory exists |
| 911 | + parentDir := filepath.Dir(fullPath) |
| 912 | + if err := os.MkdirAll(parentDir, 0755); err != nil { |
| 913 | + writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create parent directory: %v", err)) |
| 914 | + return |
| 915 | + } |
| 916 | + |
| 917 | + // Create file with content |
| 918 | + content := req.Content |
| 919 | + if content == "" { |
| 920 | + content = "# " + strings.TrimSuffix(req.Name, ".md") + "\n\n" |
| 921 | + } |
| 922 | + if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil { |
| 923 | + writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create file: %v", err)) |
| 924 | + return |
| 925 | + } |
| 926 | + } |
| 927 | + |
| 928 | + // The file will be picked up by the file watcher and indexed automatically |
| 929 | + // Wait a moment for the watcher to process it |
| 930 | + time.Sleep(100 * time.Millisecond) |
| 931 | + |
| 932 | + // Try to get the new file entry from database |
| 933 | + var fileID string |
| 934 | + // Try a few times to get the file entry (watcher might take a moment) |
| 935 | + for i := 0; i < 10; i++ { |
| 936 | + entry, err := dbService.GetFileEntryByPath(targetPath) |
| 937 | + if err == nil && entry != nil { |
| 938 | + fileID = entry.ID |
| 939 | + break |
| 940 | + } |
| 941 | + time.Sleep(50 * time.Millisecond) |
| 942 | + } |
| 943 | + |
| 944 | + logger.WithFields(map[string]interface{}{ |
| 945 | + "vault_id": req.VaultID, |
| 946 | + "path": targetPath, |
| 947 | + "is_folder": req.IsFolder, |
| 948 | + "file_id": fileID, |
| 949 | + }).Info("Created file/folder") |
| 950 | + |
| 951 | + writeSuccess(w, map[string]interface{}{ |
| 952 | + "message": fmt.Sprintf("%s created successfully", map[bool]string{true: "Folder", false: "File"}[req.IsFolder]), |
| 953 | + "id": fileID, |
| 954 | + "path": targetPath, |
| 955 | + "name": req.Name, |
| 956 | + "is_folder": req.IsFolder, |
| 957 | + }) |
| 958 | +} |
0 commit comments