Skip to content

Commit 677d1b0

Browse files
committed
feat(backend): enhance WordPress plugin and database commands
- Bump plugin version to 1.5 and add a ping endpoint for connectivity testing. - Improve error handling in the WordPress client for export and import operations, including detailed responses for API errors. - Update export and import commands to validate file sizes and handle potential errors more gracefully. - Refactor CouchDB and MySQL/MariaDB command logic for better clarity and reliability. - Enhance UI to include connection testing for WordPress and local databases, providing user feedback on potential issues. - Streamline export and import workflows with improved progress tracking and error messages.
1 parent 5e026e3 commit 677d1b0

File tree

6 files changed

+489
-81
lines changed

6 files changed

+489
-81
lines changed

backend/db/commands.go

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,13 @@ func BuildExportCommand(p models.Profile) string {
1616
var cmd string
1717

1818
if p.DBType == models.DBTypeCouchDB {
19-
// CouchDB Logic
20-
// Note: CouchDB logic uses complex sh -c string, we assume fixed structure safe.
21-
// But p.ContainerID might need escaping?
22-
// For simplicity, we keep CouchDB logic as is, assuming alphanumeric IDs.
19+
// CouchDB Logic - backup the data directory
2320
if p.IsDocker {
24-
cmd = fmt.Sprintf(`sh -c 'DATA_DIR=$(docker inspect %s --format "{{ range .Mounts }}{{ if eq .Destination \"/opt/couchdb/data\" }}{{ .Destination }}{{ end }}{{ end }}"); if [ -z "$DATA_DIR" ]; then DATA_DIR="/opt/couchdb/data"; fi; docker exec %s tar cf - $DATA_DIR'`, p.ContainerID, p.ContainerID)
21+
// For Docker, backup /opt/couchdb/data from inside the container
22+
cmd = fmt.Sprintf("docker exec %s tar cf - /opt/couchdb/data", p.ContainerID)
2523
} else {
26-
cmd = `sh -c 'DATA_DIR=$(grep -r "database_dir" /opt/couchdb/etc/local.ini 2>/dev/null | awk "{print $3}"); if [ -z "$DATA_DIR" ]; then DATA_DIR="/var/lib/couchdb"; fi; sudo systemctl stop couchdb >&2; tar cf - $DATA_DIR; sudo systemctl start couchdb >&2'`
24+
// For native install, find and backup the data directory
25+
cmd = `sh -c 'DATA_DIR=$(grep -r "database_dir" /opt/couchdb/etc/local.ini 2>/dev/null | awk "{print \$3}"); if [ -z "$DATA_DIR" ]; then DATA_DIR="/var/lib/couchdb"; fi; tar cf - "$DATA_DIR"'`
2726
}
2827
} else if p.DBType == models.DBTypePostgreSQL {
2928
// PostgreSQL Logic
@@ -122,23 +121,33 @@ func BuildImportCommand(p models.Profile) string {
122121
}
123122

124123
} else {
125-
// MySQL/MariaDB Logic
124+
// MySQL/MariaDB Logic - try mariadb first (for MariaDB 10.5+), fallback to mysql
126125
authArgs := fmt.Sprintf("-u %s -p'%s'", p.DBUser, p.DBPassword)
127126
hostArgs := ""
128127
if !p.IsDocker {
129128
hostArgs = fmt.Sprintf("-h %s -P %s", p.DBHost, p.DBPort)
130129
}
131130

132131
if p.IsDocker {
133-
cmd = fmt.Sprintf("docker exec -i %s mysql %s %s",
134-
p.ContainerID, authArgs, p.TargetDBName)
132+
// Inside container, check which command exists
133+
cmd = fmt.Sprintf("docker exec -i %s sh -c 'if command -v mariadb >/dev/null 2>&1; then mariadb %s %s; else mysql %s %s; fi'",
134+
p.ContainerID, authArgs, p.TargetDBName, authArgs, p.TargetDBName)
135135
} else {
136-
cmd = fmt.Sprintf("mysql %s %s %s",
137-
hostArgs, authArgs, p.TargetDBName)
136+
// On host, check which command exists
137+
cmd = fmt.Sprintf("sh -c 'if command -v mariadb >/dev/null 2>&1; then mariadb %s %s %s; else mysql %s %s %s; fi'",
138+
hostArgs, authArgs, p.TargetDBName, hostArgs, authArgs, p.TargetDBName)
138139
}
139140
}
140141

141-
// Decompression Logic: Try zstd, fallback to gzip
142-
decompressCmd := "if command -v zstd >/dev/null 2>&1; then zstd -d 2>/dev/null || gunzip -c; else gunzip -c; fi"
143-
return fmt.Sprintf("{ %s; } | %s", decompressCmd, cmd)
142+
// Decompression Logic: Detect format and decompress if needed
143+
// Then prepend SET commands to disable strict mode and foreign key checks
144+
decompressCmd := `F=/tmp/dback_import_$$.dat; cat > $F;
145+
if file "$F" 2>/dev/null | grep -q "gzip"; then gunzip -c "$F";
146+
elif file "$F" 2>/dev/null | grep -q "Zstandard"; then zstd -d -c "$F";
147+
else cat "$F"; fi; rm -f "$F"`
148+
149+
// Prepend SQL settings before the actual data
150+
prependSQL := `echo "SET SESSION sql_mode=''; SET FOREIGN_KEY_CHECKS=0;"`
151+
152+
return fmt.Sprintf("{ %s; { %s; }; } | %s", prependSQL, decompressCmd, cmd)
144153
}

backend/wordpress/client.go

Lines changed: 150 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package wordpress
22

33
import (
4+
"encoding/json"
45
"fmt"
56
"io"
67
"net/http"
78
"os"
9+
"strings"
10+
"time"
811
)
912

1013
// Client handles communication with the WordPress plugin
@@ -14,44 +17,139 @@ type Client struct {
1417
Client *http.Client
1518
}
1619

20+
// WPError represents an error response from WordPress REST API
21+
type WPError struct {
22+
Code string `json:"code"`
23+
Message string `json:"message"`
24+
Data struct {
25+
Status int `json:"status"`
26+
} `json:"data"`
27+
}
28+
29+
// PingResponse represents the response from the ping endpoint
30+
type PingResponse struct {
31+
Success bool `json:"success"`
32+
Message string `json:"message"`
33+
Version string `json:"version"`
34+
PHPVersion string `json:"php_version"`
35+
WPVersion string `json:"wp_version"`
36+
CanUseShell bool `json:"can_use_shell"`
37+
DBConnected bool `json:"db_connected"`
38+
UploadDirWritable bool `json:"upload_dir_writable"`
39+
MemoryLimit string `json:"memory_limit"`
40+
MaxExecutionTime string `json:"max_execution_time"`
41+
}
42+
1743
// NewClient creates a new WordPress client
1844
func NewClient(url, key string) *Client {
45+
// Remove trailing slash from URL
46+
url = strings.TrimRight(url, "/")
1947
return &Client{
2048
BaseURL: url,
2149
Key: key,
22-
Client: &http.Client{},
50+
Client: &http.Client{
51+
Timeout: 30 * time.Minute, // Long timeout for large DB exports
52+
},
2353
}
2454
}
2555

56+
// Ping tests connectivity and returns server info
57+
func (c *Client) Ping() (*PingResponse, error) {
58+
apiURL := fmt.Sprintf("%s/wp-json/dback/v1/ping", c.BaseURL)
59+
60+
req, err := http.NewRequest("GET", apiURL, nil)
61+
if err != nil {
62+
return nil, fmt.Errorf("failed to create request: %w", err)
63+
}
64+
req.Header.Set("X-DBACK-KEY", c.Key)
65+
66+
// Use shorter timeout for ping
67+
client := &http.Client{Timeout: 30 * time.Second}
68+
resp, err := client.Do(req)
69+
if err != nil {
70+
return nil, fmt.Errorf("connection failed: %w", err)
71+
}
72+
defer resp.Body.Close()
73+
74+
body, err := io.ReadAll(resp.Body)
75+
if err != nil {
76+
return nil, fmt.Errorf("failed to read response: %w", err)
77+
}
78+
79+
// Check for error response
80+
if resp.StatusCode != http.StatusOK {
81+
var wpErr WPError
82+
if json.Unmarshal(body, &wpErr) == nil && wpErr.Code != "" {
83+
return nil, fmt.Errorf("API error [%s]: %s", wpErr.Code, wpErr.Message)
84+
}
85+
return nil, fmt.Errorf("ping failed (HTTP %d): %s", resp.StatusCode, string(body))
86+
}
87+
88+
// Parse success response
89+
var pingResp PingResponse
90+
if err := json.Unmarshal(body, &pingResp); err != nil {
91+
return nil, fmt.Errorf("invalid response format: %s", string(body))
92+
}
93+
94+
return &pingResp, nil
95+
}
96+
2697
// Export triggers a DB dump on WP and streams it to destPath
2798
func (c *Client) Export(destPath string, progressCallback func(int64)) error {
2899
// URL: /wp-json/dback/v1/export
29-
// We need to ensure BaseURL doesn't have trailing slash, and has wp-json if not provided?
30-
// Usually user provides site root. We append /wp-json/...
31-
// Let's assume BaseURL is site root.
32100
apiURL := fmt.Sprintf("%s/wp-json/dback/v1/export", c.BaseURL)
33101

34102
req, err := http.NewRequest("GET", apiURL, nil)
35103
if err != nil {
36-
return err
104+
return fmt.Errorf("failed to create request: %w", err)
37105
}
38106
req.Header.Set("X-DBACK-KEY", c.Key)
39107

40108
resp, err := c.Client.Do(req)
41109
if err != nil {
42-
return err
110+
return fmt.Errorf("request failed: %w", err)
43111
}
44112
defer resp.Body.Close()
45113

114+
// Check Content-Type first - WordPress plugin sends application/gzip for success
115+
contentType := resp.Header.Get("Content-Type")
116+
117+
// If response is JSON, it's likely an error (even with 200 status)
118+
if strings.Contains(contentType, "application/json") {
119+
body, _ := io.ReadAll(resp.Body)
120+
var wpErr WPError
121+
if json.Unmarshal(body, &wpErr) == nil && wpErr.Code != "" {
122+
return fmt.Errorf("WordPress API error [%s]: %s", wpErr.Code, wpErr.Message)
123+
}
124+
// Fallback if not a structured WP error
125+
return fmt.Errorf("unexpected JSON response (status %d): %s", resp.StatusCode, string(body))
126+
}
127+
128+
// Handle non-200 status codes
46129
if resp.StatusCode != http.StatusOK {
47130
body, _ := io.ReadAll(resp.Body)
48-
return fmt.Errorf("export failed (status %d): %s", resp.StatusCode, string(body))
131+
bodyStr := string(body)
132+
if bodyStr == "" {
133+
bodyStr = "(empty response)"
134+
}
135+
return fmt.Errorf("export failed (HTTP %d): %s", resp.StatusCode, bodyStr)
136+
}
137+
138+
// Verify we're getting gzip content (expected for successful export)
139+
if !strings.Contains(contentType, "application/gzip") && !strings.Contains(contentType, "application/octet-stream") {
140+
body, _ := io.ReadAll(resp.Body)
141+
// Limit body preview to avoid huge error messages
142+
bodyPreview := string(body)
143+
if len(bodyPreview) > 500 {
144+
bodyPreview = bodyPreview[:500] + "..."
145+
}
146+
return fmt.Errorf("unexpected Content-Type '%s'. Expected 'application/gzip'. Response: %s", contentType, bodyPreview)
49147
}
50148

51149
// Create local file
52150
out, err := os.Create(destPath)
53151
if err != nil {
54-
return err
152+
return fmt.Errorf("failed to create local file: %w", err)
55153
}
56154
defer out.Close()
57155

@@ -65,8 +163,25 @@ func (c *Client) Export(destPath string, progressCallback func(int64)) error {
65163
},
66164
}
67165

68-
_, err = io.Copy(out, pr)
69-
return err
166+
written, err := io.Copy(out, pr)
167+
if err != nil {
168+
// Clean up partial file
169+
out.Close()
170+
os.Remove(destPath)
171+
return fmt.Errorf("failed to download: %w", err)
172+
}
173+
174+
// Validate file size - a valid gzip SQL dump should be at least a few hundred bytes
175+
// 100 bytes is extremely small and likely indicates an error
176+
if written < 100 {
177+
// Read what we got to provide better error message
178+
out.Close()
179+
content, _ := os.ReadFile(destPath)
180+
os.Remove(destPath)
181+
return fmt.Errorf("export file too small (%d bytes). This usually indicates an error on the server. Content: %s", written, string(content))
182+
}
183+
184+
return nil
70185
}
71186

72187
// Import uploads a SQL dump to WP
@@ -75,40 +190,58 @@ func (c *Client) Import(sourcePath string, progressCallback func(int64)) error {
75190

76191
file, err := os.Open(sourcePath)
77192
if err != nil {
78-
return err
193+
return fmt.Errorf("failed to open source file: %w", err)
79194
}
80195
defer file.Close()
81196

82-
fileInfo, _ := file.Stat()
197+
fileInfo, err := file.Stat()
198+
if err != nil {
199+
return fmt.Errorf("failed to stat source file: %w", err)
200+
}
83201
totalSize := fileInfo.Size()
84202

85203
pr := &ProgressReader{
86204
Reader: file,
87205
Callback: func(curr int64) {
88206
if progressCallback != nil {
89-
progressCallback(curr) // % can be calc with totalSize
207+
progressCallback(curr)
90208
}
91209
},
92210
}
93211

94212
// We send raw body (application/gzip)
95213
req, err := http.NewRequest("POST", apiURL, pr)
96214
if err != nil {
97-
return err
215+
return fmt.Errorf("failed to create request: %w", err)
98216
}
99217
req.Header.Set("X-DBACK-KEY", c.Key)
100218
req.Header.Set("Content-Type", "application/gzip")
101219
req.ContentLength = totalSize // Important for streaming upload
102220

103221
resp, err := c.Client.Do(req)
104222
if err != nil {
105-
return err
223+
return fmt.Errorf("request failed: %w", err)
106224
}
107225
defer resp.Body.Close()
108226

227+
// Read response body
228+
body, _ := io.ReadAll(resp.Body)
229+
230+
// Check for JSON error response (even on 200)
231+
contentType := resp.Header.Get("Content-Type")
232+
if strings.Contains(contentType, "application/json") {
233+
var wpErr WPError
234+
if json.Unmarshal(body, &wpErr) == nil && wpErr.Code != "" {
235+
return fmt.Errorf("WordPress API error [%s]: %s", wpErr.Code, wpErr.Message)
236+
}
237+
}
238+
109239
if resp.StatusCode != http.StatusOK {
110-
body, _ := io.ReadAll(resp.Body)
111-
return fmt.Errorf("import failed (status %d): %s", resp.StatusCode, string(body))
240+
bodyStr := string(body)
241+
if bodyStr == "" {
242+
bodyStr = "(empty response)"
243+
}
244+
return fmt.Errorf("import failed (HTTP %d): %s", resp.StatusCode, bodyStr)
112245
}
113246

114247
return nil

0 commit comments

Comments
 (0)