Skip to content

Add seafile-download.sh to mirror Seafile desktop client downloads - #203

Open
yaoge123 wants to merge 1 commit into
tuna:masterfrom
yaoge123:add-seafile-download-sh
Open

yaoge123 wants to merge 1 commit into
tuna:masterfrom
yaoge123:add-seafile-download-sh

Conversation

@yaoge123

@yaoge123 yaoge123 commented May 24, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Add seafile-download.sh to mirror Seafile desktop client downloads from https://www.seafile.com/download/.

Why

The official Seafile download page links its files on package.seafile.com (Alibaba ESA CDN in front of Aliyun OSS; it previously used seafile-downloads.oss-cn-shanghai.aliyuncs.com directly). There is no rsync, ftp, or directory listing.

How it works

  1. Fetch the download page via wget (into a mktemp file, cleaned up via trap)
  2. Parse out download URLs with Python's html.parser; resolve hrefs with urljoin against TUNASYNC_UPSTREAM_URL and accept only links whose parsed hostname is exactly package.seafile.com (http/https) — look-alike hosts and query-string matches are rejected
  3. Derive plain-basename filenames (URL-decoded; separators, NUL, .. rejected)
  4. Atomically download new/changed files via wget into a tempfile.mkstemp (O_EXCL, unpredictable name) temp file in the working dir + os.replace — a pre-planted symlink cannot redirect the download
  5. Freshness is size plus ETag: a per-file {"size", "etag"} record is kept in .seafile-download.state; a local file is skipped only when both match, so a same-size content change (e.g. seafile-android-latest.apk) is still refreshed. Files without a recorded ETag (first run with this script version) are re-downloaded once to establish state
  6. Remove leftover *.tmp partials from killed runs at the start of every run
  7. Delete stale local files only after all downloads succeed, bounded by TUNASYNC_MAX_DELETE (default 50)
  8. Skip seafile-server URLs (matched against the basename; out of scope)

Environment variables

Variable Default Meaning
TUNASYNC_WORKING_DIR (required) mirror data directory (validated, resolved to an absolute path)
TUNASYNC_UPSTREAM_URL https://www.seafile.com/download/ download page URL
TUNASYNC_MAX_DELETE 50 safety cap for stale-file deletion

Note on wget vs curl

wget is used throughout instead of curl because, in at least one tunasync Docker bridge network, curl fails to reach the package.seafile.com edge IPs while wget succeeds. The choice is deliberate and not just a stylistic preference.

Testing

Container run against the live page: 20/20 checks pass — 10 client files discovered, seafile-server correctly skipped, spider/size/ETag logic exercised. NJU production runs this version.

Deployment

Runs in the standard tunathu/tunasync-scripts image (needs wget + python3, both present). First run with this version re-downloads each file once to establish ETag state.

Copilot AI review requested due to automatic review settings May 24, 2026 09:20

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Adds a seafile-download.sh helper to mirror Seafile client downloads by scraping the official download page, downloading files via wget, and pruning stale local artifacts.

Changes:

  • Introduces a Bash script that fetches Seafile’s download page and extracts OSS links using an embedded Python parser.
  • Downloads files atomically and skips unchanged downloads via a remote size check.
  • Removes local files no longer present upstream, bounded by TUNASYNC_MAX_DELETE.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread seafile-download.sh
Comment thread seafile-download.sh Outdated
Comment thread seafile-download.sh Outdated
Comment thread seafile-download.sh Outdated
Comment thread seafile-download.sh Outdated
@yaoge123
yaoge123 force-pushed the add-seafile-download-sh branch from 939ae1d to 93d3778 Compare September 24, 2026 02:30
@yaoge123

Copy link
Copy Markdown
Contributor Author

Copilot review addressed:

  1. Path traversal via decoded URL segment — fixed: safe_basename() rejects names containing path separators, NUL, .., or empty strings after URL-decoding, before joining with WORKDIR (93d3778).
  2. Fixed /tmp path for the page file — fixed: mktemp -t seafile-page.XXXXXX.html plus a trap cleanup (93d3778).
  3. Stale files deleted before downloads complete — fixed: deletion is deferred until after every download has succeeded, so a transient failure can no longer wipe previously mirrored files (93d3778).
  4. Missing Content-Length treated as size 0 — fixed: when no valid size is available the file is always downloaded, and the downloaded size is verified against Content-Length when it is present (93d3778).
  5. Shebang indentation — not an issue: the file starts with #!/bin/bash at byte 0 (verified with xxd); the leading spaces exist only in the rendered diff.

Additionally, the OSS prefix was updated to package.seafile.com (b653848): the live download page no longer serves seafile-downloads.oss-cn-shanghai.aliyuncs.com links (verified 2026-09-25). This matches the fix already deployed on the NJU production mirror.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread seafile-download.sh Outdated
Comment on lines +42 to +44
# The download page currently links to package.seafile.com (it previously
# used seafile-downloads.oss-cn-shanghai.aliyuncs.com).
OSS_PREFIX = "package.seafile.com"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The page moved: it now links to package.seafile.com (Alibaba ESA CDN/OSS), not seafile-downloads.oss-cn-shanghai.aliyuncs.com. The PR description has been updated to say so, and the code's allowlist already matches package.seafile.com, so this is resolved without a code change.

@happyaron happyaron left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked against the live page: it currently lists 10 client files on package.seafile.com plus the seafile-server tarball (correctly skipped), and a sample file returns 200 OK directly with Content-Length, so the spider/size logic works as intended. The earlier Copilot findings look properly addressed. A few remaining points inline.

Also, the PR description still says the page links to seafile-downloads.oss-cn-shanghai.aliyuncs.com; updating it to package.seafile.com should resolve the open Copilot thread (no code change needed there).

Comment thread seafile-download.sh Outdated
# files (bounded by TUNASYNC_MAX_DELETE).
#
# wget is used (not curl or urllib) because the tunasync Docker bridge
# network can reach the Seafile AWS origin only via wget.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

package.seafile.com currently responds with Server: ESA and x-oss-* headers, i.e. Alibaba ESA CDN in front of Aliyun OSS rather than AWS. The curl-vs-wget observation may well still hold, but the comment here (and at line 112, and in the PR description) should probably not name AWS.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c04f802: the comments now say package.seafile.com is Alibaba ESA CDN in front of Aliyun OSS (the curl-vs-wget observation is kept but no longer names AWS). The PR description is updated likewise.

Comment thread seafile-download.sh Outdated

if (remote_size is not None
and os.path.exists(target)
and os.path.getsize(target) == remote_size):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Size is the only freshness signal, so a same-name file whose content changes without a size change is never refreshed. That's realistic here: seafile-android-latest.apk is a mutable name. The server already sends ETag, Last-Modified and x-oss-hash-crc64ecma; recording the ETag (or setting mtime from Last-Modified and comparing it) alongside the size would close this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in c04f802: the spider now also parses the ETag header, and a small .seafile-download.state file in the working dir records {"size", "etag"} per file. A local file is only skipped when both size and recorded ETag still match, so a same-size replacement of seafile-android-latest.apk is now refreshed; files without a recorded ETag are re-downloaded once to establish state.

Comment thread seafile-download.sh Outdated
# upstream issue cannot wipe the mirror.
local_files = [f for f in os.listdir(WORKDIR)
if os.path.isfile(os.path.join(WORKDIR, f))]
stale = [f for f in local_files if f not in remote_names and not f.endswith(".tmp")]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.tmp files are excluded from stale cleanup, but nothing else removes them either. If a run is killed mid-download (timeout, container stop), foo.msi.tmp stays in the mirror indefinitely and is publicly served. Consider removing leftover *.tmp at the start of the run, or including them in the stale set.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in c04f802: leftover *.tmp (and the new unique *.tmp.XXXXXX names) are removed at the start of every run, before any downloads, so a killed run can no longer leave partial files in the served tree.

Comment thread seafile-download.sh Outdated
oss_urls = []
for u in parser.urls:
full = u if u.startswith("http") else "https://www.seafile.com" + (u if u.startswith("/") else "/" + u)
if OSS_PREFIX in full and "seafile-server" not in full:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both checks are substring matches over the whole URL, so e.g. a link with package.seafile.com in its query string would pass. Something like urllib.parse.urlparse(full).hostname == "package.seafile.com" and checking seafile-server against the basename would be stricter. Low risk with the current page.

Minor, on line 64: resolving relative links with urllib.parse.urljoin(UPSTREAM, u) would honor TUNASYNC_UPSTREAM_URL instead of hardcoding https://www.seafile.com (no effect today, since all matching links are absolute). oss_urls also isn't deduplicated, so a link appearing twice on the page is spidered twice.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in c04f802: links are resolved with urllib.parse.urljoin(UPSTREAM, u), accepted only when urlparse(full).hostname == 'package.seafile.com' (scheme restricted to http/https), and 'seafile-server' is matched against the URL basename only.

Comment thread seafile-download.sh
exit 1
}

python3 - "$WORKDIR" "$MAX_DELETE" "$UPSTREAM" "$PAGE" <<'PY'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: other Python-based jobs in this repo are standalone .py scripts (github-release.py, anaconda.py, ...). Shelling out to wget via subprocess works the same from a .py file, and it'd be easier to lint/test than a ~130-line heredoc. The script also ignores WGET_OPTIONS, which other jobs (e.g. virtualbox.sh) pass through to wget. Maintainers' call.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy to follow the maintainers' call here: if you prefer a standalone seafile-download.py (with WGET_OPTIONS passed through to wget, like virtualbox.sh), I'll convert it. Kept as-is for this round to match the existing shell-wrapper pattern, but no objection to the .py rewrite.

@yaoge123

Copy link
Copy Markdown
Contributor Author

Re the round-2 finding on the download-host allowlist (seafile-download.sh:44 etc.): the script intentionally targets package.seafile.com only. The PR description originally named the Aliyun OSS host because the download page used it at the time of writing; the page has since moved. Verified just now against https://www.seafile.com/download/:

  • zero aliyuncs.com/OSS links anywhere in the page source
  • file downloads (e.g. seafile-server_12.0.14_x86-64.tar.gz, seadrive-2.0.26.dmg) are all served from package.seafile.com

The script's exact-hostname allowlist therefore matches what the page currently serves, and NJU production runs this version successfully. The PR description has been updated to name package.seafile.com as the supported host. No code change needed.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Unresolved URL validation, temporary-file safety, path handling, and cleanup issues remain.

Review effort: Lite
Findings: 3 High severity · 1 Medium severity

Open (4)

Comment thread seafile-download.sh Outdated
Comment on lines +64 to +66
full = u if u.startswith("http") else "https://www.seafile.com" + (u if u.startswith("/") else "/" + u)
if OSS_PREFIX in full and "seafile-server" not in full:
oss_urls.append(full)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c04f802: the check is now urllib.parse.urlparse(full).hostname == 'package.seafile.com' with the scheme restricted to http/https, so look-alike hosts or query-string matches are rejected; relative and protocol-relative links are normalized with urljoin first.

Comment thread seafile-download.sh Outdated
Comment on lines +118 to +137
tmp = target + ".tmp"

remote_size, stderr = remote_size_via_spider(url)
if remote_size is None:
# Some CDNs strip Content-Length on chunked or 302 responses; we still
# need to know whether the URL itself is reachable.
if stderr and "200 OK" not in stderr and "remote file exists" not in stderr.lower():
print(f"ERROR: spider {url}: {stderr[-200:]}", file=sys.stderr)
sys.exit(1)
# Fall through; we'll download and trust wget to validate.

if (remote_size is not None
and os.path.exists(target)
and os.path.getsize(target) == remote_size):
continue

print(f"Downloading: {name} ({remote_size} bytes)" if remote_size is not None
else f"Downloading: {name}", file=sys.stderr)
r = subprocess.run(
["wget", "-q", "--timeout=30", "--tries=3", "-O", tmp, url],

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c04f802: downloads go to a tempfile.mkstemp file created with O_EXCL in the working dir (unique, non-predictable name), and wget -O writes to that already-existing file, so a pre-planted symlink at a predictable path can no longer redirect the download. Leftover temp files are also removed at the start of each run.

Comment thread seafile-download.sh
exit 1
}

python3 - "$WORKDIR" "$MAX_DELETE" "$UPSTREAM" "$PAGE" <<'PY'

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c04f802: the shell now resolves WORKDIR with pwd -P right after cd and passes the absolute path into Python, so a relative TUNASYNC_WORKING_DIR no longer breaks joins/listing.

The official Seafile download page (www.seafile.com/download) embeds
direct download links on package.seafile.com (Alibaba ESA CDN in
front of Aliyun OSS); there is no rsync, ftp, or directory listing.
This script fetches the page with wget, parses the download URLs with
Python's html.parser, downloads new/changed files atomically, and
removes stale local files bounded by TUNASYNC_MAX_DELETE (default 50).

Key design decisions (shaped by review):

- Links are resolved with urllib.parse.urljoin against
  TUNASYNC_UPSTREAM_URL and accepted only when the parsed hostname is
  exactly package.seafile.com (scheme http/https), so look-alike
  hosts and query-string matches are rejected. Filenames are
  URL-decoded and must be plain basenames (no separators, NUL, or
  dot-dots) before being joined with WORKDIR. seafile-server tarballs
  are matched against the basename and skipped (out of scope).
- Freshness is size plus ETag: a per-file {"size", "etag"} record is
  kept in .seafile-download.state, and a local file is skipped only
  when both match, so a same-size content change (e.g.
  seafile-android-latest.apk) is still refreshed. Files without a
  recorded ETag (first run with this version) are re-downloaded once
  to establish state.
- Downloads go to a tempfile.mkstemp file (O_EXCL, unpredictable
  name) inside the working dir and are moved into place with
  os.replace; wget -O therefore cannot be redirected through a
  pre-planted symlink. Leftover *.tmp partials from killed runs are
  removed at the start of every run instead of being served forever.
- Stale-file deletion is deferred until after every download has
  succeeded, so a transient upstream or network failure cannot wipe
  previously mirrored files.
- TUNASYNC_WORKING_DIR is validated and resolved to an absolute path
  (pwd -P) before being passed into Python.
- wget is used deliberately (not curl or urllib): on the tunasync
  Docker bridge network, curl fails to reach the package.seafile.com
  edge IPs while wget succeeds.

Verified in a container against the live page: 20/20 checks pass
(10 client files found, seafile-server skipped, spider/size/ETag
logic exercised).
@yaoge123
yaoge123 force-pushed the add-seafile-download-sh branch from c04f802 to a36d7b3 Compare September 26, 2026 03:32
@yaoge123

Copy link
Copy Markdown
Contributor Author

Branch cleanup note: this branch has been squashed to a single commit, a36d7b3. All commit SHAs referenced earlier in the review threads (a41bfaa, 93d3778, b653848, c04f802) are now orphaned commits — the links still open, but please rely on the current diff, whose tree is byte-identical to the previous head c04f802.

Review items → how they were addressed (all included in the current diff)

Maintainer review (@happyaron):

  • Page host is Alibaba ESA CDN in front of Aliyun OSS, not AWS → comments and PR description now say so (the curl-vs-wget observation is kept, no AWS naming).
  • Size-only freshness misses same-size content changes (realistic for seafile-android-latest.apk) → the spider now also parses the ETag header and a .seafile-download.state file records {"size", "etag"} per file; a local file is skipped only when both match. Files without a recorded ETag are re-downloaded once to establish state.
  • Leftover .tmp files are never removed and get served → leftover *.tmp partials are removed at the start of every run.
  • Substring host check / seafile-server matched over the whole URL → links are resolved with urljoin and accepted only when urlparse(full).hostname == "package.seafile.com" (scheme http/https); seafile-server is matched against the basename only.
  • Heredoc vs standalone .py (maintainers' call) → kept as a shell wrapper for this round; happy to convert to a standalone seafile-download.py with WGET_OPTIONS pass-through if preferred.

Copilot rounds:

  • Round 1: path traversal via decoded URL segment → basename validation (separators/NUL/.. rejected); fixed /tmp page path → mktemp + trap; stale deletion before downloads complete → deferred until after every download succeeds; missing Content-Length treated as size 0 → unknown size falls through to download, downloaded size verified when Content-Length is present; shebang indentation → non-issue (byte 0, verified with xxd).
  • Round 2: description named the old Aliyun OSS host → the page moved to package.seafile.com; description updated, allowlist already matches (no code change needed).
  • Round 3: substring check accepts look-alike hosts / protocol-relative links → exact hostname match after urljoin (above); deterministic <name>.tmp path exploitable via pre-planted symlink → tempfile.mkstemp O_EXCL temp files; relative TUNASYNC_WORKING_DIR breaking Python path joins after cd → resolved to an absolute path (pwd -P) before use.

Some earlier in-thread replies described intermediate states of the branch; please rely on the current diff and this summary.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants