Documentation
¶
Overview ¶
Package cliclient implements the HTTP client behind `filex client` — the subcommand family that talks to a REMOTE filex server over its public REST API. It deliberately reuses only the wire contracts (no server-side imports) so the same binary can act as a pure API consumer from any machine.
upload.go — recursive (tree) upload on top of the single-file Upload core in api.go. Only existing server verbs are used: `newfolder` to materialize directories (empty ones included) and the streaming multipart `upload` for files. Symlinks are never followed — they are reported and skipped, so a cyclic link can't wedge the walk. Named pipes, sockets and devices are reported and skipped too: opening a pipe nobody writes to never returns (issue #38).
Index ¶
- Constants
- Variables
- func DefaultConfigPath() (string, error)
- func DefaultResumeDir() (string, error)
- func IsUnauthorized(err error) bool
- func SaveFileConfig(path string, cfg FileConfig) error
- type APIError
- type ChangeStream
- type Client
- func (c *Client) Changes(ctx context.Context, remote, since string) (cursor string, changed bool, err error)
- func (c *Client) Download(ctx context.Context, remote string, w io.Writer) (int64, error)
- func (c *Client) DownloadSized(ctx context.Context, remote string, w io.Writer, want int64) (int64, error)
- func (c *Client) List(ctx context.Context, remote string) (*ListResult, error)
- func (c *Client) ListStats(ctx context.Context, remote string) ([]*Stat, error)
- func (c *Client) Login(ctx context.Context, email, password, totp string) (*LoginResponse, error)
- func (c *Client) Mkdir(ctx context.Context, remote string) ([]byte, error)
- func (c *Client) Move(ctx context.Context, src, dst string) (RemotePath, []byte, error)
- func (c *Client) ReadRange(ctx context.Context, remote string, off, length int64) ([]byte, error)
- func (c *Client) Remove(ctx context.Context, remote string) ([]byte, error)
- func (c *Client) Search(ctx context.Context, query, scope string, storageID int64, limit int) (*SearchResult, error)
- func (c *Client) Share(ctx context.Context, remote string, pin bool, expiresDays int) (*ShareResult, error)
- func (c *Client) SizeOf(ctx context.Context, remote string) (int64, error)
- func (c *Client) StatPath(ctx context.Context, remote string) (*Stat, error)
- func (c *Client) Upload(ctx context.Context, localPath, remote string) (RemotePath, []byte, error)
- func (c *Client) UploadTo(ctx context.Context, localPath string, destDir RemotePath, name, expect string) ([]byte, error)
- func (c *Client) UploadTree(ctx context.Context, localDir, remote string, progress TreeProgress) (*TreeReport, error)
- type Conn
- type FileConfig
- type ListEntry
- type ListResult
- type LoginResponse
- type RateLimiter
- type RemotePath
- type SearchHit
- type SearchResult
- type ShareResult
- type SizeMismatchError
- type Stat
- type StreamState
- type TreeChange
- type TreeError
- type TreeEvent
- type TreeProgress
- type TreeReport
Constants ¶
const ( TreeDir = "dir" // remote directory ensured (created or existing) TreeFile = "file" // file uploaded TreeSymlink = "symlink" // local symlink skipped TreeSpecial = "special" // local named pipe, socket or device skipped TreeErr = "error" // one item failed (walk continues) )
Tree event kinds delivered to a TreeProgress callback.
const DefaultStagedThreshold = 8 << 20
DefaultStagedThreshold is the size at which the CLI switches from the one-shot multipart POST to the resumable staged protocol. It matches the server's default chunk size, so "large" means the same thing on both ends.
Variables ¶
var ErrChangesUnsupported = errors.New("the server cannot report changes")
ErrChangesUnsupported means the server has no `action=changes` (anything before it answers 501): the caller has to walk the tree to find out.
var ErrNoRange = errors.New("cliclient: the server ignored the requested range")
ErrNoRange means the server answered a ranged request with the whole object.
⚠ It is a real error, not a fallback: a caller that asked for bytes [1000,2000) and silently received [0,N) would write the WRONG BYTES into a file at the offset it asked about. Wrong data is worse than a refusal.
var ErrPreconditionFailed = errors.New("the file changed on the server since it was listed")
ErrPreconditionFailed is a conditional upload the server refused because the target is no longer what the caller last saw (HTTP 412, code PRECONDITION_FAILED). Nothing was written.
Functions ¶
func DefaultConfigPath ¶
DefaultConfigPath returns the CLI config location: $FILEX_CLI_CONFIG when set (tests / unusual homes), otherwise ~/.filex/cli.yaml.
func DefaultResumeDir ¶
DefaultResumeDir is where upload bookmarks live: $FILEX_UPLOAD_STATE when set (tests, unusual homes), otherwise ~/.filex/uploads.
func IsUnauthorized ¶
IsUnauthorized reports whether err is an APIError with status 401 — the CLI uses it to append the "run `filex client login`" hint.
func SaveFileConfig ¶
func SaveFileConfig(path string, cfg FileConfig) error
SaveFileConfig writes the CLI config with owner-only permissions. The explicit Chmod after the write covers a pre-existing file whose looser mode WriteFile would otherwise keep.
Types ¶
type APIError ¶
APIError is a non-2xx response mapped to an error. Body keeps the raw payload so callers can inspect extra fields (e.g. totp_required).
type ChangeStream ¶
type ChangeStream struct {
Client *Client
// OnChange receives every change frame. Called from the stream's reader
// goroutine; it must not block for long.
OnChange func(TreeChange)
// OnState receives every state transition, with a human-readable detail.
OnState func(state StreamState, detail string)
// OnConnected runs after every successful subscribe — the first one and
// every reconnect. Changes made while the stream was down were not
// announced to anybody; the caller answers this by asking what changed.
OnConnected func()
// (HTTP 401 on the ticket). The stream stops for good: retrying a
// revoked token only fills the server's log.
OnUnauthorized func(error)
// Tunables. Zero values mean the production defaults; tests shrink them.
PingEvery time.Duration // keep-alive + dead-connection detection
AckTimeout time.Duration // how long to wait for `watching`
UnsupportedRetry time.Duration // re-probe a server without the stream
MaxBackoff time.Duration // ceiling of the reconnect backoff
// contains filtered or unexported fields
}
ChangeStream follows the server's change stream for a set of watch roots.
func (*ChangeStream) Run ¶
func (s *ChangeStream) Run(ctx context.Context)
Run keeps the stream up until ctx ends: connect, subscribe, read, and on any failure back off and do it again.
func (*ChangeStream) SetRoots ¶
func (s *ChangeStream) SetRoots(roots []string)
SetRoots replaces the folders being watched. Safe to call at any time; a live connection re-subscribes at once.
func (*ChangeStream) State ¶
func (s *ChangeStream) State() StreamState
State reports the current state ("" before the first attempt).
type Client ¶
type Client struct {
BaseURL string
Token string
HTTP *http.Client
// StagedThreshold is the file size at or above which uploads use the
// resumable staged protocol instead of one multipart POST. 0 means the
// default (DefaultStagedThreshold); a negative value disables the staged
// path, which is how a caller pins the old behaviour.
StagedThreshold int64
// ChunkSize is the part size asked for at `begin`. 0 lets the server
// choose — and the server's answer is binding either way.
ChunkSize int64
// ResumeDir holds the bookmarks that let an interrupted upload continue
// across process restarts. Empty disables persistence: uploads still
// resume within a run, but a restart begins the file again.
ResumeDir string
// DownLimit and UpLimit cap download and upload bodies (bytes per
// second), shared by every transfer of this client. nil = no limit.
DownLimit *RateLimiter
UpLimit *RateLimiter
}
Client is a thin REST client for one filex server. Token may be either a session token minted by /api/auth/login or a durable API token — the server accepts both on the Authorization: Bearer header.
func New ¶
New builds a Client from a resolved Conn. No global timeout is set — uploads/downloads stream arbitrarily large bodies; cancellation is the caller's context (Ctrl-C in the CLI).
func (*Client) Changes ¶
func (c *Client) Changes(ctx context.Context, remote, since string) (cursor string, changed bool, err error)
Changes asks whether anything under the folder remote changed since the cursor since (empty = never asked; always "changed"), and returns the cursor to send next time. One request, instead of listing the whole tree.
func (*Client) Download ¶
Download streams the remote file into w and returns the byte count.
⚠ Only a body that IS the file is ever copied: a 200, or a 206 answering this request's own `bytes=0-` with the whole object. Servers from v0.20 to v0.42 answered an unranged download of a big file on a slow storage with `202 {"state":"preparing",…}`, and the old check here — anything 2xx is the file — wrote that JSON to disk under the file's name. The sync engine then saw a locally edited file and uploaded the JSON over the real one: 45 files of 70–290 MB on one deployment, unrecoverable from version history.
`bytes=0-` is the request no server version answers with 202 (a Range comes from a client already committed to a body), so the CLI never waits on a server-side copy of a file it reads exactly once anyway. Any other 2xx is still refused, and nothing of it is written.
func (*Client) DownloadSized ¶
func (c *Client) DownloadSized(ctx context.Context, remote string, w io.Writer, want int64) (int64, error)
DownloadSized is Download for a caller that knows how big the file is — the sync engine does, from the listing it planned against. want < 0 means unknown. A body of any other length is a *SizeMismatchError, which on its own would have caught the 202 above: the listing said 151,983,227 bytes and the body was 88.
func (*Client) List ¶
List returns the directory listing at remote (`adapter://rel`). An empty remote asks the server for its default view — useful only for discovering the Storages slice (adapter names).
func (*Client) Login ¶
Login exchanges email+password (and an optional TOTP code) for a session token. Call on a token-less Client.
func (*Client) Mkdir ¶
Mkdir creates the folder named by the last segment of remote inside its parent (server verb: newfolder).
func (*Client) Move ¶
Move implements Unix-mv semantics on top of the server's move/rename verbs. dst may be an existing directory (item moves into it, keeping its name) or a target path (rename, or move+rename across dirs — two wire calls, the manager API has no combined verb). Cross-adapter moves are rejected client-side; the server refuses them anyway.
func (*Client) ReadRange ¶
ReadRange fetches [off, off+length) of a remote file.
It returns the bytes actually read, which may be fewer at the end of the object. A server that answers 200 instead of 206 gets ErrNoRange rather than having its whole-object body treated as the window that was asked for.
func (*Client) Remove ¶
Remove sends the item to the server-side trash (server verb: delete — filex soft-deletes into `.filex-trash`, restorable from the panel).
func (*Client) Search ¶
func (c *Client) Search(ctx context.Context, query, scope string, storageID int64, limit int) (*SearchResult, error)
Search queries the server-side index. scope is "name", "content" or "all" ("" = server default, all). storageID 0 searches every storage.
func (*Client) Share ¶
func (c *Client) Share(ctx context.Context, remote string, pin bool, expiresDays int) (*ShareResult, error)
Share mints a public download link for remote. pin=true asks the server to generate an unlock PIN (returned once, in PIN); expiresDays > 0 sets the expiry that many days from now.
func (*Client) SizeOf ¶
SizeOf reads an object's length from a HEAD-shaped request.
It uses a one-byte ranged GET rather than HEAD: the manager endpoint answers HEAD, but a Content-Range header is the only place the TOTAL size appears in a partial response, and this is the same request the read path makes.
func (*Client) StatPath ¶
StatPath returns metadata for one path.
⚠ It answers from the PARENT's listing rather than a per-file endpoint, because the manager API has no stat verb: `action=index` on a file 404s. The listing is what the server already computes, so this costs one request and stays correct when a driver reports a size the database has not caught up with.
func (*Client) Upload ¶
Upload streams localPath to the server. remote may be a destination folder (`docs://reports/` — trailing slash or an existing dir) or a full target path (`docs://reports/renamed.pdf`); an existing remote folder wins, otherwise the last segment becomes the uploaded filename. Nothing is ever buffered in memory, and a large file is resumable — see uploadFile.
func (*Client) UploadTo ¶
func (c *Client) UploadTo(ctx context.Context, localPath string, destDir RemotePath, name, expect string) ([]byte, error)
UploadTo sends localPath to destDir/name with no destination probing and an optional overwrite precondition — the call `filex sync` makes.
expect is "" (no precondition), "none" (the target must not exist yet) or "<size>:<last_modified-ms>" (the target must still be exactly what a listing reported). A refusal comes back as ErrPreconditionFailed. An older server ignores the field and writes unconditionally — no worse than before.
⚠ No remoteIsDir probe, unlike Upload: the caller already knows the target is a file path, and the probe is a full listing round-trip in front of every file — on a server behind a CDN proxy (~0.35 s per request, measured) that was a third of the time a one-line edit took to leave the machine.
func (*Client) UploadTree ¶
func (c *Client) UploadTree(ctx context.Context, localDir, remote string, progress TreeProgress) (*TreeReport, error)
UploadTree uploads localDir recursively into remote. Empty local folders are created remotely too. Per-item failures accumulate in the report (a failed mkdir skips its subtree); only pre-flight problems (bad remote path, unreadable root) and context cancellation return a non-nil error. A plain file with -r degrades to a single upload, matching `cp -r` behavior.
type FileConfig ¶
FileConfig is the on-disk shape of ~/.filex/cli.yaml. It carries a bearer token, so SaveFileConfig always writes it owner-only (0600).
func LoadFileConfig ¶
func LoadFileConfig(path string) (FileConfig, error)
LoadFileConfig reads the CLI config. A missing file is not an error — it just yields the zero config so flags/env can still win.
type ListEntry ¶
type ListEntry struct {
Path string `json:"path"`
Basename string `json:"basename"`
Type string `json:"type"` // "file" | "dir"
Extension string `json:"extension"`
Size int64 `json:"size"`
MimeType string `json:"mime_type"`
LastModified int64 `json:"last_modified"` // Unix millis; 0 = unknown
}
ListEntry is one row of a directory listing (server FileNode shape).
type ListResult ¶
type ListResult struct {
Adapter string `json:"adapter"`
Storages []string `json:"storages"`
Dirname string `json:"dirname"`
ReadOnly bool `json:"read_only"`
Files []ListEntry `json:"files"`
Raw []byte `json:"-"`
}
ListResult is GET /api/files/manager?action=index.
type LoginResponse ¶
LoginResponse is the subset of POST /api/auth/login the CLI needs.
type RateLimiter ¶
type RateLimiter struct {
// contains filtered or unexported fields
}
RateLimiter caps a direction of traffic at a number of bytes per second.
One limiter is shared by every transfer of that direction, so the four parallel transfers of a sync run TOGETHER stay under the limit — which is the point: a nine-hour first sync saturated a server's ~18 Mbit line and everyone else using that server slowed down, and `--transfers` only changes how many files move at once, not how fast.
It paces the bodies, never the connection: HTTP/2 health pings and window updates share the connection, and a throttled connection would delay them past the client's own dead-connection timeout.
A token bucket holding one second's worth of bytes; a stream that runs ahead goes into debt and sleeps it off, so concurrent streams share the rate fairly. A nil *RateLimiter never waits.
func NewRateLimiter ¶
func NewRateLimiter(bytesPerSecond int64) *RateLimiter
NewRateLimiter returns a limiter for bytesPerSecond, or nil (no limit) for zero or less.
type RemotePath ¶
RemotePath is a parsed `adapter://relative/path` reference. Adapter is the storage name on the server; Rel is the slash-separated relative path inside it ("" = storage root).
func ParseRemotePath ¶
func ParseRemotePath(raw string) (RemotePath, error)
ParseRemotePath validates and splits an `adapter://rel/path` argument. The adapter prefix is mandatory — a bare relative path is ambiguous when the server hosts several storages. `..` segments are rejected before they ever reach the wire.
func (RemotePath) Base ¶
func (p RemotePath) Base() string
Base returns the last path segment ("" at the storage root).
func (RemotePath) Dir ¶
func (p RemotePath) Dir() RemotePath
Dir returns the parent directory (the storage root stays the root).
func (RemotePath) IsRoot ¶
func (p RemotePath) IsRoot() bool
IsRoot reports whether the path points at the storage root.
func (RemotePath) Join ¶
func (p RemotePath) Join(name string) RemotePath
Join appends one segment to the path.
func (RemotePath) String ¶
func (p RemotePath) String() string
String renders the wire form `adapter://rel` (root: `adapter://`).
type SearchHit ¶
type SearchHit struct {
ID int64 `json:"id"`
StorageID int64 `json:"storage_id"`
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
Size int64 `json:"size"`
Snippet string `json:"snippet"`
Matched string `json:"matched"` // "name" | "content" | "both"
}
SearchHit is one result row from /api/files/search (node fields plus the v0.2 content-search additions).
type SearchResult ¶
SearchResult is the /api/files/search envelope.
type ShareResult ¶
type ShareResult struct {
}
ShareResult is the nested `share` object of POST /api/files/share.
type SizeMismatchError ¶
type SizeMismatchError struct {
Remote string
Want int64 // what the listing said
Got int64 // what the server declared or sent
}
SizeMismatchError is a download whose body is not the length the caller's listing reported. When the server DECLARES the other length up front (Content-Length, or the total of a Content-Range) not one byte reaches the writer; a body that only turns out short or long at its end has reached it, and the caller — which writes to a temporary file — throws it away.
func (*SizeMismatchError) Error ¶
func (e *SizeMismatchError) Error() string
type StreamState ¶
type StreamState string
StreamState is what the stream can honestly say about itself.
const ( // StreamLive: subscribed; changes arrive as they happen. StreamLive StreamState = "connected" // StreamPolling: the server cannot announce changes (older than the // `watch` subscription, or realtime is switched off). Only the interval // poll finds them. StreamPolling StreamState = "polling" // StreamOffline: the server could not be reached; retrying with backoff. StreamOffline StreamState = "offline" )
type TreeChange ¶
TreeChange is one `tree_change` frame: something changed at or below Root. Dirs are relative to Root ("" = Root itself). Overflow means too many folders changed to name them — treat the whole root as changed.
type TreeEvent ¶
type TreeEvent struct {
Kind string // TreeDir | TreeFile | TreeSymlink | TreeSpecial | TreeErr
Local string // local path of the item
Remote RemotePath // resolved remote target (zero for symlink/local errors)
Err error // set when Kind == TreeErr
}
TreeEvent is one progress notification from UploadTree.
type TreeProgress ¶
type TreeProgress func(ev TreeEvent)
TreeProgress receives one call per processed item during UploadTree — the CLI streams these as they happen. May be nil.
type TreeReport ¶
type TreeReport struct {
Files int // files uploaded successfully
Dirs int // remote directories ensured (created or pre-existing)
Symlinks []string // local symlinks skipped (never followed)
Special []string // local named pipes, sockets and devices skipped (never opened)
Errors []TreeError // items that failed (upload, mkdir, walk)
}
TreeReport summarizes a finished UploadTree run. Errors are per-item: the walk keeps going past a failed file, and a failed mkdir skips just that subtree.