mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-11-14 16:58:24 +00:00
## What Adds [Webshare.cz](https://webshare.cz), the Czech file host, as a release source for ebooks and audiobooks. It follows the Libgen source: disabled by default, with a settings tab for credentials and a **Test Login** button that reports whether the account is VIP. ## Why Webshare is one of the main places Czech and Slovak ebooks and audiobooks are shared, and none of the existing sources reach it. Together with the Databáze knih metadata provider (#1479), this lets Universal mode find Czech and Slovak titles end to end. ## How - **API client** (`webshare/api.py`): Webshare's XML API (`POST /api/<endpoint>/`). Login is the salted exchange the API documents: fetch the salt, send `SHA1(MD5_CRYPT(password, salt))` plus an MD5 digest. Python dropped the `crypt` module in 3.13, so md5-crypt is a small pure-Python port, checked against `openssl passwd -1`. The session token is cached per account and refreshed once when an authenticated call fails. File-level errors (`FILE_LINK_FATAL_*`) are not retried. XML is parsed with `defusedxml`. - **Search** (`webshare/source.py`): Webshare matches file names only, so the file name becomes the release title and its extension the format. Queries are title + author, then title only, or the manual query. Results are filtered to the configured `SUPPORTED_FORMATS` / `SUPPORTED_AUDIOBOOK_FORMATS`, plus `zip`/`rar` for audiobooks, since they are usually uploaded as archives. Password-protected files are skipped. The language is guessed from tags in the name (`CZ`, `SK`, `ENG`, ...), and the vote balance is a sortable column. API failures raise `SourceUnavailableError`. - **Download** (`webshare/handler.py`): resolves a time-limited link per attempt with `file_link`, then streams it into `TMP_DIR` with progress and cancel support. Large audiobook archives are not buffered in memory. - **Settings**: `WEBSHARE_ENABLED`, `WEBSHARE_USERNAME`, `WEBSHARE_PASSWORD` (a `PasswordField`, already covered by the `_PASSWORD` redaction in `genDebug.sh`), and `WEBSHARE_MAX_RESULTS`. The `docs/environment-variables.md` section was added. ## Testing - `tests/webshare/`: 31 offline tests. They cover md5-crypt vectors, password hashing, XML parsing, token reuse and re-login, non-retried file errors, availability gating, format filtering, query fallback, release mapping, language guessing, and the handler's streaming, small-file rejection, link errors and cancel. - `ruff check` and `ruff format --check` are clean. The rest of the unit suite fails the same way with and without this change: Windows-only tests on my machine. - Verified live against webshare.cz: search parsing and the salt endpoint's error path. - Still a draft: I haven't yet verified login and download with a real account. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: CaliBrain <calibrain@l4n.xyz>
176 lines
5.8 KiB
Python
176 lines
5.8 KiB
Python
"""Tests for the Webshare API client: password hashing, XML parsing, token retry."""
|
|
|
|
import hashlib
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from shelfmark.release_sources.webshare import api
|
|
from shelfmark.release_sources.webshare.api import WebshareClient, WebshareError
|
|
|
|
|
|
def _xml(body: str, status: str = "OK") -> bytes:
|
|
return f"<?xml version='1.0' encoding='UTF-8'?><response><status>{status}</status>{body}</response>".encode()
|
|
|
|
|
|
def _response(content: bytes) -> MagicMock:
|
|
response = MagicMock()
|
|
response.content = content
|
|
response.raise_for_status.return_value = None
|
|
return response
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_tokens():
|
|
WebshareClient._tokens.clear()
|
|
yield
|
|
WebshareClient._tokens.clear()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("password", "salt", "expected"),
|
|
[
|
|
# Vectors from `openssl passwd -1 -salt <salt> <password>`.
|
|
("password", "saltsalt", "$1$saltsalt$qjXMvbEw8oaL.CzflDtaK/"),
|
|
("heslo123ľ", "UtvP0QwB", "$1$UtvP0QwB$BNPMnAafFtaDfoqW.SVSy/"),
|
|
("x" * 40, "$1$abc$", "$1$abc$b/oSMeDNO7QqHD3W/TXaY0"),
|
|
],
|
|
)
|
|
def test_md5_crypt_matches_openssl(password, salt, expected):
|
|
assert api.md5_crypt(password, salt) == expected
|
|
|
|
|
|
def test_hash_password_is_sha1_of_md5crypt_and_md5_digest():
|
|
password_hash, digest = api.hash_password("user", "password", "saltsalt")
|
|
expected_hash = hashlib.sha1(b"$1$saltsalt$qjXMvbEw8oaL.CzflDtaK/").hexdigest()
|
|
assert password_hash == expected_hash
|
|
assert digest == hashlib.md5(f"user:Webshare:{expected_hash}".encode()).hexdigest()
|
|
|
|
|
|
def test_search_parses_files_and_sends_token():
|
|
calls = []
|
|
|
|
def post(url, data, **kwargs):
|
|
calls.append((url, dict(data)))
|
|
if url.endswith("/salt/"):
|
|
return _response(_xml("<salt>saltsalt</salt>"))
|
|
if url.endswith("/login/"):
|
|
return _response(_xml("<token>TOKEN1</token>"))
|
|
return _response(
|
|
_xml(
|
|
"<total>2</total>"
|
|
"<file><ident>abc123</ident><name>Kniha (CZ).epub</name><type>epub</type>"
|
|
"<size>123456</size><positive_votes>3</positive_votes>"
|
|
"<negative_votes>1</negative_votes><password>0</password></file>"
|
|
"<file><ident></ident><name>broken.epub</name></file>"
|
|
)
|
|
)
|
|
|
|
with patch.object(api.requests, "post", side_effect=post):
|
|
files = WebshareClient("user", "pw").search("kniha", limit=10)
|
|
|
|
assert [f.ident for f in files] == ["abc123"]
|
|
assert files[0].size_bytes == 123456
|
|
assert files[0].positive_votes == 3
|
|
assert files[0].info_url == "https://webshare.cz/#/file/abc123"
|
|
search_url, search_data = calls[-1]
|
|
assert search_url == "https://webshare.cz/api/search/"
|
|
assert search_data["wst"] == "TOKEN1"
|
|
assert search_data["what"] == "kniha"
|
|
|
|
|
|
def test_token_is_reused_between_calls():
|
|
logins = []
|
|
|
|
def post(url, data, **kwargs):
|
|
if url.endswith("/salt/"):
|
|
return _response(_xml("<salt>s</salt>"))
|
|
if url.endswith("/login/"):
|
|
logins.append(1)
|
|
return _response(_xml("<token>T</token>"))
|
|
return _response(_xml("<link>https://dl.example/file.epub</link>"))
|
|
|
|
with patch.object(api.requests, "post", side_effect=post):
|
|
WebshareClient("user", "pw").file_link("a")
|
|
WebshareClient("user", "pw").file_link("b")
|
|
|
|
assert len(logins) == 1
|
|
|
|
|
|
def test_expired_token_triggers_one_relogin():
|
|
tokens = iter(["OLD", "NEW"])
|
|
seen_tokens = []
|
|
|
|
def post(url, data, **kwargs):
|
|
if url.endswith("/salt/"):
|
|
return _response(_xml("<salt>s</salt>"))
|
|
if url.endswith("/login/"):
|
|
return _response(_xml(f"<token>{next(tokens)}</token>"))
|
|
seen_tokens.append(data["wst"])
|
|
if data["wst"] == "OLD":
|
|
return _response(
|
|
_xml("<code>SEARCH_FATAL_1</code><message>Bad token</message>", "FATAL")
|
|
)
|
|
return _response(_xml("<total>0</total>"))
|
|
|
|
with patch.object(api.requests, "post", side_effect=post):
|
|
assert WebshareClient("user", "pw").search("x") == []
|
|
|
|
assert seen_tokens == ["OLD", "NEW"]
|
|
|
|
|
|
def test_file_errors_are_not_retried():
|
|
link_calls = []
|
|
|
|
def post(url, data, **kwargs):
|
|
if url.endswith("/salt/"):
|
|
return _response(_xml("<salt>s</salt>"))
|
|
if url.endswith("/login/"):
|
|
return _response(_xml("<token>T</token>"))
|
|
link_calls.append(1)
|
|
return _response(
|
|
_xml("<code>FILE_LINK_FATAL_1</code><message>File not found.</message>", "FATAL")
|
|
)
|
|
|
|
with (
|
|
patch.object(api.requests, "post", side_effect=post),
|
|
pytest.raises(WebshareError) as exc_info,
|
|
):
|
|
WebshareClient("user", "pw").file_link("gone")
|
|
|
|
assert exc_info.value.code == "FILE_LINK_FATAL_1"
|
|
assert str(exc_info.value) == "File not found."
|
|
assert len(link_calls) == 1
|
|
|
|
|
|
def test_login_requires_credentials():
|
|
with pytest.raises(WebshareError):
|
|
WebshareClient("", "").login()
|
|
|
|
|
|
def test_unreadable_response_raises():
|
|
with (
|
|
patch.object(api.requests, "post", return_value=_response(b"<html>nope")),
|
|
pytest.raises(WebshareError),
|
|
):
|
|
WebshareClient("user", "pw").login()
|
|
|
|
|
|
def test_changed_password_forces_new_login():
|
|
logins = []
|
|
|
|
def post(url, data, **kwargs):
|
|
if url.endswith("/salt/"):
|
|
return _response(_xml("<salt>s</salt>"))
|
|
if url.endswith("/login/"):
|
|
logins.append(data["password"])
|
|
return _response(_xml(f"<token>T{len(logins)}</token>"))
|
|
return _response(_xml("<link>https://dl.example/file.epub</link>"))
|
|
|
|
with patch.object(api.requests, "post", side_effect=post):
|
|
WebshareClient("user", "old").file_link("a")
|
|
WebshareClient("user", "new").file_link("a")
|
|
|
|
assert len(logins) == 2
|
|
assert logins[0] != logins[1]
|