Skip to content

Commit e8eef29

Browse files
committed
fix(alist): created missing parent directories before upload
1 parent ef36bad commit e8eef29

3 files changed

Lines changed: 169 additions & 4 deletions

File tree

‎storage/alist/alist.go‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"net/http"
1010
"net/url"
1111
"path"
12+
"strings"
1213
"sync"
1314
"time"
1415

@@ -122,6 +123,10 @@ func (a *Alist) Save(ctx context.Context, reader io.Reader, storagePath string)
122123
}, 1000)
123124
}
124125

126+
if err := a.mkdirAll(ctx, path.Dir(candidate)); err != nil {
127+
return fmt.Errorf("failed to create parent directories: %w", err)
128+
}
129+
125130
resp, err := a.putFile(ctx, reader, candidate)
126131
if err != nil {
127132
return err
@@ -200,6 +205,90 @@ func (a *Alist) JoinStoragePath(p string) string {
200205
return path.Join(a.config.BasePath, p)
201206
}
202207

208+
// mkdirAll creates the directory and any missing parents. Alist's upload API
209+
// returns FileNotFound when the parent directory does not exist, so callers
210+
// must ensure it before uploading. Existing directories are skipped.
211+
func (a *Alist) mkdirAll(ctx context.Context, dirPath string) error {
212+
if dirPath == "" || dirPath == "/" || dirPath == "." {
213+
return nil
214+
}
215+
segments := strings.Split(strings.Trim(dirPath, "/"), "/")
216+
tokenRefreshed := false
217+
for i := range segments {
218+
current := "/" + strings.Join(segments[:i+1], "/")
219+
if a.existsPath(ctx, current) {
220+
continue
221+
}
222+
status, code, message, err := a.mkdirRequest(ctx, current)
223+
if err != nil {
224+
return err
225+
}
226+
if (status == http.StatusUnauthorized || status == http.StatusForbidden) && !tokenRefreshed {
227+
// Stale token. Token-only storage cannot refresh; otherwise
228+
// re-login once and retry this segment (the probe above runs
229+
// again first, so an already-created directory is not an error).
230+
if a.loginInfo == nil {
231+
return fmt.Errorf("failed to create directory %s: %s", current, message)
232+
}
233+
if err := a.getToken(ctx); err != nil {
234+
return fmt.Errorf("failed to refresh alist token: %w", err)
235+
}
236+
tokenRefreshed = true
237+
if a.existsPath(ctx, current) {
238+
continue
239+
}
240+
status, code, message, err = a.mkdirRequest(ctx, current)
241+
if err != nil {
242+
return err
243+
}
244+
}
245+
if status != http.StatusOK {
246+
return fmt.Errorf("failed to create directory %s: %s", current, message)
247+
}
248+
if code != http.StatusOK {
249+
// The directory may have been created by a concurrent upload
250+
// between the probe and the request.
251+
if a.existsPath(ctx, current) {
252+
continue
253+
}
254+
return fmt.Errorf("failed to create directory %s: %d, %s", current, code, message)
255+
}
256+
}
257+
return nil
258+
}
259+
260+
// mkdirRequest sends a single /api/fs/mkdir request and returns the HTTP
261+
// status, the response body code and message.
262+
func (a *Alist) mkdirRequest(ctx context.Context, p string) (int, int, string, error) {
263+
bodyBytes, err := json.Marshal(fsMkdirRequest{Path: p})
264+
if err != nil {
265+
return 0, 0, "", fmt.Errorf("failed to marshal request body: %w", err)
266+
}
267+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.baseURL+"/api/fs/mkdir", bytes.NewBuffer(bodyBytes))
268+
if err != nil {
269+
return 0, 0, "", fmt.Errorf("failed to create request: %w", err)
270+
}
271+
req.Header.Set("Authorization", a.authHeader())
272+
req.Header.Set("Content-Type", "application/json")
273+
resp, err := a.client.Do(req)
274+
if err != nil {
275+
return 0, 0, "", fmt.Errorf("failed to send request: %w", err)
276+
}
277+
defer resp.Body.Close()
278+
if resp.StatusCode != http.StatusOK {
279+
return resp.StatusCode, 0, resp.Status, nil
280+
}
281+
data, err := io.ReadAll(resp.Body)
282+
if err != nil {
283+
return 0, 0, "", fmt.Errorf("failed to read response body: %w", err)
284+
}
285+
var mkResp fsSimpleResponse
286+
if err := json.Unmarshal(data, &mkResp); err != nil {
287+
return 0, 0, "", fmt.Errorf("failed to unmarshal mkdir response: %w", err)
288+
}
289+
return resp.StatusCode, mkResp.Code, mkResp.Message, nil
290+
}
291+
203292
func (a *Alist) Exists(ctx context.Context, storagePath string) bool {
204293
return a.existsPath(ctx, a.JoinStoragePath(storagePath))
205294
}

‎storage/alist/alist_test.go‎

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,12 @@ import (
1717
// newAlistServer starts a fake alist whose login endpoint issues sequential
1818
// tokens and whose PUT endpoint rejects the given token (simulating an expired
1919
// credential) while accepting refreshed ones.
20-
func newAlistServer(t *testing.T, rejectedToken string) (*httptest.Server, *sync.Mutex, *int, *[]putRecord) {
20+
func newAlistServer(t *testing.T, rejectedToken string) (*httptest.Server, *sync.Mutex, *int, *[]putRecord, map[string]bool) {
2121
t.Helper()
2222
var mu sync.Mutex
2323
loginCount := 0
2424
tokenSeq := 0
25+
createdDirs := map[string]bool{}
2526
var puts []putRecord
2627

2728
mux := http.NewServeMux()
@@ -51,9 +52,41 @@ func newAlistServer(t *testing.T, rejectedToken string) (*httptest.Server, *sync
5152
}
5253
json.NewEncoder(w).Encode(map[string]any{"code": 200, "message": "ok"})
5354
})
55+
mux.HandleFunc("/api/fs/get", func(w http.ResponseWriter, r *http.Request) {
56+
var body struct {
57+
Path string `json:"path"`
58+
}
59+
json.NewDecoder(r.Body).Decode(&body)
60+
mu.Lock()
61+
defer mu.Unlock()
62+
exists := body.Path == "/probe" || createdDirs[body.Path]
63+
w.Header().Set("Content-Type", "application/json")
64+
if !exists {
65+
json.NewEncoder(w).Encode(map[string]any{"code": 500, "message": "object not found"})
66+
return
67+
}
68+
json.NewEncoder(w).Encode(map[string]any{"code": 200, "message": "ok", "data": map[string]any{"is_dir": true}})
69+
})
70+
mux.HandleFunc("/api/fs/mkdir", func(w http.ResponseWriter, r *http.Request) {
71+
var body struct {
72+
Path string `json:"path"`
73+
}
74+
json.NewDecoder(r.Body).Decode(&body)
75+
mu.Lock()
76+
defer mu.Unlock()
77+
if r.Header.Get("Authorization") == rejectedToken {
78+
w.Header().Set("Content-Type", "application/json")
79+
w.WriteHeader(http.StatusUnauthorized)
80+
json.NewEncoder(w).Encode(map[string]any{"code": 401, "message": "unauthorized"})
81+
return
82+
}
83+
createdDirs[body.Path] = true
84+
w.Header().Set("Content-Type", "application/json")
85+
json.NewEncoder(w).Encode(map[string]any{"code": 200, "message": "ok"})
86+
})
5487
srv := httptest.NewServer(mux)
5588
t.Cleanup(srv.Close)
56-
return srv, &mu, &loginCount, &puts
89+
return srv, &mu, &loginCount, &puts, createdDirs
5790
}
5891

5992
type putRecord struct {
@@ -66,7 +99,7 @@ type putRecord struct {
6699
// (token-1); the server rejects it, so the concurrent uploads must trigger a
67100
// second, merged login (token-2).
68101
func TestConcurrent401RetrySingleLogin(t *testing.T) {
69-
srv, mu, loginCount, putAuths := newAlistServer(t, "token-1")
102+
srv, mu, loginCount, putAuths, _ := newAlistServer(t, "token-1")
70103

71104
cfg := &storconfig.AlistStorageConfig{}
72105
cfg.Name = "probe"
@@ -123,7 +156,7 @@ func TestConcurrent401RetrySingleLogin(t *testing.T) {
123156
// A token-only storage receives 401 and must return the auth error without
124157
// attempting a login (it has no credentials to refresh with).
125158
func TestTokenOnlyNoLoginOn401(t *testing.T) {
126-
srv, mu, loginCount, _ := newAlistServer(t, "token-0")
159+
srv, mu, loginCount, _, _ := newAlistServer(t, "token-0")
127160

128161
cfg := &storconfig.AlistStorageConfig{}
129162
cfg.Name = "probe"
@@ -150,3 +183,37 @@ func TestTokenOnlyNoLoginOn401(t *testing.T) {
150183
t.Fatalf("expected no login attempts for token-only storage, got %d", *loginCount)
151184
}
152185
}
186+
187+
// Regression for issue #239: uploading when the parent directory does not
188+
// exist made alist return FileNotFound (code 500) and the task layer retried
189+
// until exhaustion. Save must create missing parent directories first.
190+
func TestSaveCreatesMissingParentDirs(t *testing.T) {
191+
srv, mu, loginCount, _, createdDirs := newAlistServer(t, "unused")
192+
193+
cfg := &storconfig.AlistStorageConfig{}
194+
cfg.Name = "probe"
195+
cfg.URL = srv.URL
196+
cfg.Username = "user"
197+
cfg.Password = "pass"
198+
cfg.BasePath = "/probe"
199+
200+
stor := &alist.Alist{}
201+
if err := stor.Init(t.Context(), cfg); err != nil {
202+
t.Fatalf("Init failed: %v", err)
203+
}
204+
205+
if err := stor.Save(t.Context(), bytes.NewReader([]byte("data")), "a/b/file.txt"); err != nil {
206+
t.Fatalf("Save failed: %v", err)
207+
}
208+
209+
mu.Lock()
210+
defer mu.Unlock()
211+
for _, dir := range []string{"/probe/a", "/probe/a/b"} {
212+
if !createdDirs[dir] {
213+
t.Fatalf("expected directory %s to be created", dir)
214+
}
215+
}
216+
if *loginCount != 1 {
217+
t.Fatalf("expected only the init login, got %d", *loginCount)
218+
}
219+
}

‎storage/alist/types.go‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,12 @@ type fsListResponse struct {
8989
Provider string `json:"provider"`
9090
} `json:"data"`
9191
}
92+
93+
type fsMkdirRequest struct {
94+
Path string `json:"path"`
95+
}
96+
97+
type fsSimpleResponse struct {
98+
Code int `json:"code"`
99+
Message string `json:"message"`
100+
}

0 commit comments

Comments
 (0)