diff --git a/README.md b/README.md index 0f0cd77..691f457 100644 --- a/README.md +++ b/README.md @@ -148,8 +148,13 @@ daily downloads; a book whose file is already in the library is not downloaded again. Z-Library has no official API: Shelfloom uses the one its apps use, which can -change. Its domain moves too; set `SHELFLOOM_ZLIBRARY_DOMAIN` to one that -works from your server (`HTTPS_PROXY` is honoured if it needs a proxy). +change. Its domain moves too: **Settings → Z-Library** lists its published +domains together with the current list Z-Library's own API gives out, checks +which ones answer from your server, and lets you switch domain or add a new +one. Take new domains only from Z-Library's own +channels: lookalike domains exist to steal account passwords. +`SHELFLOOM_ZLIBRARY_DOMAIN` is only the starting choice (`HTTPS_PROXY` is +honoured if the server needs a proxy). ### KOReader Sync and the web reader @@ -198,7 +203,7 @@ npm run lint # gofmt + go vet, eslint | `HOMEPAGE_URL` | | Foyer address for the sidebar link | | `SHELFLOOM_ZLIBRARY_EMAIL` | | Z-Library account email | | `SHELFLOOM_ZLIBRARY_PASSWORD` | | Z-Library account password | -| `SHELFLOOM_ZLIBRARY_DOMAIN` | `z-library.ec` | Z-Library domain (or base URL) | +| `SHELFLOOM_ZLIBRARY_DOMAIN` | `z-library.hn` | Z-Library domain until one is picked in Settings | | `TZ` | | Time zone stats are grouped in | Databases from the earlier Python-based releases open as they are, as long as diff --git a/cmd/shelfloom/main.go b/cmd/shelfloom/main.go index ada4b8e..eb84229 100644 --- a/cmd/shelfloom/main.go +++ b/cmd/shelfloom/main.go @@ -66,7 +66,7 @@ func main() { ScanInterval: seconds("SHELFLOOM_SCAN_INTERVAL", 300), SerialCheckInterval: seconds("SHELFLOOM_SERIAL_CHECK_INTERVAL", 86400), FoyerURL: os.Getenv("HOMEPAGE_URL"), - ZLibraryDomain: env("SHELFLOOM_ZLIBRARY_DOMAIN", "z-library.ec"), + ZLibraryDomain: env("SHELFLOOM_ZLIBRARY_DOMAIN", "z-library.hn"), ZLibraryEmail: os.Getenv("SHELFLOOM_ZLIBRARY_EMAIL"), ZLibraryPassword: os.Getenv("SHELFLOOM_ZLIBRARY_PASSWORD"), }, diff --git a/frontend/src/__tests__/ZLibraryModal.test.tsx b/frontend/src/__tests__/ZLibraryModal.test.tsx index 8bf8357..f0d932b 100644 --- a/frontend/src/__tests__/ZLibraryModal.test.tsx +++ b/frontend/src/__tests__/ZLibraryModal.test.tsx @@ -47,7 +47,7 @@ describe('ZLibraryModal', () => { if (u === '/api/zlibrary/status') { body = { enabled: true, - domain: 'z-library.ec', + domain: 'z-library.hn', downloads_today: 3, downloads_limit: 10, error: null, diff --git a/frontend/src/__tests__/ZLibrarySettings.test.tsx b/frontend/src/__tests__/ZLibrarySettings.test.tsx new file mode 100644 index 0000000..60896d6 --- /dev/null +++ b/frontend/src/__tests__/ZLibrarySettings.test.tsx @@ -0,0 +1,153 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import ZLibrarySettings from '../components/settings/ZLibrarySettings' + +describe('ZLibrarySettings', () => { + let fetchSpy: { + mockRestore: () => void + mock: { calls: [RequestInfo | URL, RequestInit | undefined][] } + } + let state: { + selected: string + default: string + enabled: boolean + domains: { domain: string; source: 'official' | 'zlibrary' | 'custom' }[] + fetched_from: string | null + fetched_at: number | null + } + + beforeEach(() => { + state = { + selected: 'z-library.hn', + default: 'z-library.hn', + enabled: true, + domains: [ + { domain: 'z-library.sk', source: 'official' }, + { domain: 'z-library.hn', source: 'official' }, + ], + fetched_from: null, + fetched_at: null, + } + fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((url, opts) => { + const u = url.toString() + const method = (opts?.method ?? 'GET').toUpperCase() + const sent = opts?.body ? JSON.parse(opts.body as string) : {} + let body: unknown = state + let status = 200 + if (u.startsWith('/api/zlibrary/domains/check?')) { + const d = new URL(u, 'http://x').searchParams.get('domain')! + body = + d === 'z-library.sk' + ? { domain: d, ok: false, ms: 0, error: 'behind a bot check' } + : { domain: d, ok: true, ms: 420, error: null } + } else if (u === '/api/zlibrary/domains/refresh') { + state = { + ...state, + domains: [ + ...state.domains, + { domain: 'articles.sk', source: 'zlibrary' }, + ], + fetched_from: 'z-library.hn', + fetched_at: 1791700000, + } + body = state + } else if (u === '/api/zlibrary/domain' && method === 'PUT') { + state = { ...state, selected: sent.domain } + body = state + } else if (u === '/api/zlibrary/domains' && method === 'POST') { + status = 201 + state = { + ...state, + domains: [ + ...state.domains, + { domain: sent.domain, source: 'custom' }, + ], + } + body = state + } else if ( + u.startsWith('/api/zlibrary/domains/') && + method === 'DELETE' + ) { + state = { + ...state, + domains: state.domains.filter((d) => d.source !== 'custom'), + } + body = state + } + return Promise.resolve({ + ok: true, + status, + json: async () => body, + }) as Promise + }) as unknown as typeof fetchSpy + }) + + afterEach(() => { + fetchSpy.mockRestore() + }) + + it('checks every domain and shows which one is in use', async () => { + render() + expect( + (await screen.findAllByText('Works · 420 ms')).length + ).toBeGreaterThan(0) + expect(screen.getByText('behind a bot check')).toBeInTheDocument() + expect( + screen.getByRole('radio', { name: 'Use z-library.hn' }) + ).toHaveAttribute('aria-checked', 'true') + }) + + it('adds Z-Library’s own domain list and checks the new ones', async () => { + render() + expect(await screen.findByText('articles.sk')).toBeInTheDocument() + expect(screen.getByText('FROM Z-LIBRARY')).toBeInTheDocument() + expect(screen.getByTestId('zlibrary-fetched')).toHaveTextContent( + 'from z-library.hn' + ) + await waitFor(() => + expect( + fetchSpy.mock.calls.some(([u]) => + u.toString().includes('check?domain=articles.sk') + ) + ).toBe(true) + ) + }) + + it('switches, adds and removes domains', async () => { + render() + fireEvent.click( + await screen.findByRole('radio', { name: 'Use z-library.sk' }) + ) + await waitFor(() => + expect( + screen.getByRole('radio', { name: 'Use z-library.sk' }) + ).toHaveAttribute('aria-checked', 'true') + ) + + fireEvent.change(screen.getByLabelText('New domain'), { + target: { value: 'libb.example' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Add' })) + expect(await screen.findByText('libb.example')).toBeInTheDocument() + await waitFor(() => + expect( + fetchSpy.mock.calls.some(([u]) => + u.toString().includes('check?domain=libb.example') + ) + ).toBe(true) + ) + + fireEvent.click(screen.getByRole('button', { name: 'Remove libb.example' })) + await waitFor(() => + expect(screen.queryByText('libb.example')).not.toBeInTheDocument() + ) + }) + + it('says how to turn Z-Library on when no account is set', async () => { + state.enabled = false + render() + expect( + await screen.findByText(/Z-Library is off until an account is set/) + ).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/library/ZLibraryModal.tsx b/frontend/src/components/library/ZLibraryModal.tsx index 4abaa9e..440c975 100644 --- a/frontend/src/components/library/ZLibraryModal.tsx +++ b/frontend/src/components/library/ZLibraryModal.tsx @@ -278,6 +278,14 @@ export default function ZLibraryModal({ onClose, onAdded }: Props) { {error && (

{error} + {/domain|reach/i.test(error) && ( + <> + {' '} + + Change domain in Settings + + + )}

)} {notice && ( diff --git a/frontend/src/components/settings/ZLibrarySettings.tsx b/frontend/src/components/settings/ZLibrarySettings.tsx new file mode 100644 index 0000000..92c8d0f --- /dev/null +++ b/frontend/src/components/settings/ZLibrarySettings.tsx @@ -0,0 +1,246 @@ +import { useCallback, useEffect, useState } from 'react' +import { Check, Loader2, Plus, RefreshCw, Trash2 } from 'lucide-react' +import { api } from '../../api/client' +import type { ZLibraryDomainCheck, ZLibraryDomains } from '../../types/api' + +const input = + 'w-full border border-white/25 bg-black px-3 py-2 text-sm text-white focus:border-primary focus:outline-none' + +type CheckState = ZLibraryDomainCheck | 'checking' + +function errorText(err: unknown, fallback: string) { + return (err as { data?: { detail?: string } }).data?.detail ?? fallback +} + +/** Settings: pick the Z-Library domain, check which ones answer, add more. */ +export default function ZLibrarySettings() { + const [data, setData] = useState(null) + const [checks, setChecks] = useState>({}) + const [newDomain, setNewDomain] = useState('') + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + const check = useCallback((domain: string) => { + setChecks((c) => ({ ...c, [domain]: 'checking' })) + api + .get( + `/api/zlibrary/domains/check?domain=${encodeURIComponent(domain)}` + ) + .then((res) => { + if (res) setChecks((c) => ({ ...c, [domain]: res })) + }) + .catch(() => + setChecks((c) => ({ + ...c, + [domain]: { domain, ok: false, ms: 0, error: 'Check failed' }, + })) + ) + }, []) + + const checkAll = useCallback( + (d: ZLibraryDomains) => d.domains.forEach((x) => check(x.domain)), + [check] + ) + + useEffect(() => { + api + .get('/api/zlibrary/domains') + .then((d) => { + if (!d || !Array.isArray(d.domains)) return + setData(d) + checkAll(d) + // Then bring in Z-Library's current list, checking what's new. + const known = new Set(d.domains.map((x) => x.domain)) + api + .post('/api/zlibrary/domains/refresh') + .then((fresh) => { + if (!fresh || !Array.isArray(fresh.domains)) return + setData(fresh) + fresh.domains + .filter((x) => !known.has(x.domain)) + .forEach((x) => check(x.domain)) + }) + .catch(() => { + // No domain answered: the stored list stays. + }) + }) + .catch(() => setError('Could not load the Z-Library domains')) + }, [check, checkAll]) + + const run = async (fn: () => Promise) => { + setBusy(true) + setError(null) + try { + const d = await fn() + if (d) setData(d) + return d + } catch (err) { + setError(errorText(err, 'Something went wrong')) + return null + } finally { + setBusy(false) + } + } + + const select = (domain: string) => + run(() => api.put('/api/zlibrary/domain', { domain })) + + const remove = (domain: string) => + run(() => + api.delete( + `/api/zlibrary/domains/${encodeURIComponent(domain)}` + ) + ) + + const add = async (e: React.FormEvent) => { + e.preventDefault() + if (!newDomain.trim()) return + const before = new Set(data?.domains.map((d) => d.domain)) + const d = await run(() => + api.post('/api/zlibrary/domains', { + domain: newDomain.trim(), + }) + ) + if (d) { + setNewDomain('') + d.domains + .filter((x) => !before.has(x.domain)) + .forEach((x) => check(x.domain)) + } + } + + if (!data) { + return error ? ( +

{error}

+ ) : ( + + ) + } + + return ( +
+ {!data.enabled && ( +

+ Z-Library is off until an account is set:{' '} + SHELFLOOM_ZLIBRARY_EMAIL and{' '} + SHELFLOOM_ZLIBRARY_PASSWORD. +

+ )} + +
+
+

+ DOMAIN +

+ +
+
    + {data.domains.map((d) => { + const selected = d.domain === data.selected + const state = checks[d.domain] + return ( +
  • + +
    +

    + {d.domain} + {selected && ( + + IN USE + + )} +

    +

    + {state === 'checking' || !state + ? 'Checking…' + : state.ok + ? `Works · ${state.ms} ms` + : state.error} +

    +
    + {d.source !== 'custom' ? ( + + {d.source === 'official' ? 'OFFICIAL' : 'FROM Z-LIBRARY'} + + ) : ( + + )} +
  • + ) + })} +
+
+ + {data.fetched_from && data.fetched_at && ( +

+ Z-Library’s own domain list, from {data.fetched_from},{' '} + {new Date(data.fetched_at * 1000).toLocaleString()}. +

+ )} + +
+ setNewDomain(e.target.value)} + placeholder="New domain, e.g. z-library.example" + aria-label="New domain" + autoComplete="off" + autoCapitalize="none" + className={input} + /> + +
+ {error &&

{error}

} +

+ Z-Library moves domains now and then. When search stops working, pick + one marked “Works”, or add a new one from Z-Library’s latest list. +

+
+ ) +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 8bb13b5..c90e768 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -19,6 +19,7 @@ import { useApi } from '../hooks/useApi' import { api } from '../api/client' import ShelfModal from '../components/settings/ShelfModal' import KoreaderSync from '../components/settings/KoreaderSync' +import ZLibrarySettings from '../components/settings/ZLibrarySettings' import type { Shelf, OrganizerResult, @@ -791,10 +792,22 @@ export default function Settings() { - {/* ── 05 Data Management ── */} + {/* ── 05 Z-Library ── */}
+
+ +
+
+ + {/* ── 06 Data Management ── */} +
+ diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index aa8a3d2..df47eae 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -545,3 +545,19 @@ export interface ZLibraryAddResponse { series: string | null series_sequence: string | null } + +export interface ZLibraryDomains { + selected: string + default: string + enabled: boolean + domains: { domain: string; source: 'official' | 'zlibrary' | 'custom' }[] + fetched_from: string | null + fetched_at: number | null +} + +export interface ZLibraryDomainCheck { + domain: string + ok: boolean + ms: number + error: string | null +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 4dd038e..819208b 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -137,6 +137,12 @@ func (s *Server) routes() []route { {"GET /api/zlibrary/search", s.zlibSearchHandler}, {"GET /api/zlibrary/books/{zlib_id}/cover", s.zlibCoverHandler}, {"POST /api/zlibrary/books", s.zlibAddHandler}, + {"GET /api/zlibrary/domains", s.zlibDomainsHandler}, + {"POST /api/zlibrary/domains", s.zlibAddDomainHandler}, + {"POST /api/zlibrary/domains/refresh", s.zlibRefreshDomainsHandler}, + {"PUT /api/zlibrary/domain", s.zlibSelectDomainHandler}, + {"DELETE /api/zlibrary/domains/{domain}", s.zlibRemoveDomainHandler}, + {"GET /api/zlibrary/domains/check", s.zlibCheckDomainHandler}, // KOReader sync {"POST /api/kosync/users/create", s.kosyncCreateUserHandler}, diff --git a/internal/server/zlibrary.go b/internal/server/zlibrary.go index 5f0b582..c6a62aa 100644 --- a/internal/server/zlibrary.go +++ b/internal/server/zlibrary.go @@ -35,7 +35,7 @@ func (s *Server) zlib() *zlibrary.Client { s.zl.once.Do(func() { c := s.Config if c.ZLibraryEmail != "" && c.ZLibraryPassword != "" { - s.zl.client = zlibrary.New(c.ZLibraryDomain, c.ZLibraryEmail, c.ZLibraryPassword) + s.zl.client = zlibrary.New(s.zlibDomain(context.Background()), c.ZLibraryEmail, c.ZLibraryPassword) } }) return s.zl.client @@ -82,7 +82,7 @@ func zlibError(err error) error { } func (s *Server) zlibStatusHandler(w http.ResponseWriter, r *http.Request) error { - out := map[string]any{"enabled": false, "domain": s.Config.ZLibraryDomain, "downloads_today": nil, "downloads_limit": nil, "error": nil} + out := map[string]any{"enabled": false, "domain": s.zlibDomain(r.Context()), "downloads_today": nil, "downloads_limit": nil, "error": nil} c := s.zlib() if c == nil { return ok(w, out) diff --git a/internal/server/zlibrary_domains.go b/internal/server/zlibrary_domains.go new file mode 100644 index 0000000..addd2bb --- /dev/null +++ b/internal/server/zlibrary_domains.go @@ -0,0 +1,377 @@ +package server + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "net/http" + "net/url" + "regexp" + "slices" + "strings" + "time" + + "github.com/audemed44/shelfloom/internal/zlibrary" +) + +// zlibBuiltinDomains are Z-Library's published main domains and mirror +// (October 2026). Lookalike domains phish for accounts: only official +// ones belong here. +var zlibBuiltinDomains = []string{ + "z-lib.sk", + "z-library.im", + "z-library.sk", + "z-library.hn", + "libb.la", +} + +const ( + settingZLibDomain = "zlibrary_domain" + settingZLibDomains = "zlibrary_custom_domains" + settingZLibAPIDomains = "zlibrary_api_domains" +) + +// Where a domain in the list comes from. +const ( + domainOfficial = "official" // built in + domainZLibrary = "zlibrary" // from Z-Library's own domain list + domainCustom = "custom" // added in Settings +) + +// zlibAPIDomains is the last domain list fetched from Z-Library. +type zlibAPIDomains struct { + Domains []string `json:"domains"` + From string `json:"from"` + FetchedAt time.Time `json:"fetched_at"` +} + +func (s *Server) apiZLibDomains(ctx context.Context) (zlibAPIDomains, error) { + var out zlibAPIDomains + _, err := s.getSetting(ctx, settingZLibAPIDomains, &out) + return out, err +} + +// getSetting decodes a setting into out; false when it isn't set. +func (s *Server) getSetting(ctx context.Context, key string, out any) (bool, error) { + var raw string + err := s.DB.QueryRowContext(ctx, "SELECT value FROM settings WHERE key = ?", key).Scan(&raw) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, err + } + return true, json.Unmarshal([]byte(raw), out) +} + +func (s *Server) putSetting(ctx context.Context, key string, value any) error { + raw, err := json.Marshal(value) + if err != nil { + return err + } + _, err = s.DB.ExecContext(ctx, "INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value", key, string(raw)) + return err +} + +// defaultZLibDomain is the domain from the environment, or the first +// built-in one. +func (s *Server) defaultZLibDomain() string { + if d, err := normalizeDomain(s.Config.ZLibraryDomain); err == nil { + return d + } + return zlibBuiltinDomains[0] +} + +// zlibDomain is the domain picked in Settings, else the default. +func (s *Server) zlibDomain(ctx context.Context) string { + var d string + if found, err := s.getSetting(ctx, settingZLibDomain, &d); err == nil && found && d != "" { + return d + } + return s.defaultZLibDomain() +} + +func (s *Server) customZLibDomains(ctx context.Context) ([]string, error) { + var out []string + if _, err := s.getSetting(ctx, settingZLibDomains, &out); err != nil { + return nil, err + } + return out, nil +} + +var domainPattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*(:\d{1,5})?$`) + +// normalizeDomain turns "https://Z-Library.hn/" into "z-library.hn". A +// plain-http address keeps its scheme. +func normalizeDomain(in string) (string, error) { + in = strings.TrimSpace(in) + if in == "" { + return "", errors.New("empty") + } + scheme := "https" + if strings.Contains(in, "://") { + u, err := url.Parse(in) + if err != nil || u.Host == "" { + return "", errors.New("not a domain") + } + scheme, in = strings.ToLower(u.Scheme), u.Host + } else if i := strings.IndexAny(in, "/?#"); i >= 0 { + in = in[:i] + } + in = strings.ToLower(strings.TrimSuffix(in, ".")) + if !domainPattern.MatchString(in) || (!strings.Contains(in, ".") && !strings.Contains(in, ":")) { + return "", errors.New("not a domain") + } + switch scheme { + case "https": + return in, nil + case "http": + return "http://" + in, nil + } + return "", errors.New("only http and https addresses work") +} + +type zlibDomainOut struct { + Domain string `json:"domain"` + Source string `json:"source"` +} + +// zlibDomainList is every domain on offer: official ones, then Z-Library's +// own list, then the user's. +func (s *Server) zlibDomainList(ctx context.Context) ([]zlibDomainOut, zlibAPIDomains, error) { + custom, err := s.customZLibDomains(ctx) + if err != nil { + return nil, zlibAPIDomains{}, err + } + fetched, err := s.apiZLibDomains(ctx) + if err != nil { + return nil, zlibAPIDomains{}, err + } + domains := []zlibDomainOut{} + seen := map[string]bool{} + add := func(d, source string) { + if !seen[d] { + seen[d] = true + domains = append(domains, zlibDomainOut{Domain: d, Source: source}) + } + } + for _, d := range zlibBuiltinDomains { + add(d, domainOfficial) + } + add(s.defaultZLibDomain(), domainOfficial) + for _, d := range fetched.Domains { + add(d, domainZLibrary) + } + for _, d := range custom { + add(d, domainCustom) + } + return domains, fetched, nil +} + +func (s *Server) zlibDomainsResponse(ctx context.Context) (map[string]any, error) { + domains, fetched, err := s.zlibDomainList(ctx) + if err != nil { + return nil, err + } + out := map[string]any{ + "selected": s.zlibDomain(ctx), + "default": s.defaultZLibDomain(), + "enabled": s.zlib() != nil, + "domains": domains, + "fetched_from": nil, + "fetched_at": nil, + } + if fetched.From != "" { + out["fetched_from"] = fetched.From + out["fetched_at"] = fetched.FetchedAt.Unix() + } + return out, nil +} + +// zlibRefreshDomainsHandler fetches Z-Library's current domain list, asking +// the domain in use first and then the others until one answers. +func (s *Server) zlibRefreshDomainsHandler(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + domains, _, err := s.zlibDomainList(ctx) + if err != nil { + return err + } + order := []string{s.zlibDomain(ctx)} + for _, d := range domains { + if d.Domain != order[0] { + order = append(order, d.Domain) + } + } + var lastErr error + for _, from := range order { + tctx, cancel := context.WithTimeout(ctx, 10*time.Second) + list, err := zlibrary.New(from, "", "").Domains(tctx) + cancel() + if err != nil { + lastErr = err + continue + } + var clean []string + for _, d := range list { + if n, err := normalizeDomain(d); err == nil && !slices.Contains(clean, n) { + clean = append(clean, n) + } + } + if err := s.putSetting(ctx, settingZLibAPIDomains, zlibAPIDomains{Domains: clean, From: from, FetchedAt: time.Now().UTC()}); err != nil { + return err + } + return s.zlibDomainsHandler(w, r) + } + return errStatus(http.StatusBadGateway, "No Z-Library domain answered: "+lastErr.Error()) +} + +func (s *Server) zlibDomainsHandler(w http.ResponseWriter, r *http.Request) error { + out, err := s.zlibDomainsResponse(r.Context()) + if err != nil { + return err + } + return ok(w, out) +} + +// zlibKnownDomain reports whether d is in the list. +func (s *Server) zlibKnownDomain(ctx context.Context, d string) (bool, error) { + domains, _, err := s.zlibDomainList(ctx) + if err != nil { + return false, err + } + return slices.ContainsFunc(domains, func(x zlibDomainOut) bool { return x.Domain == d }), nil +} + +func (s *Server) selectZLibDomain(ctx context.Context, d string) error { + if err := s.putSetting(ctx, settingZLibDomain, d); err != nil { + return err + } + if c := s.zlib(); c != nil { + c.SetDomain(d) + } + return nil +} + +func (s *Server) zlibSelectDomainHandler(w http.ResponseWriter, r *http.Request) error { + body, err := readBody(r, false) + if err != nil { + return err + } + raw := body.Str("domain", true, false) + if err := body.err(); err != nil { + return err + } + ctx := r.Context() + d, err := normalizeDomain(*raw) + if err != nil { + return badRequest("“%s” is not a domain", *raw) + } + if known, err := s.zlibKnownDomain(ctx, d); err != nil { + return err + } else if !known { + return notFound("%s is not in the list; add it first", d) + } + if err := s.selectZLibDomain(ctx, d); err != nil { + return err + } + return s.zlibDomainsHandler(w, r) +} + +func (s *Server) zlibAddDomainHandler(w http.ResponseWriter, r *http.Request) error { + body, err := readBody(r, false) + if err != nil { + return err + } + raw := body.Str("domain", true, false) + if err := body.err(); err != nil { + return err + } + ctx := r.Context() + d, err := normalizeDomain(*raw) + if err != nil { + return badRequest("“%s” is not a domain", *raw) + } + if known, err := s.zlibKnownDomain(ctx, d); err != nil { + return err + } else if known { + return conflict("%s is already in the list", d) + } + custom, err := s.customZLibDomains(ctx) + if err != nil { + return err + } + if err := s.putSetting(ctx, settingZLibDomains, append(custom, d)); err != nil { + return err + } + out, err := s.zlibDomainsResponse(ctx) + if err != nil { + return err + } + return created(w, out) +} + +func (s *Server) zlibRemoveDomainHandler(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + d, err := normalizeDomain(r.PathValue("domain")) + if err != nil { + return notFound("Domain not found") + } + custom, err := s.customZLibDomains(ctx) + if err != nil { + return err + } + i := slices.Index(custom, d) + if i < 0 { + return notFound("%s is not one of your domains", d) + } + if err := s.putSetting(ctx, settingZLibDomains, slices.Delete(custom, i, i+1)); err != nil { + return err + } + if s.zlibDomain(ctx) == d { + if err := s.selectZLibDomain(ctx, s.defaultZLibDomain()); err != nil { + return err + } + } + return s.zlibDomainsHandler(w, r) +} + +// zlibCheckDomainHandler runs a one-result search on a domain to see whether +// its app API answers from this server. +func (s *Server) zlibCheckDomainHandler(w http.ResponseWriter, r *http.Request) error { + q := newQuery(r) + raw := q.Required("domain") + if err := q.err(); err != nil { + return err + } + d, err := normalizeDomain(raw) + if err != nil { + return badRequest("“%s” is not a domain", raw) + } + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + probe := zlibrary.New(d, "", "") + start := time.Now() + _, err = probe.Search(ctx, zlibrary.Query{Text: "dune", Limit: 1}) + // Any reply from the API itself (even an error) means it answers. + var apiErr *zlibrary.APIError + if errors.As(err, &apiErr) { + err = nil + } + out := map[string]any{"domain": d, "ok": err == nil, "ms": time.Since(start).Milliseconds(), "error": nil} + if err != nil { + msg := err.Error() + switch { + case errors.Is(err, context.DeadlineExceeded): + msg = "No answer within 15 seconds" + case strings.Contains(msg, "bot check"): + msg = "Behind a bot check" + case strings.Contains(msg, "could not reach"): + msg = "Can't reach it from this server" + case strings.Contains(msg, "unexpected reply"): + msg = "Doesn't answer like Z-Library" + } + out["error"] = msg + } + return ok(w, out) +} diff --git a/internal/server/zlibrary_test.go b/internal/server/zlibrary_test.go index 4ba42f9..1ac9cbf 100644 --- a/internal/server/zlibrary_test.go +++ b/internal/server/zlibrary_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" "strings" @@ -33,6 +34,12 @@ func fakeZLib(t *testing.T, file []byte) (*httptest.Server, *int) { return } reply(map[string]any{"success": 1, "user": map[string]any{"id": 7, "remix_userkey": "key"}}) + case "/eapi/info/domains": + reply(map[string]any{"success": 1, "domains": []map[string]any{ + {"domain": "mirror.example", "contentAvailable": true}, + {"domain": "dead.example", "contentAvailable": false}, + {"domain": "z-library.sk", "contentAvailable": true}, + }}) case "/eapi/user/profile": if !loggedIn { reply(map[string]any{"success": 0, "error": "Please login"}) @@ -197,3 +204,99 @@ func TestBookFileName(t *testing.T) { } } } + +func TestNormalizeDomain(t *testing.T) { + for in, want := range map[string]string{ + "z-library.hn": "z-library.hn", + " https://Z-Library.HN/ ": "z-library.hn", + "libb.la/some/page": "libb.la", + "http://127.0.0.1:8399": "http://127.0.0.1:8399", + "https://z-lib.sk/?a=b#c": "z-lib.sk", + "localhost:9000": "localhost:9000", + } { + if got, err := normalizeDomain(in); err != nil || got != want { + t.Errorf("normalizeDomain(%q) = %q, %v; want %q", in, got, err, want) + } + } + for _, in := range []string{"", "nodot", "ftp://x.org", "bad domain.com", "-x.com"} { + if got, err := normalizeDomain(in); err == nil { + t.Errorf("normalizeDomain(%q) = %q, want an error", in, got) + } + } +} + +func TestZLibraryDomains(t *testing.T) { + file, _ := os.ReadFile("testdata/test.epub") + zl, _ := fakeZLib(t, file) + s := newTestServer(t) + s.app.Config.ZLibraryDomain = "z-library.hn" + s.app.Config.ZLibraryEmail = "me@example.com" + s.app.Config.ZLibraryPassword = "secret" + + type list struct { + Selected string `json:"selected"` + Domains []zlibDomainOut `json:"domains"` + } + var l list + expect(t, "list", s.do("GET", "/api/zlibrary/domains", nil, &l), 200) + if l.Selected != "z-library.hn" || len(l.Domains) != len(zlibBuiltinDomains) || l.Domains[0].Source != domainOfficial { + t.Fatalf("list = %+v", l) + } + expect(t, "select unknown", s.do("PUT", "/api/zlibrary/domain", map[string]string{"domain": "example.org"}, nil), 404) + expect(t, "add bad", s.do("POST", "/api/zlibrary/domains", map[string]string{"domain": "not a domain"}, nil), 400) + expect(t, "add built-in", s.do("POST", "/api/zlibrary/domains", map[string]string{"domain": "https://libb.la/"}, nil), 409) + expect(t, "add", s.do("POST", "/api/zlibrary/domains", map[string]string{"domain": zl.URL + "/"}, &l), 201) + if last := l.Domains[len(l.Domains)-1]; last.Domain != zl.URL || last.Source != domainCustom { + t.Fatalf("added = %+v", last) + } + + // Picking the fake makes search go there (the default domain would fail). + expect(t, "select", s.do("PUT", "/api/zlibrary/domain", map[string]string{"domain": zl.URL}, &l), 200) + if l.Selected != zl.URL { + t.Fatalf("selected = %q", l.Selected) + } + var found struct { + Results []zlibResult `json:"results"` + } + expect(t, "search", s.do("GET", "/api/zlibrary/search?q=fake", nil, &found), 200) + + // Z-Library's own list joins the official one (asked of the domain in + // use), skipping domains without content and ones already listed. + var refreshed struct { + Domains []zlibDomainOut `json:"domains"` + FetchedFrom string `json:"fetched_from"` + } + expect(t, "refresh", s.do("POST", "/api/zlibrary/domains/refresh", nil, &refreshed), 200) + var fromZLib []string + for _, d := range refreshed.Domains { + if d.Source == domainZLibrary { + fromZLib = append(fromZLib, d.Domain) + } + } + if refreshed.FetchedFrom != zl.URL || len(fromZLib) != 1 || fromZLib[0] != "mirror.example" { + t.Fatalf("refreshed = %+v", refreshed) + } + expect(t, "select fetched", s.do("PUT", "/api/zlibrary/domain", map[string]string{"domain": "mirror.example"}, nil), 200) + expect(t, "add fetched", s.do("POST", "/api/zlibrary/domains", map[string]string{"domain": "mirror.example"}, nil), 409) + expect(t, "select fake again", s.do("PUT", "/api/zlibrary/domain", map[string]string{"domain": zl.URL}, nil), 200) + if len(found.Results) != 1 { + t.Fatalf("results = %+v", found.Results) + } + + var check map[string]any + expect(t, "check", s.do("GET", "/api/zlibrary/domains/check?domain="+url.QueryEscape(zl.URL), nil, &check), 200) + if check["ok"] != true { + t.Fatalf("check = %v", check) + } + expect(t, "check down", s.do("GET", "/api/zlibrary/domains/check?domain=http://127.0.0.1:1", nil, &check), 200) + if check["ok"] != false || check["error"] == nil { + t.Fatalf("check down = %v", check) + } + + // Removing the selected domain goes back to the default. + expect(t, "remove", s.do("DELETE", "/api/zlibrary/domains/"+url.PathEscape(zl.URL), nil, &l), 200) + if l.Selected != "z-library.hn" || len(l.Domains) != len(zlibBuiltinDomains)+1 { + t.Fatalf("after remove = %+v", l) + } + expect(t, "remove built-in", s.do("DELETE", "/api/zlibrary/domains/libb.la", nil, nil), 404) +} diff --git a/internal/store/schema/0002_settings.sql b/internal/store/schema/0002_settings.sql new file mode 100644 index 0000000..709bed1 --- /dev/null +++ b/internal/store/schema/0002_settings.sql @@ -0,0 +1,5 @@ +-- App settings changed from the UI (key → JSON value). +CREATE TABLE settings ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL +); diff --git a/internal/zlibrary/zlibrary.go b/internal/zlibrary/zlibrary.go index 3dbfd35..e74c6b3 100644 --- a/internal/zlibrary/zlibrary.go +++ b/internal/zlibrary/zlibrary.go @@ -36,7 +36,7 @@ func (e *APIError) Error() string { return "Z-Library: " + e.Message } // Client talks to one Z-Library domain with one account. It logs in on first // use and again when the session expires. type Client struct { - // Domain is a host ("z-library.ec") or a base URL ("http://127.0.0.1:1"). + // Domain is a host ("z-library.hn") or a base URL ("http://127.0.0.1:1"). Domain string Email string Password string @@ -60,13 +60,31 @@ func New(domain, email, password string) *Client { // Base is the API's base URL. func (c *Client) Base() string { - d := strings.TrimRight(strings.TrimSpace(c.Domain), "/") + c.mu.Lock() + d := c.Domain + c.mu.Unlock() + return BaseURL(d) +} + +// BaseURL turns a domain ("z-library.hn") or base URL into a base URL. +func BaseURL(domain string) string { + d := strings.TrimRight(strings.TrimSpace(domain), "/") if strings.Contains(d, "://") { return d } return "https://" + d } +// SetDomain switches the client to another domain, logging in afresh there. +func (c *Client) SetDomain(domain string) { + c.mu.Lock() + defer c.mu.Unlock() + if c.Domain != domain { + c.Domain = domain + c.userID, c.userKey = "", "" + } +} + // Book is one search result. type Book struct { ID string `json:"id"` @@ -190,6 +208,26 @@ func (c *Client) Search(ctx context.Context, q Query) ([]Book, error) { return out.Books, nil } +// Domains asks Z-Library for its current domains (those with content). +func (c *Client) Domains(ctx context.Context) ([]string, error) { + var out struct { + Domains []struct { + Domain string `json:"domain"` + ContentAvailable *bool `json:"contentAvailable"` + } `json:"domains"` + } + if err := c.call(ctx, http.MethodGet, "/eapi/info/domains", nil, &out); err != nil { + return nil, err + } + var domains []string + for _, d := range out.Domains { + if d.Domain != "" && (d.ContentAvailable == nil || *d.ContentAvailable) { + domains = append(domains, d.Domain) + } + } + return domains, nil +} + // Limits is the account's download allowance. type Limits struct { DownloadsToday int `json:"downloads_today"` @@ -366,6 +404,10 @@ func (c *Client) call(ctx context.Context, method, p string, form url.Values, ou c.decorate(req) resp, err := c.HTTP.Do(req) if err != nil { + if strings.Contains(err.Error(), "stopped after") { + // A bot check that redirects to itself until it gets a cookie. + return fmt.Errorf("%s is behind a bot check; try another domain", c.Base()) + } return fmt.Errorf("could not reach Z-Library at %s: %w", c.Base(), err) } defer resp.Body.Close() @@ -378,7 +420,7 @@ func (c *Client) call(ctx context.Context, method, p string, form url.Values, ou Error string `json:"error"` } if err := json.Unmarshal(raw, &head); err != nil { - return fmt.Errorf("Z-Library at %s sent an unexpected reply (HTTP %d); the domain may have moved", c.Base(), resp.StatusCode) + return fmt.Errorf("Z-Library at %s sent an unexpected reply (HTTP %d); try another domain", c.Base(), resp.StatusCode) } if ok := strings.Trim(string(head.Success), `"`); ok != "1" && ok != "true" { msg := head.Error