feat: 初始化自部署书库服务端

按产品设计 v0.2 落地服务端,覆盖第一版范围中的扫描、元数据、权限与同步。

核心决策:
- 书籍主键用 dc:identifier + 内容哈希,不用文件路径。
  重命名/移动不丢进度,副本自动合并,重新编码的文件保持独立。
- 库目录只读挂载,写操作全部落到 DATA_DIR。
  启动时断言 DATA_DIR 不在 BOOKS_DIR 内,路径解析统一走 resolveInside
  拦截逃逸,扫描不跟随符号链接。
- 元数据优先级:手动修改 > 内嵌 > provider > 文件名解析。
  手动修改独立成表,扫描永远碰不到,可单字段撤销。
- 中文文件名解析采取保守策略,无证据时不猜测作者方向。
- 离线合并用 LWW,笔记删除写墓碑,批次单事务。
- 无原生编译依赖:node:sqlite 与 scrypt 均为内置模块。

交付:
- Fastify + TypeScript 服务端,Docker 单容器
- 27 个自动化测试用例全部通过
- AGPL-3.0,README 做到一条命令跑起来
- CNB 流水线:typecheck / test / build
This commit is contained in:
cnb.dQC5J6ohAGA committed 2026-09-16 14:07:25 +08:00
1 parent 68bc7e8c61
commit 2233b295bb
42 files changed
+6573

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
# 服务端 CI:类型检查、测试、构建。
# 忽略文档改动,避免改 README 也跑一遍 Node。
.node-env: &node-env
docker:
image: node:24
ifModify:
- server/**
# 推送:完整检查
main:
push:
- name: server-ci
<<: *node-env
stages:
- name: install
script: cd server && npm ci
- name: typecheck
script: cd server && npm run typecheck
- name: test
script: cd server && npm test
- name: build
script: cd server && npm run build
failStages:
- name: report-failure
script: echo "CI 失败:$CNB_BUILD_FAILED_MSG"
# PR:同样的检查,作为评审依据
"**":
pull_request:
- name: server-checks
<<: *node-env
stages:
- name: install
script: cd server && npm ci
- name: typecheck
script: cd server && npm run typecheck
- name: test
script: cd server && npm test
- name: build
script: cd server && npm run build
+19
View File
@@ -0,0 +1,19 @@
# dependencies
node_modules/
# build output
server/dist/
# runtime data: sqlite db, covers, token secret
data/
server/data/
# logs and local env
*.log
.env
.env.local
# editor / OS
.DS_Store
.idea/
.vscode/
+661
View File
@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+287
View File
@@ -0,0 +1,287 @@
# reader
**自部署精品书库阅读器。** 把你自己磁盘上的电子书挂载成书库,用移动端以「精品级排版」阅读。
书文件始终留在你自己的磁盘上,不经过任何厂商服务器。开源免费,AGPL-3.0。
---
## 一条命令跑起来
```bash
git clone https://cnb.cool/hectorqin/reader.git
cd reader
# 把 /path/to/your/books 换成你自己的书库目录
sed -i 's#/path/to/your/books#/home/me/books#' docker-compose.yml
docker compose up -d
```
打开 `http://<你的主机>:8080`,**第一个注册的账号自动成为管理员**。
> 只有第一次注册是开放的。之后新账号由管理员在后台创建(默认关闭公开注册),
> 这样你的实例不会被陌生人占用。想让家人自行注册,在 `docker-compose.yml` 里
> 把 `ALLOW_REGISTRATION` 设为 `"true"`。
### 最小化运行(不用 compose)
```bash
docker run -d --name reader \
-p 8080:8080 \
-v /path/to/your/books:/books:ro \
-v ./data:/data \
cnb.cool/hectorqin/reader:latest
```
---
## 书库目录:只读,永不写回
```yaml
volumes:
- /path/to/your/books:/books:ro # 你的书,只读挂载
- ./data:/data # 服务端写的一切
```
服务端**绝不写入** `/books`。所有写操作——SQLite 数据库、封面缓存、扫描记录——都在 `/data`。
这一条是硬约束,代码里有断言保护:`DATA_DIR` 落在 `BOOKS_DIR` 里面时服务会直接拒绝启动。
你的书籍目录不会被污染:不会多出封面文件、`.calibre` 之类的元数据文件、转换产物。
---
## 支持格式
| 格式 | 第一版状态 |
| --- | --- |
| EPUB | 精排支持(内嵌元数据、封面、calibre 系列、dc:identifier) |
| PDF | 保底可读(页数、基础内嵌元数据) |
---
## 书库扫描
- **增量扫描**:先用 `mtime + size` 快速判断,再用内容哈希确认。绝大多数文件在第一阶段就被跳过。
- **目录挂载**:递归扫描挂载目录,自动跳过 `.git`、`@eaDir`、`#recycle` 等目录,不跟随符号链接。
- **自动扫描**:默认每 30 分钟全量、每 60 秒增量轮询。设 `SCAN_INTERVAL=0` / `WATCH_INTERVAL=0` 可关闭。
- **手动触发**:`POST /api/v1/library/scan`(管理员)。
### 书籍主键:为什么不按文件路径
书籍主键是 `sha256(dc:identifier + 文件内容哈希)`,**不是文件路径**。
用路径做主键的话,用户重命名或移动一下文件,阅读进度和笔记就会全部丢失——
这是自建书库产品最常见的差评来源。当前设计下:
- 重命名 / 移动文件 → 主键不变,进度和笔记保留 ✅
- 同一本书的多份副本 → 自动合并成一条书架记录 ✅
- 重新编码过的同名文件 → 视为不同副本,不会被误认 ✅
### 元数据优先级
```
1. 用户手动修改 最高,永不自动覆盖
2. EPUB 内嵌元数据 次高,扫描不会覆盖手动修改
3. 在线刮削 provider 只填补空缺字段,标注来源,可一键撤销
4. 文件名解析 最弱,仅在以上都缺时使用
```
在线刮削是可插拔的 provider 机制。**第一版只做内嵌元数据 + 文件名解析 + 手动补全**,
不接任何在线源——避免引入网络依赖与限流问题,也避开豆瓣的合规风险。
后续接入 Google Books、Open Library 时,实现 `MetadataProvider` 接口注册即可,索引层不用改。
手动修改存在独立的覆盖层,扫描永远碰不到它。撤销单个字段用
`DELETE /api/v1/books/:id/metadata/:field`,会回落到内嵌值。
---
## 多用户
单个部署实例内的多账号,适合家庭或小圈子。两级权限:
- **admin**:创建/停用账号、触发扫描、查看实例信息
- **member**:只能看自己的书架、进度和笔记
每个账号有独立的书架、阅读进度、笔记与高亮。新账号注册后自动看到整个共享书库,
不需要逐本添加。
---
## 客户端三态
服务端是唯一真相源,客户端只做「渲染 + 缓存」。客户端有三种状态:
| 状态 | 行为 |
| --- | --- |
| 局域网直连 | 正常同步 |
| 公网可达 | 正常同步 |
| 完全离线 | 读本地缓存,恢复后合并进度 |
同步的只有轻量数据:书架元数据、阅读进度、笔记、高亮。书文件不走同步。
### 离线合并语义
进度和笔记都带 `updatedAt`,冲突按 **last-writer-wins** 解决:
- 离线设备重连后推的旧数据**不会**回滚你在别处读到的新进度
- 笔记删除是**墓碑(tombstone)**而不是硬删除,所以离线设备无法让已删除的笔记复活
- `GET /api/v1/sync?since=<上次的 serverTime>` 只返回增量,重连很便宜
---
## 配置
| 环境变量 | 默认值 | 说明 |
| --- | --- | --- |
| `BOOKS_DIR` | `/books` | 书库目录,只读挂载 |
| `DATA_DIR` | `./data` | 服务端写入目录,**不能**在 `BOOKS_DIR` 内 |
| `PORT` | `8080` | 监听端口 |
| `PUBLIC_URL` | 空 | 对外地址,反代后设置 |
| `SCAN_INTERVAL` | `1800` | 全量扫描间隔(秒),0 关闭 |
| `WATCH_INTERVAL` | `60` | 增量轮询间隔(秒),0 关闭 |
| `ALLOW_REGISTRATION` | `false` | 是否允许公开注册 |
| `ACCESS_TOKEN_TTL` | `86400` | 访问令牌有效期(秒) |
| `REFRESH_TOKEN_TTL` | `31536000` | 刷新令牌有效期(秒) |
| `READER_TOKEN_SECRET` | 自动生成 | 签名密钥。留空则生成并持久化到 `/data` |
| `LOG_LEVEL` | `info` | 日志级别 |
> `READER_TOKEN_SECRET` 留空时会在首次启动生成并存到 `DATA_DIR/token.secret`。
> 只要 `/data` 还在,重启不会踢掉所有客户端。**删掉 `/data` 会清空所有账号和进度。**
---
## 让外网访问(内网穿透 / 域名 / HTTPS)
自部署最大的门槛在这里,所以单独说清楚。三种常见做法:
**1. 只在局域网用**(最简单)
不用做任何事,直接访问 `http://<NAS的局域网IP>:8080`。
**2. 反向代理 + HTTPS**(推荐)
用 Caddy 最省事,自动申请证书:
```caddyfile
reader.example.com {
reverse_proxy 127.0.0.1:8080
}
```
然后设置 `PUBLIC_URL=https://reader.example.com`,下载链接才会正确。
**3. 内网穿透**(没有公网 IP)
用 frp / Tailscale / Cloudflare Tunnel 任选一个。Tailscale 最简单:
在 NAS 和手机上装 Tailscale,直接用 Tailscale 分配的 IP 访问,不用配证书。
> 服务端设计上不依赖域名和 HTTPS,所以三种方式都能工作。但公网暴露时
> **务必**套一层 HTTPS,否则令牌是明文传输的。
---
## 从源码开发
```bash
cd server
npm install
npm test # 27 个测试用例
npm run typecheck
npm run dev # 开发模式,热重载
# 指向一个测试书库
BOOKS_DIR=/tmp/books DATA_DIR=/tmp/data npm run dev
```
### 项目结构
```
server/src/
config/ 运行时配置(含「DATA_DIR 不得在 BOOKS_DIR 内」的启动断言)
db/ SQLite schema 与访问层(node:sqlite,无原生依赖)
indexer/
identity.ts 书籍主键:dc:identifier + 内容哈希
filename.ts 文件名解析(保守策略,宁可留空不猜错)
metadata.ts EPUB/PDF 元数据与封面提取
scanner.ts 增量扫描、变更检测、清理
providers/ 可插拔刮削 provider 接口
services/
users.ts 账号、角色、令牌
shelf.ts 书架查询、手动覆盖层
sync.ts 进度/笔记同步与冲突合并
merge.ts 元数据优先级链
http/ Fastify 路由
```
### 技术选型说明
- **Node.js 24 + TypeScript + Fastify**:单容器体积可控(约 150MB),迭代最快,
`node:sqlite` 与 `scrypt` 都是内置模块,**没有原生编译依赖**——这让镜像构建稳定,
也兑现了「一条命令跑起来」。
- **不用 better-sqlite3 / argon2 / bcrypt**:它们需要构建时工具链(`make`、`gcc`),
会把镜像撑大并让安装变脆。
- **SQLite 存 metadata,不存书**:schema 里的 `books` 表可以随时重建,书本身才是真相源。
---
## API
所有业务接口在 `/api/v1` 下,用 `Authorization: Bearer <token>` 认证。
响应错误统一为 `{ "error": { "code": "...", "message": "..." } }`。
服务端 API 承诺**长期向后兼容**:自部署用户不会及时升级,破坏性变更只允许
「加新字段 / 加新端点」,不允许改已有字段语义。
<details>
<summary>端点列表</summary>
**公开**
- `GET /api/v1/health` — 健康检查
- `GET /api/v1/instance` — 实例信息(是否开放注册、账号数)
- `POST /api/v1/auth/register` — 注册(仅首次或开启 ALLOW_REGISTRATION)
- `POST /api/v1/auth/login` — 登录
- `POST /api/v1/auth/refresh` — 刷新令牌
**需认证**
- `GET /api/v1/auth/me`
- `POST /api/v1/auth/password`
- `POST /api/v1/auth/logout`
- `GET /api/v1/books` — 书架列表,支持 `search` `author` `series` `tag` `format` `sort` `page`
- `GET /api/v1/books/:id`
- `GET /api/v1/books/:id/manifest` — 渲染器需要的清单
- `GET /api/v1/books/:id/content` — 书文件流
- `GET /api/v1/books/:id/cover` — 封面
- `PATCH /api/v1/books/:id/metadata` — 手动补全元数据
- `DELETE /api/v1/books/:id/metadata/:field` — 撤销单个字段
- `GET /api/v1/library/facets` — 作者/系列/标签/格式聚合
- `GET /api/v1/library/continue` — 继续阅读
- `GET /api/v1/sync?since=<ms>` — 增量拉取
- `POST /api/v1/sync` — 批量合并
- `GET/PUT /api/v1/sync/progress/:bookId`
- `GET /api/v1/notes` / `POST /api/v1/notes` / `DELETE /api/v1/notes/:id`
**管理员**
- `POST /api/v1/library/scan`
- `GET /api/v1/library/scan`
- `GET /api/v1/library/stats`
- `GET/POST/PATCH /api/v1/admin/users`
- `GET /api/v1/providers`
</details>
---
## 范围边界(第一版)
**做**:目录挂载扫描、元数据刮削、两级权限、同步 API、Docker 单容器、Android 客户端、EPUB 精排、PDF 保底、按书离线缓存。
**不做**:在线书城、iOS、桌面端、社交、AI 问答、全格式转换、移动端批注输入。
---
## 许可
AGPL-3.0。选择 AGPL 而非 GPL:自部署产品最怕别人拿去做闭源付费服务,
AGPL 的网络分发条款正好卡住这条路径(Kavita、Audiobookshelf 用的 GPL-3.0 更松一档)。
## 贡献
欢迎提交 Issue 和 PR。请先跑通 `npm test`,并为新行为补测试。
+40
View File
@@ -0,0 +1,40 @@
# One command to get running:
#
# docker compose up -d
#
# Then open http://<your-host>:8080 and register the first account, which
# automatically becomes the instance admin.
services:
reader:
image: cnb.cool/hectorqin/reader:latest
# To build from source instead of pulling the published image:
# build: ./server
container_name: reader
restart: unless-stopped
ports:
- "8080:8080"
environment:
# Polling intervals in seconds. Set to 0 to disable (a manual scan is
# still available from the admin API).
SCAN_INTERVAL: "1800"
WATCH_INTERVAL: "60"
LOG_LEVEL: "info"
# Set this to the address you actually reach the server on, so download
# links work behind a reverse proxy.
# PUBLIC_URL: "https://reader.example.com"
# Uncomment to allow family members to self-register instead of the admin
# creating their accounts:
# ALLOW_REGISTRATION: "true"
volumes:
# --- your books, mounted READ-ONLY ---
# The server NEVER writes to this directory. Replace the left-hand side
# with the path to your own library.
- /path/to/your/books:/books:ro
# --- everything the server writes lives here ---
# SQLite database, cover cache, scan journal.
- ./data:/data
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8080/api/v1/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
+314
View File
@@ -0,0 +1,314 @@
# API 参考
基础路径 `/api/v1`。除标注「公开」外都需要 `Authorization: Bearer <accessToken>`。
错误响应统一格式:
```json
{ "error": { "code": "BOOK_NOT_FOUND", "message": "book not found" } }
```
## 兼容性承诺
只做加法。已有字段的语义不会改,端点不会删。
客户端可以按 `apiVersion` 判断能力,但不需要为小版本做分支。
---
## 实例信息
### `GET /instance` — 公开
```json
{ "name": "reader", "apiVersion": 1, "registrationOpen": false, "userCount": 3 }
```
客户端用它区分「全新实例」(可以注册)和「已被占用」(需要管理员开账号)。
### `GET /health` — 公开
```json
{ "status": "ok", "booksDir": "/books", "dataDir": "/data",
"version": "0.1.0", "dataDirIsInsideBooks": false }
```
`dataDirIsInsideBooks` 恒为 `false`——为 `true` 时服务端根本不会启动。
暴露出来是为了让运维一眼确认挂载点就是自己以为的那个。
---
## 认证
### `POST /auth/register` — 公开
```json
{ "username": "owner", "password": "至少8位", "displayName": "图书馆长" }
```
- 首个账号恒可注册,且自动成为 `admin`
- 之后仅在 `ALLOW_REGISTRATION=true` 时开放
- 关闭时返回 `400 REGISTRATION_DISABLED`
返回 `201`:
```json
{ "user": { "id": "...", "username": "owner", "displayName": "图书馆长",
"role": "admin", "createdAt": 1789537801289 },
"session": { "user": {...}, "accessToken": "...", "accessTokenExpiresAt": 1789624201289,
"refreshToken": "...", "refreshTokenExpiresAt": 1821073801289 } }
```
### `POST /auth/login` — 公开
`{ "username": "...", "password": "..." }` → 同上(不含 `user` 外包一层)
失败恒为 `401 BAD_CREDENTIALS`,不区分「用户不存在」和「密码错误」。
账号停用返回 `403 ACCOUNT_DISABLED`。
### `POST /auth/refresh` — 公开
`{ "refreshToken": "..." }` → 新的 `session`。
**刷新令牌会轮转**:传入的那个立即作废。客户端必须持久化新的那个。
### `POST /auth/logout`
`{ "refreshToken": "..." }` → `{ "ok": true }`。幂等,重复调用不报错。
### `GET /auth/me`
→ `{ "user": { ... } }`
### `POST /auth/password`
`{ "currentPassword": "...", "newPassword": "..." }` → `{ "ok": true }`
会撤销该账号**所有**会话(含当前设备),需要重新登录。
---
## 书架
### `GET /books`
查询参数:
| 参数 | 说明 |
| --- | --- |
| `search` | 匹配书名 / 作者 / 系列 / ISBN |
| `author` `series` `tag` `format` | 精确过滤 |
| `sort` | `title` \| `author` \| `added` \| `updated`,默认 `added` |
| `order` | `asc` \| `desc`,默认 `desc` |
| `page` `pageSize` | 默认 1 / 50,`pageSize` 上限 200 |
```json
{ "items": [ { "id": "...", "title": "三体", "author": "刘慈欣",
"series": "地球往事", "seriesIndex": 1, "tags": ["科幻"],
"format": "epub", "coverUrl": "/api/v1/books/<id>/cover",
"fileSize": 1843, "source": "embedded", "manualFields": [] } ],
"total": 1, "page": 1, "pageSize": 50 }
```
只看得到「有可用文件」的书。文件被删的书不会留在书架上。
`source` 是基础元数据的来源,`manualFields` 列出被手动改过的字段。
### `GET /books/:id`
→ `{ "book": {...}, "progress": {...} | null }`
### `GET /books/:id/manifest`
渲染器布局前需要的清单:
```json
{ "book": {...}, "contentUrl": "/api/v1/books/<id>/content",
"coverUrl": "...",
"files": [ { "rel_path": "小说/三体.epub", "size": 1843, "missing": 0 } ] }
```
`files` 让客户端能区分「另一份副本」和「文件真的没了」。
### `GET /books/:id/content`
流式返回书文件,`Content-Type` 按格式给 `application/epub+zip` 或 `application/pdf`。
书内容按哈希不可变,所以带 `ETag`,客户端可以放心长缓存。
### `GET /books/:id/cover`
返回封面。无封面时 `404 NO_COVER`。缓存头是 `immutable`(封面随书籍主键变化)。
### `PATCH /books/:id/metadata`
```json
{ "title": "三体(修订版)", "tags": "科幻,雨果奖", "seriesIndex": 1 }
```
可改字段:`title` `author` `publisher` `language` `isbn` `description` `series`
`seriesIndex` `pubdate` `tags`(`tags` 用逗号分隔的字符串)。
写入的是覆盖层,**扫描永远不会覆盖它**。
### `DELETE /books/:id/metadata/:field`
撤销单个字段,回落到内嵌值。可用来实现 UI 上的「还原」按钮。
---
## 书库
### `GET /library/facets`
→ `{ "authors": [...], "series": [...], "tags": [...], "formats": [...] }`
用来构建筛选界面。
### `GET /library/continue?limit=20`
继续阅读列表,按最近阅读时间倒序。带 `percentage` 和 `chapterTitle`。
### `GET /library/stats`
→ `{ "myBooks": 3, "libraryBooks": 10, "files": 12, "missingFiles": 1,
"formats": [{"format":"epub","n":9}], "scan": {...} }`
### `POST /library/scan` — 管理员
触发一次扫描,同步等待结果。并发调用会共享同一次扫描。
### `GET /library/scan`
→ `{ "progress": { "running": false, "scanned": 12, "added": 1, ... } }`
---
## 同步
### `GET /sync?since=<ms>&bookId=<可选>`
`since` 是上次响应里的 `serverTime`(首次传 0)。
```json
{ "serverTime": 1789537864820,
"progress": [ { "bookId": "...", "locator": "epubcfi(/6/4!/4/2)", "percentage": 0.42,
"chapterTitle": "第七章", "device": "android-a", "updatedAt": 1000 } ],
"notes": [ { "id": "...", "bookId": "...", "type": "highlight", "locator": "...",
"text": "...", "comment": "...", "color": "yellow",
"updatedAt": 1789537864623, "deleted": false } ] }
```
**客户端必须保存返回的 `serverTime`**,下次带回来才能只拿增量。
### `POST /sync`
```json
{ "progress": [ { "bookId": "...", "locator": "...", "percentage": 0.42,
"chapterTitle": "第七章", "device": "android-a", "updatedAt": 1000 } ],
"notes": [ { "id": "...", "bookId": "...", "type": "highlight", "locator": "...",
"text": "...", "comment": "...", "color": "yellow",
"updatedAt": 1000, "deleted": false } ] }
```
两个数组至少给一个,总数上限 5000(超了返回 `400 BATCH_TOO_LARGE`)。
`updatedAt` 是**客户端时钟**的毫秒时间戳,决定 LWW 谁赢。
响应把合并后的当前状态一并返回,客户端不用再多发一次拉取:
```json
{ "accepted": 1, "rejected": 0, "serverTime": 1789537864820,
"progress": [...], "notes": [...] }
```
注意:`notes[].type` 只接受 `note` / `highlight` / `bookmark`。
`notes[].deleted: true` 是删除(写墓碑)。
### `GET /sync/progress/:bookId`
→ `{ "progress": {...} | null }`
### `PUT /sync/progress/:bookId`
单个进度上报的便捷写法:
```json
{ "locator": "epubcfi(/6/4!/4/2)", "percentage": 0.42,
"chapterTitle": "第七章", "device": "android-a", "updatedAt": 1789537864623 }
```
`percentage` 会被夹到 `[0, 1]`。
### `GET /notes?bookId=<可选>&since=<可选>`
已过滤掉墓碑,只返回可见笔记。
### `POST /notes`
```json
{ "bookId": "...", "type": "highlight", "locator": "...",
"text": "选中的原文", "comment": "我的批注", "color": "yellow" }
```
`id` 可省略或由客户端生成(推荐,便于离线创建)。返回 `201`。
### `DELETE /notes/:id`
写墓碑。幂等:不存在也返回 `{ "ok": true }`。
---
## 管理员
### `GET /admin/users`
→ `{ "users": [ { "id": "...", "username": "...", "displayName": "...",
"role": "member", "createdAt": 0 } ] }`
### `POST /admin/users`
```json
{ "username": "family", "password": "familypass123",
"displayName": "家人", "role": "member" }
```
不受 `ALLOW_REGISTRATION` 限制——管理员本就该能加家庭成员。
### `PATCH /admin/users/:id`
`{ "disabled": true }` 或 `{ "role": "admin" }`。
不能停用自己(`400 SELF_LOCKOUT`),否则会把自己锁在外面。
### `GET /providers`
→ `{ "providers": [ { "id": "none", "displayName": "...", "enabled": true } ] }`
第一版只有 `none`。在线源接入后会自动出现在这里。
---
## 错误码
| 码 | HTTP | 含义 |
| --- | --- | --- |
| `BAD_REQUEST` | 400 | 参数不合法 |
| `BAD_PAYLOAD` | 400 | 请求体结构错误 |
| `BATCH_TOO_LARGE` | 400 | 同步批次超过 5000 |
| `REGISTRATION_DISABLED` | 400 | 实例已关闭公开注册 |
| `SELF_LOCKOUT` | 400 | 不能停用自己 |
| `NO_TOKEN` | 401 | 缺少 Bearer 令牌 |
| `TOKEN_INVALID` | 401 | 签名错误 / 载荷被改 / 类型不对 |
| `TOKEN_EXPIRED` | 401 | 访问令牌过期,用 refresh 换新 |
| `REFRESH_INVALID` | 401 | 刷新令牌无效 / 已使用 |
| `REFRESH_EXPIRED` | 401 | 刷新令牌过期,需要重新登录 |
| `BAD_CREDENTIALS` | 401 | 用户名或口令错误 |
| `ACCOUNT_DISABLED` | 403 | 账号被停用 |
| `FORBIDDEN` | 403 | 越权访问 |
| `PATH_TRAVERSAL` | 403 | 路径逃逸出挂载点 |
| `ADMIN_REQUIRED` | 403 | 需要管理员权限 |
| `NOT_FOUND` | 404 | 资源不存在 |
| `NO_COVER` | 404 | 这本书没有封面 |
| `FILE_MISSING` | 404 | 文件已从磁盘消失 |
| `USERNAME_TAKEN` | 409 | 用户名已占用 |
| `PASSWORD_TOO_SHORT` | 409 | 口令少于 8 位 |
| `INTERNAL` | 500 | 服务端错误 |
+200
View File
@@ -0,0 +1,200 @@
# 架构与数据边界
本文记录**为什么这样设计**,尤其是那些一旦写错就很难回头的决策。
产品设计文档(v0.2)里的约束在这里落成了实现,逐条对应。
---
## 1. 数据边界
| 位置 | 内容 | 读写 |
| --- | --- | --- |
| `BOOKS_DIR` (`/books`) | 用户自己的电子书 | **只读** |
| `DATA_DIR` (`/data`) | SQLite、封面缓存、令牌密钥 | 读写 |
| 客户端本地 | 离线书文件缓存、本地进度 | 读写 |
服务端是唯一真相源。客户端只承担「渲染 + 缓存」。
### 只读挂载的强制手段
`src/config/index.ts` 在启动时断言 `DATA_DIR` 不在 `BOOKS_DIR` 内,否则拒绝启动。
这不是文档里的君子协定,是运行时会拦下来的硬约束。
扫描器与所有文件访问都经过 `src/lib/paths.ts` 的 `resolveInside()`——
唯一的路径解析出口,`../` 逃逸会抛 `PATH_TRAVERSAL`。
扫描时也**不跟随符号链接**:指向挂载点外的软链会读到用户没打算共享的文件。
封面缓存写在 `DATA_DIR/covers/`,不写在书旁边。
---
## 2. 书籍主键
```ts
id = sha256( (dc:identifier ?? '').toLowerCase() + '\0' + sha256(fileBytes) )
```
### 为什么不用文件路径
用户的真实行为是**持续整理书库**:改名、分目录、加前缀。
| 场景 | 路径做主键 | 当前设计 |
| --- | --- | --- |
| 重命名 `三体.epub` → `刘慈欣 - 三体 (修订版).epub` | 进度全丢 | 进度保留 |
| 移动到另一个目录 | 进度全丢 | 进度保留 |
| 同一本书放两份 | 书架出现两条 | 合并成一条 |
| 重新编码的同名文件 | 误认为同一本 | 视为不同副本 |
第二列是自建书库产品最常见的差评来源,所以这条必须在上线前定死。
### 为什么哈希也要参与
只用 `dc:identifier` 不行:很多自制/盗版 EPUB 没有 identifier,或者多本书共用
一个占位 identifier。只用哈希也不行:同一本书不同来源的副本哈希不同,无法合并。
两者结合:identifier 提供稳定的锚点,哈希保证「字节不同就是不同的副本」。
`identifier` 缺失时退化为纯哈希,行为仍然正确(只是无法跨副本合并)。
### 文件表与书表的分离
`book_files` 记录磁盘上的文件(`rel_path`、`size`、`mtime`),`books` 记录书。
多对一:一本书可以有多份副本。
文件被删时 `missing=1`,不立即删记录——用户可能只是临时拔了移动硬盘。
但一旦这本书已经有了别的可用文件,旧的行就是纯噪声,会被清掉,
否则反复整理书库会让文件表无限增长。
只有「没有任何可用文件」且「用户没手动加进书架」的书才会被删除。
宁可显示一本暂时不可用的书,也不能删掉读者精心整理过的书架条目。
---
## 3. 增量扫描
两阶段变更检测(`Scanner.indexFile`):
1. **`mtime + size`**:便宜,跳过绝大多数文件
2. **内容哈希**:权威,确认字节真的变了
只有第一阶段判断"可能变了"才读文件算哈希。所以每 60 秒轮询一次也很轻。
`Scanner.scan()` 对并发调用做去重(共享同一个 Promise),
避免定时器和手动触发同时砸磁盘。
---
## 4. 元数据优先级链
```
1. 手动修改(metadata_overrides 表)
2. EPUB 内嵌(dc:* + calibre meta)
3. provider 补丁(只填空缺)
4. 文件名解析
```
### 关键实现细节
`books` 表存的是第 2/3/4 层的结果。第 1 层单独一张表,**读取时**叠加
(`ShelfService.toDto` → `applyOverrides`)。
这样做的原因:扫描器可以放心地刷新 `books` 表,因为手动修改根本不在那张表里,
不可能被覆盖。
`books.source` 记录的是**基础值的来源**(`embedded` / `filename` / `provider:x`),
不记录 `manual`。手动修改过的字段由 DTO 的 `manualFields` 单独返回。
保持来源干净,撤销单个字段时才能正确回落到原始值。
### 文件名解析的保守策略
中文电子书里 `书名 - 作者` 和 `作者 - 书名` 都极常见,没有标记时**无法区分**。
因此只在有证据时才切分:
- `《书名》` → 这一侧是书名
- `著` / `编` / `译` → 这一侧是作者
- 一侧是中文人名(2-4 字且首字为常见姓氏)→ 另一侧是书名
- 一侧是拉丁文人名 → 另一侧是书名
- **都不满足 → 整串保留为书名,作者留空**
最后一条是刻意的:漏一个作者,读者手动补一下就好;猜错一个作者,
书就被归到错误的人名下,而且他未必会发现。
其余正则也踩过坑,值得记下来:
- 系列正则 `[【\[]...[】\]]` 里 `\[` 在字符类中不转义,会把分隔符 `' - '` 吃掉,
导致 `金庸 - 射雕英雄传` 变成 `title=金庸, author=射雕英雄传`。改成显式括号对匹配。
- 「(第1卷)」这种卷标,书名是作品本身,不是「第」。要先剥离再切分作者,
且剥离要按完整括号组做,否则会留下 `射雕英雄传(第1卷` 这样的半截括号。
---
## 5. 同步与冲突合并
### 为什么是 last-writer-wins
一台设备属于一个读者。多设备之间不存在"编辑冲突",只存在"某台设备落后了"。
所以 LWW 是正确语义,不需要 CRDT 或向量时钟的复杂度。
### 墓碑
笔记删除写 `deleted=1` 而不是 `DELETE`。
否则:手机离线时删了一条笔记 → 平板(也离线,数据更旧)重连 → 把笔记推回来 →
已删除的笔记复活。墓碑让这个时序变得无害。
### 批次原子性
`SyncService.push` 整个批在**一个事务**里。断网不会留下写了一半的进度。
### 增量拉取
`GET /api/v1/sync?since=<上次的 serverTime>`。客户端记住上次返回的 `serverTime`,
每次只拿增量。移动网络下重连很便宜。
---
## 6. 认证与安全
- **scrypt** 做口令哈希(内置模块,无原生依赖)。比较用 `timingSafeEqual`。
- 登录时即便用户不存在也跑一次哈希比较,避免用时间差枚举用户名。
- 访问令牌是 HS256 签名的无状态令牌;刷新令牌是不透明随机串存库,可按设备撤销。
- 改口令 / 停用账号会撤销该用户**所有**会话。
- 每个请求都从库里重读用户,不信任令牌里的 role——停用账号立即失效。
- 刷新令牌**轮转**:用过即废,降低泄露窗口。
### 不要重新引入原生依赖
镜像构建的稳定性直接决定部署成功率,而部署门槛是最大的获客阻力。
`better-sqlite3`、`argon2`、`bcrypt` 都需要构建时的 `make`/`gcc`,
在部分 NAS 环境会直接装不上。
`node:sqlite` 和 `node:crypto` 的 `scrypt` 都是内置的,所以当前依赖树里
**没有任何需要编译的包**。
---
## 7. 向后兼容承诺
自部署用户不会及时升级。所以:
- API 只做加法:加字段、加端点。不改已有字段语义、不删端点。
- 数据库 schema 用 `CREATE TABLE IF NOT EXISTS`,升级时新增列/表,不动旧的。
- 错误响应格式全站统一,客户端可以一直用同一套解析逻辑。
---
## 8. 客户端渲染(尚未实现)
产品设计里的关键决策:**不要直接套 Readium Mobile**。
Readium 用 Readium CSS 主动接管排版、覆盖出版方样式,这与本产品
「忠实还原精排书设计」的核心差异化直接冲突。
计划方案(下一轮):
- 原生外壳(Kotlin)只负责:文件与缓存、手势、书架、账号
- 渲染层用 WebView + 自研 CSS 干预策略,参考 foliate-js 的「保留作者样式 + 最小覆盖」
- 参考 readium-css 的变量组织方式,但**不引入它的默认接管行为**
服务端为此准备的是 `GET /api/v1/books/:id/manifest`:格式中立的清单接口,
返回书信息和 `contentUrl`,PDF 支持可以复用同一个契约。
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
data
.git
*.log
test
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
data/
*.log
+43
View File
@@ -0,0 +1,43 @@
# --- build stage -------------------------------------------------------------
FROM docker.cnb.cool/dockerhub/node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
# Install dev deps too: the TypeScript toolchain is needed to compile.
RUN npm ci --include=dev
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
# Strip dev dependencies for the runtime image.
RUN npm prune --omit=dev
# --- runtime stage -----------------------------------------------------------
FROM docker.cnb.cool/dockerhub/node:24-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production \
BOOKS_DIR=/books \
DATA_DIR=/data \
PORT=8080
# Run as an unprivileged user. The library mount is read-only, so this user
# only ever needs write access to /data.
RUN addgroup -S reader && adduser -S reader -G reader \
&& mkdir -p /data /books && chown -R reader:reader /data /books
COPY --from=build --chown=reader:reader /app/node_modules ./node_modules
COPY --from=build --chown=reader:reader /app/dist ./dist
COPY --chown=reader:reader package.json ./
USER reader
EXPOSE 8080
VOLUME ["/data"]
# The healthcheck hits the unauthenticated endpoint so an orchestrator can tell
# "server up but library unreadable" from "server down".
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||8080)+'/api/v1/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "dist/main.js"]
+1528
View File
File diff suppressed because it is too large. Load diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "reader-server",
"version": "0.1.0",
"private": true,
"description": "Self-hosted精品书库 server: mount your own book directory read-only, sync shelves/progress/notes across clients.",
"license": "AGPL-3.0-or-later",
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "tsx watch src/main.ts",
"build": "tsc -p tsconfig.build.json",
"start": "node dist/main.js",
"typecheck": "tsc -p tsconfig.json",
"test": "tsx --test test/*.test.ts"
},
"dependencies": {
"@fastify/multipart": "^9.0.3",
"epub2": "^3.0.2",
"fast-xml-parser": "^4.5.1",
"fastify": "^5.2.1",
"jszip": "^3.10.1",
"pino": "^9.6.0"
},
"devDependencies": {
"@types/node": "^22.13.1",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
}
+85
View File
@@ -0,0 +1,85 @@
import { randomBytes } from 'node:crypto';
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
import { join, resolve } from 'node:path';
/**
* Runtime configuration.
*
* Hard constraint (product design §6): the book library directory is mounted
* READ-ONLY. This server must never write into it. Every write (sqlite db,
* extracted cover cache, scan journal) goes into DATA_DIR instead.
*/
export interface AppConfig {
/** Read-only mount of the user's book directory. */
booksDir: string;
/** Writable directory owned by this server. Never inside booksDir. */
dataDir: string;
host: string;
port: number;
/** Persistent token signing secret, auto generated on first boot if absent. */
jwtSecret: string;
/** Access token lifetime in seconds. */
accessTokenTtl: number;
/** Refresh token lifetime in seconds (long, self-hosted users upgrade rarely). */
refreshTokenTtl: number;
/** Scan interval in seconds; 0 disables the scheduler. */
scanInterval: number;
/** Polling watcher interval in seconds; 0 disables it. */
watchInterval: number;
logLevel: string;
/** Public base URL advertised to clients (used by download links). */
publicUrl: string;
}
const DATA_DIR_DEFAULT_NAME = 'data';
function envInt(name: string, fallback: number): number {
const raw = process.env[name];
if (!raw) return fallback;
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
/**
* Load or create the persistent secret. Regenerating it on every boot would
* silently invalidate every client session, so it is persisted in DATA_DIR.
*/
function loadOrCreateSecret(dataDir: string, file = 'token.secret'): string {
const fromEnv = process.env.READER_TOKEN_SECRET ?? process.env.JWT_SECRET;
if (fromEnv && fromEnv.length >= 16) return fromEnv;
const secretPath = join(dataDir, file);
if (existsSync(secretPath)) {
const existing = readFileSync(secretPath, 'utf8').trim();
if (existing.length >= 16) return existing;
}
const generated = randomBytes(48).toString('base64url');
writeFileSync(secretPath, generated, { mode: 0o600 });
return generated;
}
export function loadConfig(): AppConfig {
const booksDir = resolve(process.env.BOOKS_DIR ?? '/books');
const dataDir = resolve(process.env.DATA_DIR ?? join(process.cwd(), DATA_DIR_DEFAULT_NAME));
mkdirSync(dataDir, { recursive: true });
if (booksDir === dataDir || dataDir.startsWith(`${booksDir}/`)) {
throw new Error(
`DATA_DIR (${dataDir}) must not live inside BOOKS_DIR (${booksDir}); the library mount is read-only.`,
);
}
return {
booksDir,
dataDir,
host: process.env.HOST ?? '0.0.0.0',
port: envInt('PORT', 8080),
jwtSecret: loadOrCreateSecret(dataDir),
accessTokenTtl: envInt('ACCESS_TOKEN_TTL', 60 * 60 * 24),
refreshTokenTtl: envInt('REFRESH_TOKEN_TTL', 60 * 60 * 24 * 365),
scanInterval: envInt('SCAN_INTERVAL', 60 * 30),
watchInterval: envInt('WATCH_INTERVAL', 60),
logLevel: process.env.LOG_LEVEL ?? 'info',
publicUrl: process.env.PUBLIC_URL ?? '',
};
}
+58
View File
@@ -0,0 +1,58 @@
import { DatabaseSync } from 'node:sqlite';
import { join } from 'node:path';
import type { AppConfig } from '../config/index.ts';
import { SCHEMA_SQL } from './schema.ts';
/**
* Thin wrapper over the built-in node:sqlite driver.
*
* We deliberately avoid better-sqlite3: it needs a native toolchain at install
* time, which inflates the image and breaks the "one command to run it"
* promise (§8.1). node:sqlite ships with Node itself.
*/
export type SqlValue = string | number | bigint | null | Uint8Array;
export type Row = Record<string, SqlValue>;
export class Db {
private readonly raw: DatabaseSync;
constructor(path: string) {
this.raw = new DatabaseSync(path);
this.raw.exec('PRAGMA journal_mode = WAL;');
this.raw.exec('PRAGMA foreign_keys = ON;');
this.raw.exec(SCHEMA_SQL);
}
get<T = Row>(sql: string, ...params: SqlValue[]): T | undefined {
return this.raw.prepare(sql).get(...params) as T | undefined;
}
all<T = Row>(sql: string, ...params: SqlValue[]): T[] {
return this.raw.prepare(sql).all(...params) as T[];
}
run(sql: string, ...params: SqlValue[]): void {
this.raw.prepare(sql).run(...params);
}
/** Runs `fn` inside a transaction, rolling back on any throw. */
transaction<T>(fn: () => T): T {
this.raw.exec('BEGIN');
try {
const result = fn();
this.raw.exec('COMMIT');
return result;
} catch (err) {
this.raw.exec('ROLLBACK');
throw err;
}
}
close(): void {
this.raw.close();
}
}
export function openDatabase(config: AppConfig): Db {
return new Db(join(config.dataDir, 'reader.db'));
}
+125
View File
@@ -0,0 +1,125 @@
/**
* Schema is intentionally kept in plain SQL so that upgrades are reviewable and
* backward compatible (product design §8.3: self-hosted users do not upgrade
* promptly, so the on-disk format must stay additive).
*/
export const SCHEMA_SQL = `
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
display_name TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('admin', 'member')),
disabled INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS refresh_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
device TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
revoked_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_refresh_user ON refresh_tokens(user_id);
-- The stable book identity. NEVER key books by path: a rename or a move of the
-- file would otherwise wipe the reader's progress and highlights, which is the
-- single most common complaint about self-hosted libraries.
CREATE TABLE IF NOT EXISTS books (
id TEXT PRIMARY KEY, -- stable identity: dc:identifier + content hash
identifier TEXT, -- EPUB dc:identifier, when present
content_hash TEXT NOT NULL, -- sha256 of the file bytes
format TEXT NOT NULL, -- epub | pdf | unknown
title TEXT NOT NULL DEFAULT '',
author TEXT NOT NULL DEFAULT '',
publisher TEXT NOT NULL DEFAULT '',
language TEXT NOT NULL DEFAULT '',
isbn TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
series TEXT NOT NULL DEFAULT '',
series_index REAL,
tags TEXT NOT NULL DEFAULT '[]',-- JSON array
pubdate TEXT NOT NULL DEFAULT '',
cover_path TEXT, -- relative path inside DATA_DIR/covers
file_size INTEGER NOT NULL DEFAULT 0,
page_count INTEGER,
meta_json TEXT NOT NULL DEFAULT '{}',-- full raw metadata, round-trippable
source TEXT NOT NULL DEFAULT 'embedded', -- embedded | filename | manual | provider:*
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_books_hash ON books(content_hash);
CREATE INDEX IF NOT EXISTS idx_books_identifier ON books(identifier);
-- One file on disk may contain exactly one book here; several rows may point at
-- the same book id when the library holds duplicate copies.
CREATE TABLE IF NOT EXISTS book_files (
id TEXT PRIMARY KEY,
book_id TEXT NOT NULL REFERENCES books(id) ON DELETE CASCADE,
rel_path TEXT NOT NULL UNIQUE,
size INTEGER NOT NULL,
mtime_ms INTEGER NOT NULL,
inode TEXT NOT NULL DEFAULT '',
missing INTEGER NOT NULL DEFAULT 0,
first_seen INTEGER NOT NULL,
last_seen INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_files_book ON book_files(book_id);
-- Per-user library state. Kept separate from books so that a rescan can never
-- clobber a user's own edits.
CREATE TABLE IF NOT EXISTS user_books (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
book_id TEXT NOT NULL REFERENCES books(id) ON DELETE CASCADE,
added_at INTEGER NOT NULL,
hidden INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, book_id)
);
-- Reading progress: one row per (user, book), last-writer-wins on updated_at.
CREATE TABLE IF NOT EXISTS reading_progress (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
book_id TEXT NOT NULL REFERENCES books(id) ON DELETE CASCADE,
locator TEXT NOT NULL DEFAULT '', -- opaque to the server: CFI / page / scroll offset
percentage REAL NOT NULL DEFAULT 0,
chapter_title TEXT NOT NULL DEFAULT '',
device TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL,
PRIMARY KEY (user_id, book_id)
);
CREATE INDEX IF NOT EXISTS idx_progress_updated ON reading_progress(user_id, updated_at);
-- Notes, highlights and bookmarks. deleted is a tombstone rather than a hard
-- delete so that an offline client cannot resurrect a note it removed elsewhere.
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
book_id TEXT NOT NULL REFERENCES books(id) ON DELETE CASCADE,
type TEXT NOT NULL CHECK (type IN ('note', 'highlight', 'bookmark')),
locator TEXT NOT NULL DEFAULT '',
text TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL,
deleted INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_notes_user_book ON notes(user_id, book_id);
CREATE INDEX IF NOT EXISTS idx_notes_updated ON notes(user_id, updated_at);
-- Highest-priority override layer: user manual edits. Never auto overwritten.
CREATE TABLE IF NOT EXISTS metadata_overrides (
book_id TEXT NOT NULL REFERENCES books(id) ON DELETE CASCADE,
field TEXT NOT NULL,
value TEXT NOT NULL,
updated_by TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL,
PRIMARY KEY (book_id, field)
);
`;
+37
View File
@@ -0,0 +1,37 @@
import Fastify, { type FastifyInstance } from 'fastify';
import type { AppContext } from './context.ts';
import { registerErrorHandler } from './errors.ts';
import { registerAuthRoutes } from './routes/auth.ts';
import { registerLibraryRoutes } from './routes/library.ts';
import { registerSyncRoutes } from './routes/sync.ts';
export function buildApp(ctx: AppContext): FastifyInstance {
const app = Fastify({
logger: { level: ctx.config.logLevel },
// Book bodies are streamed straight from disk, so the default 1MB body
// limit is fine and protects the server from oversized sync payloads.
bodyLimit: 8 * 1024 * 1024,
trustProxy: true,
});
registerErrorHandler(app);
// Permissive CORS by default: a self-hosted reader is expected to be reached
// from a LAN address, a reverse proxy and possibly a local file origin. There
// are no cookies in the design, so this does not widen the attack surface to
// the extent a cookie-based session would.
app.addHook('onRequest', async (request, reply) => {
reply.header('access-control-allow-origin', request.headers.origin ?? '*');
reply.header('access-control-allow-headers', 'authorization, content-type');
reply.header('access-control-allow-methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
reply.header('vary', 'origin');
});
app.options('/*', async (_request, reply) => reply.status(204).send());
registerAuthRoutes(app, ctx);
registerLibraryRoutes(app, ctx);
registerSyncRoutes(app, ctx);
return app;
}
+33
View File
@@ -0,0 +1,33 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import type { AppContext } from './context.ts';
import { verifyAccessToken } from '../services/tokens.ts';
import { toPublicUser } from '../services/users.ts';
import { forbidden, unauthorized } from '../lib/errors.ts';
/**
* Resolves the bearer token into `request.currentUser`.
*
* A deleted or disabled account must lose access immediately, so the user is
* re-read from the database on every request instead of trusting the claims
* embedded in the token.
*/
export function authenticate(ctx: AppContext) {
return async function preHandler(request: FastifyRequest, _reply: FastifyReply): Promise<void> {
const header = request.headers.authorization;
if (!header?.startsWith('Bearer ')) throw unauthorized('missing bearer token', 'NO_TOKEN');
const payload = verifyAccessToken(ctx.config, header.slice('Bearer '.length).trim());
const row = ctx.users.byId(payload.sub);
if (!row) throw unauthorized('account no longer exists', 'TOKEN_INVALID');
if (row.disabled === 1) throw forbidden('account disabled', 'ACCOUNT_DISABLED');
request.currentUser = toPublicUser(row);
};
}
export function requireAdmin(request: FastifyRequest): void {
if (request.currentUser?.role !== 'admin') throw forbidden('admin role required', 'ADMIN_REQUIRED');
}
export function currentUser(request: FastifyRequest) {
if (!request.currentUser) throw unauthorized();
return request.currentUser;
}
+24
View File
@@ -0,0 +1,24 @@
import type { FastifyBaseLogger } from 'fastify';
import type { Db } from '../db/index.ts';
import type { AppConfig } from '../config/index.ts';
import type { Scanner } from '../indexer/scanner.ts';
import type { UserService, PublicUser } from '../services/users.ts';
import type { ShelfService } from '../services/shelf.ts';
import type { SyncService } from '../services/sync.ts';
export interface AppContext {
config: AppConfig;
db: Db;
scanner: Scanner;
users: UserService;
shelf: ShelfService;
sync: SyncService;
log: FastifyBaseLogger;
}
declare module 'fastify' {
interface FastifyRequest {
/** Populated by the auth preHandler; absent on public routes. */
currentUser?: PublicUser;
}
}
+33
View File
@@ -0,0 +1,33 @@
import type { FastifyInstance } from 'fastify';
import { AppError } from '../lib/errors.ts';
/**
* Single error serialiser. Responses intentionally keep the same shape across
* every endpoint so old clients keep working as the API grows (§8.3).
*/
export function registerErrorHandler(app: FastifyInstance): void {
app.setErrorHandler((error, request, reply) => {
if (error instanceof AppError) {
reply.status(error.statusCode).send({ error: { code: error.code, message: error.message } });
return;
}
const fastifyError = error as { statusCode?: number; message?: string };
const status = fastifyError.statusCode ?? 500;
if (status >= 500) {
request.log.error({ err: error }, 'unhandled error');
reply.status(status).send({ error: { code: 'INTERNAL', message: 'internal server error' } });
return;
}
// Validation and content-type failures land here; they are the client's
// fault, so the message is passed through verbatim.
reply.status(status).send({
error: { code: 'BAD_REQUEST', message: fastifyError.message || 'bad request' },
});
});
app.setNotFoundHandler((request, reply) => {
reply.status(404).send({
error: { code: 'NOT_FOUND', message: `no route for ${request.method} ${request.url}` },
});
});
}
+73
View File
@@ -0,0 +1,73 @@
import type { FastifyInstance } from 'fastify';
import type { AppContext } from '../context.ts';
import { authenticate, currentUser } from '../auth.ts';
import { badRequest } from '../../lib/errors.ts';
interface Credentials {
username?: string;
password?: string;
displayName?: string;
}
export function registerAuthRoutes(app: FastifyInstance, ctx: AppContext): void {
const authenticateFn = authenticate(ctx);
app.post('/api/v1/auth/register', async (request, reply) => {
const body = (request.body ?? {}) as Credentials;
if (!body.username || !body.password) throw badRequest('username and password are required');
if (!ctx.users.canRegisterPublicly()) {
throw badRequest('public registration is disabled on this instance', 'REGISTRATION_DISABLED');
}
const user = await ctx.users.create({
username: body.username,
password: body.password,
...(body.displayName !== undefined ? { displayName: body.displayName } : {}),
});
const session = await ctx.users.login(body.username, body.password, request.headers['user-agent'] ?? '');
reply.status(201);
return { user, session };
});
app.post('/api/v1/auth/login', async (request) => {
const body = (request.body ?? {}) as Credentials;
if (!body.username || !body.password) throw badRequest('username and password are required');
const session = await ctx.users.login(body.username, body.password, request.headers['user-agent'] ?? '');
return session;
});
app.post('/api/v1/auth/refresh', async (request) => {
const body = (request.body ?? {}) as { refreshToken?: string };
if (!body.refreshToken) throw badRequest('refreshToken is required');
return ctx.users.refresh(body.refreshToken);
});
app.post('/api/v1/auth/logout', async (request) => {
const body = (request.body ?? {}) as { refreshToken?: string };
// Logout is idempotent: a client that is already signed out must not error.
if (body.refreshToken) ctx.users.revokeByToken(body.refreshToken);
return { ok: true };
});
app.get('/api/v1/auth/me', { preHandler: authenticateFn }, async (request) => {
return { user: currentUser(request) };
});
app.post('/api/v1/auth/password', { preHandler: authenticateFn }, async (request) => {
const user = currentUser(request);
const body = (request.body ?? {}) as { currentPassword?: string; newPassword?: string };
if (!body.currentPassword || !body.newPassword) throw badRequest('currentPassword and newPassword are required');
await ctx.users.changePassword(user.id, body.currentPassword, body.newPassword);
return { ok: true };
});
app.get('/api/v1/instance', async () => {
// Advertised so a client can tell a fresh instance from a claimed one
// without attempting registration.
return {
name: 'reader',
apiVersion: 1,
registrationOpen: ctx.users.canRegisterPublicly(),
userCount: ctx.users.count(),
};
});
}
+211
View File
@@ -0,0 +1,211 @@
import type { FastifyInstance } from 'fastify';
import type { AppContext } from '../context.ts';
import { authenticate, currentUser, requireAdmin } from '../auth.ts';
import { badRequest, notFound } from '../../lib/errors.ts';
import { assertSafeRel, resolveInside } from '../../lib/paths.ts';
import { createReadStream, existsSync } from 'node:fs';
import { stat } from 'node:fs/promises';
import { extname, join } from 'node:path';
const MIME_BY_EXT: Record<string, string> = {
'.epub': 'application/epub+zip',
'.pdf': 'application/pdf',
};
export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): void {
const auth = authenticate(ctx);
app.get('/api/v1/library/facets', { preHandler: auth }, async (request) => {
const user = currentUser(request);
return ctx.shelf.facets(user.id);
});
app.get('/api/v1/books', { preHandler: auth }, async (request) => {
const user = currentUser(request);
const q = request.query as Record<string, string | undefined>;
return ctx.shelf.list(user.id, {
...(q.search !== undefined ? { search: q.search } : {}),
...(q.author !== undefined ? { author: q.author } : {}),
...(q.series !== undefined ? { series: q.series } : {}),
...(q.tag !== undefined ? { tag: q.tag } : {}),
...(q.format !== undefined ? { format: q.format } : {}),
...(q.sort !== undefined ? { sort: q.sort as 'title' } : {}),
...(q.order !== undefined ? { order: q.order as 'asc' } : {}),
...(q.page !== undefined ? { page: Number.parseInt(q.page, 10) || 1 } : {}),
...(q.pageSize !== undefined ? { pageSize: Number.parseInt(q.pageSize, 10) || 50 } : {}),
});
});
app.get('/api/v1/books/:id', { preHandler: auth }, async (request) => {
const user = currentUser(request);
const { id } = request.params as { id: string };
const book = ctx.shelf.get(user.id, id);
const progress = ctx.sync.progressFor(user.id, id);
return { book, progress };
});
/**
* The manifest a renderer needs before it can lay out a chapter. Kept
* deliberately format-neutral so PDF support can reuse the same contract.
*/
app.get('/api/v1/books/:id/manifest', { preHandler: auth }, async (request) => {
const user = currentUser(request);
const { id } = request.params as { id: string };
const book = ctx.shelf.get(user.id, id);
return {
book,
contentUrl: `/api/v1/books/${id}/content`,
coverUrl: book.coverUrl,
// The list of files backing this book, so a client can tell a duplicate
// copy apart from a genuinely missing file.
files: ctx.db.all<{ rel_path: string; size: number; missing: number }>(
'SELECT rel_path, size, missing FROM book_files WHERE book_id = ? ORDER BY rel_path',
id,
),
};
});
/** Streams the book file. Path traversal is blocked by resolveInside. */
app.get('/api/v1/books/:id/content', { preHandler: auth }, async (request, reply) => {
const user = currentUser(request);
const { id } = request.params as { id: string };
ctx.shelf.get(user.id, id); // authorises access
const file = ctx.db.get<{ rel_path: string }>(
'SELECT rel_path FROM book_files WHERE book_id = ? AND missing = 0 ORDER BY rel_path LIMIT 1',
id,
);
if (!file) throw notFound('no available file for this book', 'FILE_MISSING');
const abs = resolveInside(ctx.config.booksDir, assertSafeRel(file.rel_path));
if (!existsSync(abs)) throw notFound('file is no longer on disk', 'FILE_MISSING');
const info = await stat(abs);
reply.header('content-type', MIME_BY_EXT[extname(abs).toLowerCase()] ?? 'application/octet-stream');
reply.header('content-length', String(info.size));
// Progress and notes are the only synced state, so a book body is immutable
// per hash and can be cached aggressively by the client.
reply.header('etag', `"${id}"`);
reply.header('accept-ranges', 'none');
reply.header('content-disposition', `inline; filename*=UTF-8''${encodeURIComponent(file.rel_path.split('/').pop() ?? 'book')}`);
return reply.send(createReadStream(abs));
});
app.get('/api/v1/books/:id/cover', { preHandler: auth }, async (request, reply) => {
const { id } = request.params as { id: string };
const row = ctx.db.get<{ cover_path: string | null }>('SELECT cover_path FROM books WHERE id = ?', id);
if (!row?.cover_path) throw notFound('no cover for this book', 'NO_COVER');
const abs = resolveInside(ctx.config.dataDir, assertSafeRel(row.cover_path));
if (!existsSync(abs)) throw notFound('cover cache missing', 'NO_COVER');
const ext = extname(abs).toLowerCase();
const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.svg' ? 'image/svg+xml' : 'image/jpeg';
reply.header('content-type', mime);
reply.header('cache-control', 'public, max-age=31536000, immutable');
return reply.send(createReadStream(abs));
});
/**
* Manual metadata completion (§6): the layer that sits above everything else
* and is never auto-overwritten.
*/
app.patch('/api/v1/books/:id/metadata', { preHandler: auth }, async (request) => {
const user = currentUser(request);
const { id } = request.params as { id: string };
ctx.shelf.get(user.id, id);
const patch = (request.body ?? {}) as Record<string, unknown>;
const allowed = [
'title', 'author', 'publisher', 'language', 'isbn', 'description',
'series', 'seriesIndex', 'pubdate', 'tags',
];
const filtered: Record<string, unknown> = {};
for (const key of allowed) {
if (patch[key] !== undefined) filtered[key] = patch[key];
}
if (Object.keys(filtered).length === 0) throw badRequest('no editable field supplied');
ctx.shelf.setOverrides(id, filtered, user.id);
return { book: ctx.shelf.get(user.id, id) };
});
/** Undo a manual field, falling back to the embedded/base value. */
app.delete('/api/v1/books/:id/metadata/:field', { preHandler: auth }, async (request) => {
const user = currentUser(request);
const { id, field } = request.params as { id: string; field: string };
ctx.shelf.get(user.id, id);
ctx.shelf.clearOverride(id, field);
return { book: ctx.shelf.get(user.id, id) };
});
// ---- instance administration ----
app.post('/api/v1/library/scan', { preHandler: auth }, async (request) => {
const user = currentUser(request);
requireAdmin(request);
void user;
const result = await ctx.scanner.scan();
return { result };
});
app.get('/api/v1/library/scan', { preHandler: auth }, async () => {
return { progress: ctx.scanner.getProgress() };
});
app.get('/api/v1/library/stats', { preHandler: auth }, async (request) => {
const user = currentUser(request);
const books = ctx.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM user_books WHERE user_id = ? AND hidden = 0', user.id);
const library = ctx.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM books');
const files = ctx.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM book_files WHERE missing = 0');
const missing = ctx.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM book_files WHERE missing = 1');
const formats = ctx.db.all<{ format: string; n: number }>('SELECT format, COUNT(*) AS n FROM books GROUP BY format');
return {
myBooks: books?.n ?? 0,
libraryBooks: library?.n ?? 0,
files: files?.n ?? 0,
missingFiles: missing?.n ?? 0,
formats,
scan: ctx.scanner.getProgress(),
};
});
app.get('/api/v1/admin/users', { preHandler: auth }, async (request) => {
requireAdmin(request);
return { users: ctx.users.list() };
});
app.post('/api/v1/admin/users', { preHandler: auth }, async (request, reply) => {
requireAdmin(request);
const body = (request.body ?? {}) as { username?: string; password?: string; displayName?: string; role?: 'admin' | 'member' };
if (!body.username || !body.password) throw badRequest('username and password are required');
const user = await ctx.users.createAsAdmin({
username: body.username,
password: body.password,
...(body.displayName !== undefined ? { displayName: body.displayName } : {}),
...(body.role !== undefined ? { role: body.role } : {}),
});
reply.status(201);
return { user };
});
app.patch('/api/v1/admin/users/:id', { preHandler: auth }, async (request) => {
const actor = currentUser(request);
requireAdmin(request);
const { id } = request.params as { id: string };
const body = (request.body ?? {}) as { disabled?: boolean; role?: 'admin' | 'member' };
if (id === actor.id && body.disabled) throw badRequest('cannot disable your own account', 'SELF_LOCKOUT');
if (body.disabled !== undefined) ctx.users.setDisabled(id, body.disabled);
if (body.role !== undefined) ctx.users.setRole(id, body.role);
return { users: ctx.users.list() };
});
app.get('/api/v1/providers', { preHandler: auth }, async () => {
const { providers } = await import('../../providers/index.ts');
return { providers: providers.list() };
});
app.get('/api/v1/health', async () => ({
status: 'ok',
booksDir: ctx.config.booksDir,
// Reported so an operator can confirm the mount is where they think it is.
dataDir: ctx.config.dataDir,
version: process.env.npm_package_version ?? '0.1.0',
dataDirIsInsideBooks: ctx.config.dataDir.startsWith(join(ctx.config.booksDir, '/')),
}));
}
+133
View File
@@ -0,0 +1,133 @@
import type { FastifyInstance } from 'fastify';
import type { AppContext } from '../context.ts';
import { authenticate, currentUser } from '../auth.ts';
import { badRequest } from '../../lib/errors.ts';
import type { NoteRecord, ProgressRecord } from '../../services/sync.ts';
export function registerSyncRoutes(app: FastifyInstance, ctx: AppContext): void {
const auth = authenticate(ctx);
/**
* Catch-up pull. A client that has been offline passes the last `serverTime`
* it saw and receives only the deltas, which keeps reconnect cheap on mobile
* networks (§4, 三态切换).
*/
app.get('/api/v1/sync', { preHandler: auth }, async (request) => {
const user = currentUser(request);
const q = request.query as { since?: string; bookId?: string };
const since = q.since ? Number.parseInt(q.since, 10) : 0;
if (!Number.isFinite(since) || since < 0) throw badRequest('since must be a non-negative integer');
return ctx.sync.pull(user.id, since, q.bookId);
});
/**
* Offline merge. Batches are applied in one transaction so a dropped
* connection can never leave progress half written.
*/
app.post('/api/v1/sync', { preHandler: auth }, async (request) => {
const user = currentUser(request);
const body = (request.body ?? {}) as { progress?: unknown; notes?: unknown };
// Validate the shape explicitly. Iterating a non-array would otherwise walk
// a string character by character and report nonsense rejections.
if (body.progress !== undefined && !Array.isArray(body.progress)) {
throw badRequest('progress must be an array', 'BAD_PAYLOAD');
}
if (body.notes !== undefined && !Array.isArray(body.notes)) {
throw badRequest('notes must be an array', 'BAD_PAYLOAD');
}
const progress = (body.progress ?? []) as ProgressRecord[];
const notes = (body.notes ?? []) as NoteRecord[];
if (progress.length === 0 && notes.length === 0) {
throw badRequest('at least one of progress or notes is required', 'BAD_PAYLOAD');
}
if (progress.length + notes.length > 5000) {
throw badRequest('batch too large, split into chunks of 5000', 'BATCH_TOO_LARGE');
}
const result = ctx.sync.push(user.id, { progress, notes });
// Returning the merged state lets the client reconcile in the same round
// trip instead of issuing a follow-up pull.
return { ...result, ...ctx.sync.pull(user.id, 0, undefined), accepted: result.accepted, rejected: result.rejected };
});
app.get('/api/v1/sync/progress/:bookId', { preHandler: auth }, async (request) => {
const user = currentUser(request);
const { bookId } = request.params as { bookId: string };
return { progress: ctx.sync.progressFor(user.id, bookId) };
});
app.put('/api/v1/sync/progress/:bookId', { preHandler: auth }, async (request) => {
const user = currentUser(request);
const { bookId } = request.params as { bookId: string };
const body = (request.body ?? {}) as Partial<ProgressRecord>;
ctx.sync.push(user.id, {
progress: [{
bookId,
locator: body.locator ?? '',
percentage: typeof body.percentage === 'number' ? body.percentage : 0,
chapterTitle: body.chapterTitle ?? '',
device: body.device ?? '',
updatedAt: body.updatedAt ?? Date.now(),
}],
});
return { progress: ctx.sync.progressFor(user.id, bookId) };
});
app.get('/api/v1/notes', { preHandler: auth }, async (request) => {
const user = currentUser(request);
const q = request.query as { bookId?: string; since?: string };
const since = q.since ? Number.parseInt(q.since, 10) : 0;
const pulled = ctx.sync.pull(user.id, since, q.bookId);
return { notes: pulled.notes.filter((n) => !n.deleted), serverTime: pulled.serverTime };
});
app.post('/api/v1/notes', { preHandler: auth }, async (request, reply) => {
const user = currentUser(request);
const body = (request.body ?? {}) as Partial<NoteRecord>;
if (!body.bookId) throw badRequest('bookId is required');
const note: NoteRecord = {
id: body.id ?? crypto.randomUUID(),
bookId: body.bookId,
type: body.type ?? 'note',
locator: body.locator ?? '',
text: body.text ?? '',
comment: body.comment ?? '',
color: body.color ?? '',
updatedAt: body.updatedAt ?? Date.now(),
deleted: false,
};
ctx.sync.push(user.id, { notes: [note] });
reply.status(201);
return { note };
});
/** Deletion is a tombstone so an offline device cannot resurrect the note. */
app.delete('/api/v1/notes/:id', { preHandler: auth }, async (request) => {
const user = currentUser(request);
const { id } = request.params as { id: string };
const existing = ctx.db.get<{ book_id: string; type: string; locator: string; text: string; comment: string; color: string }>(
'SELECT book_id, type, locator, text, comment, color FROM notes WHERE id = ? AND user_id = ?', id, user.id,
);
if (!existing) return { ok: true };
ctx.sync.push(user.id, {
notes: [{
id,
bookId: existing.book_id,
type: existing.type as NoteRecord['type'],
locator: existing.locator,
text: existing.text,
comment: existing.comment,
color: existing.color,
updatedAt: Date.now(),
deleted: true,
}],
});
return { ok: true };
});
app.get('/api/v1/library/continue', { preHandler: auth }, async (request) => {
const user = currentUser(request);
const q = request.query as { limit?: string };
const limit = q.limit ? Math.min(100, Number.parseInt(q.limit, 10) || 20) : 20;
return { items: ctx.sync.recentlyRead(user.id, limit) };
});
}
+221
View File
@@ -0,0 +1,221 @@
import { basename, extname } from 'node:path';
import { collapseWhitespace } from '../lib/text.ts';
export interface FilenameMetadata {
title: string;
author: string;
series: string;
seriesIndex: number | null;
language: string;
}
const AUTHOR_SEPARATORS = [' - ', '—', '–'];
const TRAILING_SEPARATOR = /(?:^|\s)[-—–]+\s*$/;
// Common bracket styles used by Chinese ebook collections: 【】()()[]《》
const SERIES_PATTERN = /(?:【([^】]+)】|\[([^\]]+)\])/;
const CN_AUTHOR_HINT = /著|编著|主编|译/;
const CN_TITLE_HINT = /《|》/;
/**
* Best-effort parse of a book filename.
*
* Supported shapes:
* 《书名》作者.epub
* 书名 - 作者.epub
* 作者 - 书名 (Series 01).epub
* [系列 01] 书名 - 作者.epub
*/
export function parseFilename(relPath: string): FilenameMetadata {
const rawName = basename(relPath, extname(relPath));
let name = collapseWhitespace(rawName);
// Drop download noise that pollutes otherwise clean names.
name = name.replace(/\s*[((\[][^)\])]*(?:libgen|z-?lib|zlibrary|douban|扫描版|影印版)[^)\])]*[)\])]\s*/gi, ' ');
name = collapseWhitespace(name);
let series = '';
let seriesIndex: number | null = null;
const seriesMatch = SERIES_PATTERN.exec(name);
if (seriesMatch) {
// The bracket may wrap just a volume marker, e.g. "[01]", in which case
// there is no name to record and only the index is kept.
const inner = collapseWhitespace(seriesMatch[1] ?? seriesMatch[2] ?? '');
const nameAndIndex = /^(.*?)\s*(\d{1,3})$/.exec(inner);
if (nameAndIndex && nameAndIndex[1]) {
series = collapseWhitespace(nameAndIndex[1]);
seriesIndex = Number.parseInt(nameAndIndex[2]!, 10);
} else if (/^\d{1,3}$/.test(inner)) {
seriesIndex = Number.parseInt(inner, 10);
} else {
series = inner;
}
name = collapseWhitespace(name.replace(seriesMatch[0], ' '));
}
// "(第1卷)" style. The marker is stripped before the title/author split only
// after the split has run, so a name such as "金庸 - 射雕英雄传(第1卷)" still
// distinguishes author from title.
const parenSeries = /[((]\s*([^))]{0,40}?)\s*第\s*(\d{1,3})\s*[卷册部集][))]/.exec(name);
let volumeLabel = '';
if (!series && parenSeries) {
volumeLabel = collapseWhitespace(parenSeries[1] ?? '');
seriesIndex = Number.parseInt(parenSeries[2]!, 10);
// Remove the entire bracketed group up front so the title/author split
// below sees "金庸 - 射雕英雄传" rather than a string with a dangling
// half-open bracket.
name = collapseWhitespace(name.replace(/[((]\s*[^))]*?\s*第\s*\d{1,3}\s*[卷册部集][))]/g, ' '));
}
let title = name;
let author = '';
const bookTitleMatch = /[《《]([^》》]+)[》》]/.exec(name);
if (bookTitleMatch) {
title = collapseWhitespace(bookTitleMatch[1]!);
const rest = collapseWhitespace(name.replace(bookTitleMatch[0], ' '));
author = cleanAuthor(rest);
} else {
for (const sep of AUTHOR_SEPARATORS) {
const index = name.indexOf(sep);
if (index <= 0) continue;
const left = collapseWhitespace(name.slice(0, index));
const right = collapseWhitespace(name.slice(index + sep.length));
if (!left || !right) continue;
// Both "书名 - 作者" and "作者 - 书名" are common in the wild, and the two
// are indistinguishable without a marker. Rather than guess (and misfile
// a real book), only apply a split when something proves the direction:
//
// 《书名》 -> that side is the title
// 著 / 编 / 译 -> that side is the author
//
// Otherwise the whole string is kept as the title and the author is left
// empty, which the client surfaces as an incomplete field for the reader
// to fill in. A missing author is a far cheaper failure than a wrong one.
if (CN_AUTHOR_HINT.test(right) && !CN_AUTHOR_HINT.test(left)) {
title = left;
author = cleanAuthor(right);
} else if (CN_AUTHOR_HINT.test(left) && !CN_AUTHOR_HINT.test(right)) {
title = right;
author = cleanAuthor(left);
} else if (CN_TITLE_HINT.test(right) && !CN_TITLE_HINT.test(left)) {
title = extractTitleMarker(right) ?? right;
author = cleanAuthor(left);
} else if (CN_TITLE_HINT.test(left) && !CN_TITLE_HINT.test(right)) {
title = extractTitleMarker(left) ?? left;
author = cleanAuthor(right);
} else {
// Latin titles are unambiguous: "Title - Author" is the near-universal
// convention for English-language files.
const leftLatin = isLatinName(left);
const rightLatin = isLatinName(right);
if (leftLatin && !rightLatin) {
title = right;
author = cleanAuthor(left);
} else if (rightLatin && !leftLatin) {
title = left;
author = cleanAuthor(right);
} else if (leftLatin && rightLatin) {
title = left;
author = cleanAuthor(right);
} else if (looksLikeCjkName(left) && !looksLikeCjkName(right)) {
title = right;
author = cleanAuthor(left);
} else if (looksLikeCjkName(right) && !looksLikeCjkName(left)) {
title = left;
author = cleanAuthor(right);
} else {
// Still ambiguous. Keeping the whole string as the title is the safe
// failure: a missing author is far cheaper than a wrong one, and the
// client flags it as incomplete for the reader to fill in.
title = collapseWhitespace(`${left} - ${right}`);
author = '';
}
}
break;
}
if (!author) {
const parenAuthor = /[((]([^))]{1,30})[))]\s*$/.exec(name);
if (parenAuthor && !CN_AUTHOR_HINT.test(parenAuthor[1]!)) {
author = cleanAuthor(parenAuthor[1]!);
title = collapseWhitespace(name.replace(parenAuthor[0], ' '));
}
}
}
const language = detectLanguage(title);
// A bare volume marker means the work is itself the series, so the title
// (now free of the marker) supplies the series name.
if (!series && parenSeries) series = volumeLabel || title;
return {
title: title || collapseWhitespace(rawName),
author,
series,
seriesIndex,
language,
};
}
/**
* CJK surnames, used only as a tie-breaker between two otherwise ambiguous
* sides of a separator. Kept short on purpose: a wrong entry here misfiles a
* book, so the list holds the unambiguous single-character surnames.
*/
const CJK_SURNAME_CHARS =
'赵钱孙李周吴郑王冯陈褚卫蒋沈韩杨朱秦尤许何吕施张孔曹严华金魏陶姜戚谢邹喻柏水窦章云苏潘葛奚范彭郎鲁韦昌马苗凤花方俞任袁柳鲍史唐费廉岑薛雷贺倪汤滕殷罗毕郝邬安常乐于时傅皮卞齐康伍余元卜顾孟平黄和穆萧尹姚邵湛汪祁毛禹狄米贝明臧计伏成戴谈宋茅庞熊纪舒屈项祝董梁杜阮蓝闵席季麻强贾路娄危江童颜郭梅盛林刁钟徐邱骆高夏蔡田樊胡凌霍虞万支柯昝管卢莫';
/**
* True when the value plausibly names a person: 2-4 CJK characters beginning
* with a common surname, or a Latin name-shaped string.
*/
function looksLikeCjkName(value: string): boolean {
const trimmed = collapseWhitespace(value);
if (!/^[\u4e00-\u9fff]{2,4}$/.test(trimmed)) return false;
// A string of exactly 2 chars starting with a surname is a name; anything
// longer must also start with one. This keeps "基地" (not a surname) and
// "红楼梦" (3 chars, 红 is not a surname) out.
return CJK_SURNAME_CHARS.includes(trimmed[0]!);
}
/** Latin "Surname, Given" or "Given Surname" shapes. */
function isLatinName(value: string): boolean {
const trimmed = collapseWhitespace(value);
if (/[\u4e00-\u9fff\u3040-\u30ff]/.test(trimmed)) return false;
return /^[A-Z][A-Za-z.'-]*(?:,?\s+[A-Z][A-Za-z.'-]*){0,3}$/.test(trimmed);
}
function extractTitleMarker(value: string): string | null {
const match = /[《《]([^》》]+)[》》]/.exec(value);
return match ? collapseWhitespace(match[1]!) : null;
}
function cleanAuthor(value: string): string {
let cleaned = collapseWhitespace(value);
// Trim a trailing separator of any flavour. "刘慈欣 -《三体》" leaves a bare
// "-" behind because the title marker was stripped before the split.
while (TRAILING_SEPARATOR.test(cleaned)) {
cleaned = collapseWhitespace(cleaned.replace(TRAILING_SEPARATOR, ''));
}
return collapseWhitespace(
cleaned
.replace(/[著编著译校注疏主编]$/u, '')
.replace(/[,,;;]\s*$/u, '')
.replace(/^[\[【((]+|[\]】))]+$/gu, ''),
);
}
/**
* Rough CJK detection. This feeds the `language` field only when the EPUB has
* no dc:language, so a heuristic is acceptable.
*/
export function detectLanguage(input: string): string {
const cjk = (input.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) ?? []).length;
const kana = (input.match(/[\u3040-\u30ff]/g) ?? []).length;
const latin = (input.match(/[A-Za-z]/g) ?? []).length;
const total = cjk + kana + latin;
if (total === 0) return '';
if (kana / total > 0.15) return 'ja';
if (cjk / total > 0.3) return 'zh';
if (latin / total > 0.5) return 'en';
return '';
}
+25
View File
@@ -0,0 +1,25 @@
import { createHash } from 'node:crypto';
/**
* Book identity (product design §6, "主键设计").
*
* The key MUST survive the user renaming or moving files, and MUST collapse
* duplicate copies of the same book into one shelf entry. Therefore:
*
* id = H(identifier ?? '') + ':' + contentHash
*
* When the EPUB carries a dc:identifier it anchors the identity; the content
* hash still participates so that a re-encoded file (different bytes) is
* treated as a distinct copy rather than silently aliasing an existing row.
*
* Never use a file path as the key.
*/
export function computeBookId(identifier: string | null, contentHash: string): string {
const anchor = (identifier ?? '').trim().toLowerCase();
return createHash('sha256').update(`${anchor}\u0000${contentHash}`).digest('hex');
}
/** Stable id for a file record; path based on purpose, since it tracks a file. */
export function computeFileId(relPath: string): string {
return createHash('sha256').update(relPath).digest('hex');
}
+369
View File
@@ -0,0 +1,369 @@
import { createHash } from 'node:crypto';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import JSZip from 'jszip';
import { XMLParser } from 'fast-xml-parser';
import { stripNullBytes } from '../lib/text.ts';
import { parseFilename } from './filename.ts';
export type BookFormat = 'epub' | 'pdf' | 'unknown';
export interface ExtractedMetadata {
title: string;
author: string;
publisher: string;
language: string;
isbn: string;
description: string;
series: string;
seriesIndex: number | null;
tags: string[];
pubdate: string;
identifier: string | null;
/** Which layer produced the current values: embedded | filename. */
source: 'embedded' | 'filename' | 'unknown';
/** Raw extracted fields, kept for round-tripping and manual completion UI. */
raw: Record<string, unknown>;
}
export interface ParsedBookFile {
format: BookFormat;
contentHash: string;
size: number;
pageCount: number | null;
/** Cover bytes, written into DATA_DIR by the caller. Never into BOOKS_DIR. */
cover?: { data: Buffer; contentType: string };
/** Set by the scanner once the cover is persisted under DATA_DIR. */
coverPath?: string | null;
metadata: ExtractedMetadata;
}
const xmlParser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
trimValues: true,
// Keep every field as text; epub metadata is famously inconsistent.
parseTagValue: false,
parseAttributeValue: false,
});
export function sha256(buf: Buffer): string {
return createHash('sha256').update(buf).digest('hex');
}
function asArray<T>(value: T | T[] | undefined): T[] {
if (value === undefined || value === null) return [];
return Array.isArray(value) ? value : [value];
}
function textOf(value: unknown): string {
if (value === undefined || value === null) return '';
if (typeof value === 'string') return value.trim();
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
if (typeof value === 'object') {
const obj = value as Record<string, unknown>;
if ('#text' in obj) return textOf(obj['#text']);
}
return '';
}
function isbn13ChecksumOk(raw: string): boolean {
const digits = raw.replace(/[^0-9]/g, '');
if (digits.length !== 13) return false;
let sum = 0;
for (let i = 0; i < 12; i += 1) sum += Number(digits[i]) * (i % 2 === 0 ? 1 : 3);
return (10 - (sum % 10)) % 10 === Number(digits[12]);
}
export function normalizeIsbn(candidate: string): string {
const cleaned = candidate.replace(/^urn:isbn:/i, '').replace(/[^0-9Xx]/g, '').toUpperCase();
if (cleaned.length === 13 && isbn13ChecksumOk(cleaned)) return cleaned;
if (cleaned.length === 10) {
let sum = 0;
for (let i = 0; i < 10; i += 1) {
const ch = cleaned[i]!;
const value = ch === 'X' ? 10 : Number(ch);
if (Number.isNaN(value)) return '';
sum += value * (10 - i);
}
return sum % 11 === 0 ? cleaned : '';
}
return '';
}
/**
* EPUB metadata extraction.
*
* Priority chain from the product design §6: embedded metadata wins over
* filename parsing, and manual edits win over both (applied later, in the
* override layer — not here).
*/
export async function parseEpub(buf: Buffer, relPath: string): Promise<ExtractedMetadata> {
const zip = await JSZip.loadAsync(buf);
const containerFile = zip.file('META-INF/container.xml');
let opfPath = '';
if (containerFile) {
const containerXml = await containerFile.async('string');
const container = xmlParser.parse(containerXml) as Record<string, any>;
const rootfiles = asArray(container?.container?.rootfiles?.rootfile);
const full = rootfiles.map((r) => r?.['@_full-path']).find((p) => typeof p === 'string' && p);
if (full) opfPath = full;
}
if (!opfPath) {
opfPath = Object.keys(zip.files).find((name) => name.toLowerCase().endsWith('.opf')) ?? '';
}
if (!opfPath) throw new Error('epub has no OPF package document');
const opfFile = zip.file(opfPath);
if (!opfFile) throw new Error(`epub OPF not found at ${opfPath}`);
const opf = xmlParser.parse(await opfFile.async('string')) as Record<string, any>;
const pkg = opf?.package ?? {};
const metadata = pkg?.metadata ?? {};
const title = textOf(metadata['dc:title'] ?? metadata.title);
const creators = asArray(metadata['dc:creator'] ?? metadata.creator).map(textOf).filter(Boolean);
const publisher = textOf(metadata['dc:publisher'] ?? metadata.publisher);
const language = textOf(metadata['dc:language'] ?? metadata.language);
const description = textOf(metadata['dc:description'] ?? metadata.description);
const pubdate = textOf(metadata['dc:date'] ?? metadata.date);
const identifiers = asArray(metadata['dc:identifier'] ?? metadata.identifier).map(textOf).filter(Boolean);
let isbn = '';
for (const id of identifiers) {
const candidate = normalizeIsbn(id);
if (candidate) {
isbn = candidate;
break;
}
}
if (!isbn) {
for (const id of identifiers) {
const match = /(\d{9}[\dXx]|\d{13})/.exec(id.replace(/[^0-9Xx]/g, ' '));
if (match) {
const candidate = normalizeIsbn(match[1]!);
if (candidate) {
isbn = candidate;
break;
}
}
}
}
const tags = asArray(metadata['dc:subject'] ?? metadata.subject).map(textOf).filter(Boolean);
let series = '';
let seriesIndex: number | null = null;
const calibreSeries = metadata['meta'];
for (const entry of asArray(calibreSeries)) {
if (typeof entry !== 'object' || entry === null) continue;
const obj = entry as Record<string, unknown>;
const name = String(obj['@_name'] ?? '');
const content = textOf(obj['@_content'] ?? obj['#text']);
if (name === 'calibre:series' && content && !series) series = content;
if (name === 'calibre:series_index' && content) {
const parsed = Number.parseFloat(content);
if (Number.isFinite(parsed)) seriesIndex = parsed;
}
}
const belongsTo = metadata['meta'] ? [] : [];
void belongsTo;
// EPUB 3 collection/group-position, the modern equivalent of calibre:series.
const collection = metadata['belongs-to-collection'] ?? metadata['meta'];
for (const entry of asArray(collection)) {
if (typeof entry === 'string') {
if (!series) series = entry.trim();
continue;
}
if (typeof entry !== 'object' || entry === null) continue;
const obj = entry as Record<string, any>;
const role = String(obj['@_property'] ?? obj['@_name'] ?? '');
if (role.includes('belongs-to-collection') || role === 'calibre:series') {
const value = textOf(obj['#text'] ?? obj['@_content']);
if (value && !series) series = value;
}
}
// The identifier is not the primary key by itself: it is missing from plenty
// of pirated/self-made epubs and duplicated across re-releases. The caller
// combines it with the content hash.
const identifier = identifiers[0] ?? null;
return {
title,
author: creators.join(', '),
publisher,
language,
isbn,
description,
series,
seriesIndex,
tags,
pubdate,
identifier,
source: 'embedded',
raw: { metadata, opfPath, identifiers },
};
}
function pdfInfo(buf: Buffer): { title: string; author: string; pageCount: number | null } {
const head = buf.subarray(0, Math.min(buf.length, 2 * 1024 * 1024)).toString('latin1');
const readField = (key: string): string => {
const match = new RegExp(`/${key}\\s*\\(((?:[^()\\\\]|\\\\.)*)\\)`).exec(head);
return match ? stripNullBytes(match[1]!).trim() : '';
};
let pageCount: number | null = null;
const countMatch = /\/Type\s*\/Pages[\s\S]{0,200}?\/Count\s+(\d+)/.exec(head);
if (countMatch) {
const parsed = Number.parseInt(countMatch[1]!, 10);
if (Number.isFinite(parsed)) pageCount = parsed;
}
return { title: readField('Title'), author: readField('Author'), pageCount };
}
/**
* Parse an arbitrary library file. Never writes to disk.
*/
export async function parseBookFile(buf: Buffer, relPath: string): Promise<ParsedBookFile> {
const contentHash = sha256(buf);
const size = buf.byteLength;
const lower = relPath.toLowerCase();
if (lower.endsWith('.epub')) {
try {
const metadata = await parseEpub(buf, relPath);
// A broken or huge cover must never fail the whole book.
const cover = await extractEpubCover(buf).catch(() => undefined);
return { format: 'epub', contentHash, size, pageCount: null, metadata, cover };
} catch (err) {
const fallback = filenameMetadata(relPath);
fallback.raw = { parseError: err instanceof Error ? err.message : String(err) };
return { format: 'epub', contentHash, size, pageCount: null, metadata: fallback };
}
}
if (lower.endsWith('.pdf')) {
const info = pdfInfo(buf);
const fallback = filenameMetadata(relPath);
const metadata: ExtractedMetadata = {
...fallback,
title: info.title || fallback.title,
author: info.author || fallback.author,
source: info.title || info.author ? 'embedded' : fallback.source,
};
return { format: 'pdf', contentHash, size, pageCount: info.pageCount, metadata };
}
return {
format: 'unknown',
contentHash,
size,
pageCount: null,
metadata: filenameMetadata(relPath),
};
}
/**
* Filename parsing. Only used to fill gaps left by embedded metadata, and it is
* always the weakest layer of the priority chain.
*/
export function filenameMetadata(relPath: string): ExtractedMetadata {
const parsed = parseFilename(relPath);
return {
title: parsed.title,
author: parsed.author,
publisher: '',
language: parsed.language,
isbn: '',
description: '',
series: parsed.series,
seriesIndex: parsed.seriesIndex,
tags: [],
pubdate: '',
identifier: null,
source: 'filename',
raw: { filename: relPath, parsed },
};
}
/**
* Locate and read the cover image from an EPUB.
*
* Covers are cached into DATA_DIR/covers by the caller; the library directory is
* never written to.
*/
export async function extractEpubCover(buf: Buffer): Promise<{ data: Buffer; contentType: string } | undefined> {
const zip = await JSZip.loadAsync(buf);
const opfPath = await findOpfPath(zip);
if (!opfPath) return undefined;
const opfFile = zip.file(opfPath);
if (!opfFile) return undefined;
const opf = xmlParser.parse(await opfFile.async('string')) as Record<string, any>;
const metadata = opf?.package?.metadata ?? {};
const manifest = asArray(opf?.package?.manifest?.item);
const coverId = asArray(metadata.meta)
.map((entry) => (typeof entry === 'object' && entry !== null ? (entry as Record<string, unknown>) : {}))
.find((entry) => String(entry['@_name'] ?? '') === 'cover')?.['@_content'];
const isImage = (item: Record<string, any>) =>
String(item['@_media-type'] ?? '').startsWith('image/');
let item = coverId
? manifest.find((entry) => entry?.['@_id'] === coverId)
: undefined;
if (!item) {
item = manifest.find(
(entry) => isImage(entry) && /cover/i.test(String(entry?.['@_id'] ?? entry?.['@_href'] ?? '')),
);
}
if (!item) {
item = manifest.find((entry) => isImage(entry) && /\.(jpe?g|png|gif|webp)$/i.test(String(entry?.['@_href'] ?? '')));
}
if (!item) return undefined;
const href = String(item['@_href'] ?? '');
const baseDir = opfPath.includes('/') ? opfPath.slice(0, opfPath.lastIndexOf('/') + 1) : '';
const coverPath = decodeURIComponent(`${baseDir}${href}`);
const file = zip.file(coverPath) ?? zip.file(href);
if (!file) return undefined;
const data = Buffer.from(await file.async('uint8array'));
const contentType = String(item['@_media-type'] ?? 'image/jpeg');
return { data, contentType };
}
async function findOpfPath(zip: JSZip): Promise<string> {
const containerFile = zip.file('META-INF/container.xml');
if (containerFile) {
const container = xmlParser.parse(await containerFile.async('string')) as Record<string, any>;
const full = asArray(container?.container?.rootfiles?.rootfile)
.map((r) => r?.['@_full-path'])
.find((p) => typeof p === 'string' && p);
if (full) return full;
}
return Object.keys(zip.files).find((name) => name.toLowerCase().endsWith('.opf')) ?? '';
}
export async function saveCover(
dataDir: string,
bookId: string,
cover: { data: Buffer; contentType: string },
): Promise<string> {
const ext = cover.contentType.includes('png')
? '.png'
: cover.contentType.includes('svg')
? '.svg'
: cover.contentType.includes('gif')
? '.gif'
: '.jpg';
const rel = join('covers', `${bookId}${ext}`);
const abs = join(dataDir, rel);
await mkdir(dirname(abs), { recursive: true });
await writeFile(abs, cover.data);
return rel;
}
export async function readFileBuffer(absPath: string): Promise<Buffer> {
return readFile(absPath);
}
+345
View File
@@ -0,0 +1,345 @@
import { createHash } from 'node:crypto';
import { readdir, stat, readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { Db } from '../db/index.ts';
import type { AppConfig } from '../config/index.ts';
import { computeBookId, computeFileId } from './identity.ts';
import { parseBookFile, saveCover, type ExtractedMetadata, type ParsedBookFile } from './metadata.ts';
import { toRelative } from '../lib/paths.ts';
import { collapseWhitespace, safeJsonParse } from '../lib/text.ts';
export const SUPPORTED_EXTENSIONS = new Set(['.epub', '.pdf']);
const SKIP_DIRS = new Set(['.git', '@eaDir', '#recycle', '.DS_Store', 'lost+found']);
export interface ScanProgress {
running: boolean;
startedAt: number | null;
finishedAt: number | null;
scanned: number;
added: number;
updated: number;
unchanged: number;
removed: number;
failed: number;
lastError: string | null;
}
export interface ScanResult extends ScanProgress {
durationMs: number;
}
interface FileEntry {
relPath: string;
size: number;
mtimeMs: number;
}
interface StoredFile {
id: string;
book_id: string;
rel_path: string;
size: number;
mtime_ms: number;
missing: number;
book_content_hash: string;
book_identifier: string | null;
}
export class Scanner {
private progress: ScanProgress = {
running: false,
startedAt: null,
finishedAt: null,
scanned: 0,
added: 0,
updated: 0,
unchanged: 0,
removed: 0,
failed: 0,
lastError: null,
};
private current: Promise<ScanResult> | null = null;
constructor(
private readonly db: Db,
private readonly config: AppConfig,
private readonly log: { info: (o: unknown, m?: string) => void; warn: (o: unknown, m?: string) => void },
) {}
getProgress(): ScanProgress {
return { ...this.progress };
}
/** Concurrent calls share one run instead of stampeding the disk. */
async scan(): Promise<ScanResult> {
if (this.current) return this.current;
this.current = this.runScan().finally(() => {
this.current = null;
});
return this.current;
}
private async runScan(): Promise<ScanResult> {
const startedAt = Date.now();
this.progress = {
running: true,
startedAt,
finishedAt: null,
scanned: 0,
added: 0,
updated: 0,
unchanged: 0,
removed: 0,
failed: 0,
lastError: null,
};
try {
const found = await this.walk(this.config.booksDir);
const stored = this.loadStoredFiles();
const storedByPath = new Map(stored.map((row) => [row.rel_path, row]));
const seen = new Set<string>();
for (const entry of found) {
seen.add(entry.relPath);
this.progress.scanned += 1;
const previous = storedByPath.get(entry.relPath);
try {
await this.indexFile(entry, previous);
} catch (err) {
this.progress.failed += 1;
this.progress.lastError = err instanceof Error ? err.message : String(err);
this.log.warn({ err, relPath: entry.relPath }, 'failed to index file');
}
}
for (const row of stored) {
if (seen.has(row.rel_path)) {
if (row.missing === 1) {
this.db.run('UPDATE book_files SET missing = 0 WHERE id = ?', row.id);
}
continue;
}
if (row.missing === 0) {
this.db.run('UPDATE book_files SET missing = 1 WHERE id = ?', row.id);
this.progress.removed += 1;
}
}
// A rename or a move leaves the old rel_path row behind. Once the book is
// reachable through a live file, that stale row is pure noise: drop it so
// the file table does not grow without bound across re-organisations.
this.db.run(
`DELETE FROM book_files
WHERE missing = 1
AND book_id IN (SELECT book_id FROM book_files WHERE missing = 0)`,
);
// Only drop books that no longer have any live file AND that a user has
// not added to a shelf manually; deleting a shelf entry the reader curated
// would be a worse failure than showing a temporarily unavailable book.
this.db.run(
`DELETE FROM books
WHERE id NOT IN (SELECT DISTINCT book_id FROM book_files WHERE missing = 0)
AND id NOT IN (SELECT DISTINCT book_id FROM user_books)`,
);
this.progress.running = false;
this.progress.finishedAt = Date.now();
this.log.info(
{
scanned: this.progress.scanned,
added: this.progress.added,
updated: this.progress.updated,
unchanged: this.progress.unchanged,
removed: this.progress.removed,
failed: this.progress.failed,
durationMs: this.progress.finishedAt - startedAt,
},
'library scan finished',
);
return { ...this.progress, durationMs: this.progress.finishedAt - startedAt };
} catch (err) {
this.progress.running = false;
this.progress.finishedAt = Date.now();
this.progress.lastError = err instanceof Error ? err.message : String(err);
throw err;
}
}
/** Recursive walk that never follows symlinks out of the library root. */
private async walk(root: string): Promise<FileEntry[]> {
const out: FileEntry[] = [];
const visit = async (dir: string): Promise<void> => {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch (err) {
this.log.warn({ err, dir }, 'unreadable directory, skipping');
return;
}
for (const entry of entries) {
if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name)) continue;
const abs = join(dir, entry.name);
if (entry.isDirectory()) {
await visit(abs);
continue;
}
// Symlinks are not followed: a link pointing outside the mount would
// let the server read files the operator never shared with it.
if (!entry.isFile()) continue;
const ext = entry.name.slice(entry.name.lastIndexOf('.')).toLowerCase();
if (!SUPPORTED_EXTENSIONS.has(ext)) continue;
try {
const info = await stat(abs);
out.push({ relPath: toRelative(root, abs), size: info.size, mtimeMs: Math.floor(info.mtimeMs) });
} catch (err) {
this.log.warn({ err, abs }, 'stat failed, skipping');
}
}
};
await visit(root);
return out;
}
private loadStoredFiles(): StoredFile[] {
return this.db.all<StoredFile>(
`SELECT f.id, f.book_id, f.rel_path, f.size, f.mtime_ms, f.missing,
b.content_hash AS book_content_hash, b.identifier AS book_identifier
FROM book_files f JOIN books b ON b.id = f.book_id`,
);
}
/**
* Two stage change detection (§6, 增量扫描):
* 1. mtime + size -> cheap, skips the vast majority of files
* 2. content hash -> authoritative, confirms the bytes really changed
*/
private async indexFile(entry: FileEntry, previous: StoredFile | undefined): Promise<void> {
if (previous && previous.size === entry.size && previous.mtime_ms === entry.mtimeMs) {
this.progress.unchanged += 1;
return;
}
const abs = join(this.config.booksDir, entry.relPath);
const buf = await readFile(abs);
const parsed = await parseBookFile(buf, entry.relPath);
// Stage 2: identical bytes with a touched mtime is not a real change.
if (previous && previous.book_content_hash === parsed.contentHash) {
this.db.run('UPDATE book_files SET size = ?, mtime_ms = ?, missing = 0 WHERE id = ?',
entry.size, entry.mtimeMs, previous.id);
this.progress.unchanged += 1;
return;
}
const bookId = computeBookId(parsed.metadata.identifier, parsed.contentHash);
const existing = this.db.get<{ id: string }>('SELECT id FROM books WHERE id = ?', bookId);
const now = Date.now();
// Write the cover into DATA_DIR before recording its path. Covers are
// cached, never mirrored back into the read-only library mount.
if (parsed.cover) {
parsed.coverPath = await this.persistCover(bookId, parsed.cover);
}
if (!existing) {
this.insertBook(bookId, parsed, now);
this.progress.added += 1;
// Every existing user gets the new book on their shelf, so the library
// feels shared rather than something each member must curate by hand.
this.db.run(
`INSERT OR IGNORE INTO user_books (user_id, book_id, added_at)
SELECT id, ?, ? FROM users`,
bookId, now,
);
} else {
this.progress.updated += 1;
// Embedded metadata is never allowed to overwrite manual edits; the
// override layer is applied on read, so refreshing base values is safe.
this.updateBook(bookId, parsed, now);
}
const fileId = computeFileId(entry.relPath);
if (previous) {
this.db.run(
'UPDATE book_files SET book_id = ?, size = ?, mtime_ms = ?, missing = 0, last_seen = ? WHERE id = ?',
bookId, entry.size, entry.mtimeMs, now, previous.id,
);
} else {
this.db.run(
`INSERT INTO book_files (id, book_id, rel_path, size, mtime_ms, inode, missing, first_seen, last_seen)
VALUES (?, ?, ?, ?, ?, '', 0, ?, ?)
ON CONFLICT(rel_path) DO UPDATE SET book_id = excluded.book_id, size = excluded.size,
mtime_ms = excluded.mtime_ms, missing = 0, last_seen = excluded.last_seen`,
fileId, bookId, entry.relPath, entry.size, entry.mtimeMs, now, now,
);
}
}
private insertBook(bookId: string, parsed: ParsedBookFile, now: number): void {
const m = parsed.metadata;
const coverPath = parsed.coverPath ?? null;
this.db.run(
`INSERT INTO books (id, identifier, content_hash, format, title, author, publisher, language, isbn,
description, series, series_index, tags, pubdate, cover_path, file_size, page_count, meta_json,
source, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
bookId,
m.identifier,
parsed.contentHash,
parsed.format,
collapseWhitespace(m.title) || fallbackTitle(parsed),
m.author,
m.publisher,
m.language,
m.isbn,
m.description,
m.series,
m.seriesIndex,
JSON.stringify(m.tags),
m.pubdate,
coverPath,
parsed.size,
parsed.pageCount,
JSON.stringify(m.raw),
m.source,
now,
now,
);
}
private updateBook(bookId: string, parsed: ParsedBookFile, now: number): void {
const m = parsed.metadata;
this.db.run(
`UPDATE books SET title = ?, author = ?, publisher = ?, language = ?, isbn = ?, description = ?,
series = ?, series_index = ?, tags = ?, pubdate = ?, page_count = ?, file_size = ?, meta_json = ?,
cover_path = COALESCE(?, cover_path), source = ?, updated_at = ? WHERE id = ?`,
collapseWhitespace(m.title) || fallbackTitle(parsed),
m.author, m.publisher, m.language, m.isbn, m.description,
m.series, m.seriesIndex, JSON.stringify(m.tags), m.pubdate,
parsed.pageCount, parsed.size, JSON.stringify(m.raw),
parsed.coverPath ?? null, m.source, now, bookId,
);
}
/** Persists an extracted cover. Must be called outside the DB transaction. */
async persistCover(bookId: string, cover: { data: Buffer; contentType: string }): Promise<string> {
return saveCover(this.config.dataDir, bookId, cover);
}
}
function fallbackTitle(parsed: Awaited<ReturnType<typeof parseBookFile>>): string {
return `未命名 (${parsed.contentHash.slice(0, 8)})`;
}
export function contentFingerprint(buf: Buffer): string {
return createHash('sha256').update(buf).digest('hex');
}
export function parseTags(value: string): string[] {
return safeJsonParse<string[]>(value, []);
}
export type { ExtractedMetadata };
+17
View File
@@ -0,0 +1,17 @@
export class AppError extends Error {
readonly statusCode: number;
readonly code: string;
constructor(statusCode: number, code: string, message: string) {
super(message);
this.name = 'AppError';
this.statusCode = statusCode;
this.code = code;
}
}
export const badRequest = (message: string, code = 'BAD_REQUEST') => new AppError(400, code, message);
export const unauthorized = (message = 'unauthorized', code = 'UNAUTHORIZED') => new AppError(401, code, message);
export const forbidden = (message = 'forbidden', code = 'FORBIDDEN') => new AppError(403, code, message);
export const notFound = (message = 'not found', code = 'NOT_FOUND') => new AppError(404, code, message);
export const conflict = (message: string, code = 'CONFLICT') => new AppError(409, code, message);
+35
View File
@@ -0,0 +1,35 @@
import { relative, resolve, sep } from 'node:path';
import { forbidden } from './errors.ts';
/**
* Resolve a library-relative path against the read-only books root.
*
* Every filesystem access in the server must go through this helper: it is the
* single choke point that prevents path traversal (`../../etc/passwd`) from
* ever reaching the disk.
*/
export function resolveInside(root: string, relPath: string): string {
const abs = resolve(root, relPath);
const rootWithSep = root.endsWith(sep) ? root : `${root}${sep}`;
if (abs !== root && !abs.startsWith(rootWithSep)) {
throw forbidden('path escapes the library root', 'PATH_TRAVERSAL');
}
return abs;
}
export function toRelative(root: string, abs: string): string {
return relative(root, abs).split(sep).join('/');
}
/** Normalises a client supplied relative path to forward slashes. */
export function normalizeRel(relPath: string): string {
return relPath.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
}
export function assertSafeRel(relPath: string): string {
const normalized = normalizeRel(relPath);
if (!normalized || normalized.split('/').some((part) => part === '..' || part === '.')) {
throw forbidden('invalid path', 'PATH_TRAVERSAL');
}
return normalized;
}
+20
View File
@@ -0,0 +1,20 @@
export function stripNullBytes(value: string): string {
return value.replace(/\\u0000/g, '').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '');
}
export function collapseWhitespace(value: string): string {
return stripNullBytes(value).replace(/\s+/g, ' ').trim();
}
export function safeJsonParse<T>(value: string, fallback: T): T {
try {
return JSON.parse(value) as T;
} catch {
return fallback;
}
}
export function truncate(value: string, max: number): string {
if (value.length <= max) return value;
return `${value.slice(0, max - 1)}…`;
}
+93
View File
@@ -0,0 +1,93 @@
import { loadConfig } from './config/index.ts';
import { openDatabase } from './db/index.ts';
import { Scanner } from './indexer/scanner.ts';
import { UserService } from './services/users.ts';
import { ShelfService } from './services/shelf.ts';
import { SyncService } from './services/sync.ts';
import { buildApp } from './http/app.ts';
import type { AppContext } from './http/context.ts';
interface Schedulers {
scanTimer: NodeJS.Timeout | undefined;
watchTimer: NodeJS.Timeout | undefined;
}
async function main(): Promise<void> {
const config = loadConfig();
const db = openDatabase(config);
// Two-phase wiring: services log through the app logger, so the app is
// created first with a placeholder context and completed immediately after.
const ctx = {
config,
db,
scanner: undefined,
users: undefined,
shelf: undefined,
sync: undefined,
log: undefined,
} as unknown as AppContext;
const app = buildApp(ctx);
ctx.log = app.log;
ctx.scanner = new Scanner(db, config, {
info: (o, m) => app.log.info(o as object, m),
warn: (o, m) => app.log.warn(o as object, m),
});
ctx.users = new UserService(db, config);
ctx.shelf = new ShelfService(db);
ctx.sync = new SyncService(db);
app.log.info({ booksDir: config.booksDir, dataDir: config.dataDir }, 'starting reader server');
const scheduler = startSchedulers(ctx, app.log);
const shutdown = async (signal: string): Promise<void> => {
app.log.info({ signal }, 'shutting down');
if (scheduler.scanTimer) clearInterval(scheduler.scanTimer);
if (scheduler.watchTimer) clearInterval(scheduler.watchTimer);
await app.close();
db.close();
process.exit(0);
};
process.on('SIGTERM', () => void shutdown('SIGTERM'));
process.on('SIGINT', () => void shutdown('SIGINT'));
await app.listen({ host: config.host, port: config.port });
if (ctx.users.count() === 0) {
app.log.warn('no accounts exist yet; the first registration becomes the admin');
}
}
function startSchedulers(ctx: AppContext, log: AppContext['log']): Schedulers {
const runScan = async (): Promise<void> => {
try {
await ctx.scanner.scan();
} catch (err) {
log.error({ err }, 'scheduled scan failed');
}
};
// A scan at boot makes the library available immediately after a restart.
void runScan();
// The watch poller is the same code path as the interval scan; the mtime+size
// fast path makes it cheap enough to run every minute (see Scanner.indexFile).
const scanTimer = ctx.config.scanInterval > 0
? setInterval(() => void runScan(), ctx.config.scanInterval * 1000)
: undefined;
scanTimer?.unref();
const watchTimer = ctx.config.watchInterval > 0
? setInterval(() => void runScan(), ctx.config.watchInterval * 1000)
: undefined;
watchTimer?.unref();
return { scanTimer, watchTimer };
}
main().catch((err: unknown) => {
console.error('fatal:', err);
process.exit(1);
});
+45
View File
@@ -0,0 +1,45 @@
import { nullProvider, type MetadataProvider } from './types.ts';
/**
* Provider registry.
*
* Registering a fallback chain here is the whole extension point: the indexer
* asks the registry for a patch and never learns which source produced it.
*/
class ProviderRegistry {
private readonly providers = new Map<string, MetadataProvider>();
register(provider: MetadataProvider): void {
this.providers.set(provider.id, provider);
}
get(id: string): MetadataProvider | undefined {
return this.providers.get(id);
}
active(): MetadataProvider[] {
return [...this.providers.values()].filter((p) => p.enabled());
}
list(): Array<{ id: string; displayName: string; enabled: boolean }> {
return [...this.providers.values()].map((p) => ({
id: p.id,
displayName: p.displayName,
enabled: p.enabled(),
}));
}
/** First provider in the chain that yields a patch wins. */
async lookupFirst(query: Parameters<MetadataProvider['lookup']>[0]) {
for (const provider of this.active()) {
if (provider.id === 'none') continue;
const result = await provider.lookup(query);
if (result) return { provider: provider.id, result };
}
return null;
}
}
export const providers = new ProviderRegistry();
providers.register(nullProvider);
+49
View File
@@ -0,0 +1,49 @@
/**
* Online metadata scraping is a pluggable provider chain (§6).
*
* First release ships only the `none` provider: embedded metadata + filename
* parsing + manual completion. Online sources (Google Books, Open Library) drop
* in later without touching the indexer, and every provider must comply with
* the contract below so that the priority chain stays intact.
*/
export interface ScrapeQuery {
title: string;
author: string;
isbn: string;
language: string;
}
/** A partial metadata patch. Only empty fields are ever filled. */
export interface ScrapeResult {
title?: string;
author?: string;
publisher?: string;
description?: string;
language?: string;
pubdate?: string;
series?: string;
seriesIndex?: number;
tags?: string[];
isbn?: string;
coverUrl?: string;
}
export interface MetadataProvider {
/** Stable id, persisted in `books.source` as `provider:<id>`. */
readonly id: string;
readonly displayName: string;
/** Whether this provider is usable in the current configuration. */
enabled(): boolean;
/** Returns a patch, or null when the provider has nothing to add. */
lookup(query: ScrapeQuery): Promise<ScrapeResult | null>;
}
/** Provider that never returns anything; keeps the chain explicit and testable. */
export const nullProvider: MetadataProvider = {
id: 'none',
displayName: 'Disabled (local metadata only)',
enabled: () => true,
async lookup() {
return null;
},
};
+82
View File
@@ -0,0 +1,82 @@
import type { ScrapeResult } from '../providers/types.ts';
/**
* Metadata priority chain (§6). Highest wins, and a higher layer is never
* allowed to overwrite a lower one:
*
* 1. user manual override (metadata_overrides table)
* 2. embedded EPUB metadata
* 3. online provider patch (fills gaps only)
* 4. filename parsing (weakest)
*
* This module implements layers 2-4. Layer 1 is applied afterwards by the
* shelf service so that manual edits are always the last word.
*/
export interface BaseMetadata {
title: string;
author: string;
publisher: string;
language: string;
isbn: string;
description: string;
series: string;
seriesIndex: number | null;
tags: string[];
pubdate: string;
source: string;
}
const EMPTY = (value: string | null | undefined): boolean => !value || value.trim().length === 0;
export function mergeProviderPatch(base: BaseMetadata, patch: ScrapeResult, providerId: string): BaseMetadata {
const merged: BaseMetadata = { ...base };
if (EMPTY(merged.title) && patch.title) merged.title = patch.title;
if (EMPTY(merged.author) && patch.author) merged.author = patch.author;
if (EMPTY(merged.publisher) && patch.publisher) merged.publisher = patch.publisher;
if (EMPTY(merged.language) && patch.language) merged.language = patch.language;
if (EMPTY(merged.isbn) && patch.isbn) merged.isbn = patch.isbn;
if (EMPTY(merged.description) && patch.description) merged.description = patch.description;
if (EMPTY(merged.series) && patch.series) merged.series = patch.series;
if (merged.seriesIndex === null && typeof patch.seriesIndex === 'number') merged.seriesIndex = patch.seriesIndex;
if (merged.tags.length === 0 && patch.tags?.length) merged.tags = patch.tags;
if (EMPTY(merged.pubdate) && patch.pubdate) merged.pubdate = patch.pubdate;
// Provenance is recorded so the UI can label scraped fields and offer undo.
merged.source = `provider:${providerId}`;
return merged;
}
/**
* Applies the user's manual edits as the final layer.
* Returns the effective value for each field plus which layer produced it.
*/
export function applyOverrides(
base: BaseMetadata,
overrides: Record<string, string>,
): { effective: BaseMetadata; appliedFields: string[] } {
const effective: BaseMetadata = { ...base };
const appliedFields: string[] = [];
const textFields: Array<keyof BaseMetadata> = [
'title', 'author', 'publisher', 'language', 'isbn', 'description', 'series', 'pubdate',
];
for (const field of textFields) {
const override = overrides[field as string];
if (override === undefined) continue;
// An override is the user's explicit intent, even when it blanks a field.
(effective[field] as string) = override;
appliedFields.push(field as string);
}
if (overrides.seriesIndex !== undefined) {
const parsed = Number.parseFloat(overrides.seriesIndex);
effective.seriesIndex = Number.isFinite(parsed) ? parsed : null;
appliedFields.push('seriesIndex');
}
if (overrides.tags !== undefined) {
effective.tags = overrides.tags.split(',').map((t) => t.trim()).filter(Boolean);
appliedFields.push('tags');
}
// `source` keeps describing where the BASE metadata came from; the caller
// reports `manualFields` separately. Keeping the origin intact is what lets
// the client show "scraped from X" even after a partial manual edit, and
// makes an undo fall back to the correct original value.
return { effective, appliedFields };
}
+33
View File
@@ -0,0 +1,33 @@
import { randomBytes, scrypt as scryptCb, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';
const scrypt = promisify(scryptCb) as (
password: string | Buffer,
salt: string | Buffer,
keylen: number,
options: { N: number; r: number; p: number; maxmem: number },
) => Promise<Buffer>;
// scrypt is deliberately used instead of argon2/bcrypt: both of those need a
// native build, which would break the "one command" deployment promise (§8.1).
const PARAMS = { N: 16384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
const KEY_LEN = 64;
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16);
const derived = await scrypt(password, salt, KEY_LEN, PARAMS);
return `scrypt$${PARAMS.N}$${PARAMS.r}$${PARAMS.p}$${salt.toString('base64')}$${derived.toString('base64')}`;
}
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
const parts = stored.split('$');
if (parts.length !== 6 || parts[0] !== 'scrypt') return false;
const [, n, r, p, saltB64, hashB64] = parts as [string, string, string, string, string, string];
const salt = Buffer.from(saltB64, 'base64');
const expected = Buffer.from(hashB64, 'base64');
const derived = await scrypt(password, salt, expected.length, {
N: Number(n), r: Number(r), p: Number(p), maxmem: PARAMS.maxmem,
});
if (derived.length !== expected.length) return false;
return timingSafeEqual(derived, expected);
}
+240
View File
@@ -0,0 +1,240 @@
import type { Db } from '../db/index.ts';
import { applyOverrides, type BaseMetadata } from './merge.ts';
import { safeJsonParse } from '../lib/text.ts';
import { notFound } from '../lib/errors.ts';
export interface BookDto {
id: string;
title: string;
author: string;
publisher: string;
language: string;
isbn: string;
description: string;
series: string;
seriesIndex: number | null;
tags: string[];
pubdate: string;
format: string;
coverUrl: string | null;
fileSize: number;
pageCount: number | null;
source: string;
/** Which fields the user overrode by hand; lets the UI offer an undo. */
manualFields: string[];
updatedAt: number;
}
interface BookRow {
id: string;
identifier: string | null;
content_hash: string;
format: string;
title: string;
author: string;
publisher: string;
language: string;
isbn: string;
description: string;
series: string;
series_index: number | null;
tags: string;
pubdate: string;
cover_path: string | null;
file_size: number;
page_count: number | null;
source: string;
updated_at: number;
}
const BOOK_COLUMNS = `id, identifier, content_hash, format, title, author, publisher, language, isbn,
description, series, series_index, tags, pubdate, cover_path, file_size, page_count, source, updated_at`;
export interface ListOptions {
search?: string;
author?: string;
series?: string;
tag?: string;
format?: string;
sort?: 'title' | 'author' | 'added' | 'updated';
order?: 'asc' | 'desc';
page?: number;
pageSize?: number;
}
export class ShelfService {
constructor(private readonly db: Db) {}
/**
* Books visible to a user. Only rows that still have a live file are
* returned, so a book whose file was deleted disappears from the shelf even
* if its progress row survives for a later restore.
*/
list(userId: string, options: ListOptions = {}): { items: BookDto[]; total: number; page: number; pageSize: number } {
const page = Math.max(1, options.page ?? 1);
const pageSize = Math.min(200, Math.max(1, options.pageSize ?? 50));
const where: string[] = ['ub.user_id = ?', 'ub.hidden = 0', 'EXISTS (SELECT 1 FROM book_files f WHERE f.book_id = b.id AND f.missing = 0)'];
const params: Array<string | number> = [userId];
if (options.search) {
where.push('(b.title LIKE ? OR b.author LIKE ? OR b.series LIKE ? OR b.isbn = ?)');
const like = `%${options.search}%`;
params.push(like, like, like, options.search);
}
if (options.author) {
where.push('b.author = ?');
params.push(options.author);
}
if (options.series) {
where.push('b.series = ?');
params.push(options.series);
}
if (options.format) {
where.push('b.format = ?');
params.push(options.format);
}
if (options.tag) {
// Tags are stored as a JSON array; substring matching is sufficient here
// and avoids a join table that would complicate the sync payload.
where.push('b.tags LIKE ?');
params.push(`%"${options.tag}"%`);
}
const sortColumn = {
title: 'b.title',
author: 'b.author',
added: 'ub.added_at',
updated: 'b.updated_at',
}[options.sort ?? 'added'];
const order = options.order === 'asc' ? 'ASC' : 'DESC';
const whereSql = where.join(' AND ');
const totalRow = this.db.get<{ n: number }>(
`SELECT COUNT(*) AS n FROM books b JOIN user_books ub ON ub.book_id = b.id WHERE ${whereSql}`,
...params,
);
const rows = this.db.all<BookRow>(
`SELECT ${BOOK_COLUMNS.split(',').map((c) => `b.${c.trim()}`).join(', ')}
FROM books b JOIN user_books ub ON ub.book_id = b.id
WHERE ${whereSql}
ORDER BY ${sortColumn} ${order}, b.title ASC
LIMIT ? OFFSET ?`,
...params, pageSize, (page - 1) * pageSize,
);
return { items: rows.map((row) => this.toDto(row)), total: totalRow?.n ?? 0, page, pageSize };
}
get(userId: string, bookId: string): BookDto {
const row = this.db.get<BookRow>(
`SELECT ${BOOK_COLUMNS} FROM books WHERE id = ?`,
bookId,
);
if (!row) throw notFound('book not found');
const visible = this.db.get<{ n: number }>(
'SELECT COUNT(*) AS n FROM user_books WHERE user_id = ? AND book_id = ? AND hidden = 0',
userId, bookId,
);
if (!visible?.n) throw notFound('book not found');
return this.toDto(row);
}
/**
* Applies the manual override layer, which sits above embedded metadata and
* above anything a provider filled in.
*/
toDto(row: BookRow): BookDto {
const overrides = this.loadOverrides(row.id);
const base: BaseMetadata = {
title: row.title,
author: row.author,
publisher: row.publisher,
language: row.language,
isbn: row.isbn,
description: row.description,
series: row.series,
seriesIndex: row.series_index,
tags: safeJsonParse<string[]>(row.tags, []),
pubdate: row.pubdate,
source: row.source,
};
const { effective, appliedFields } = applyOverrides(base, overrides);
return {
id: row.id,
title: effective.title,
author: effective.author,
publisher: effective.publisher,
language: effective.language,
isbn: effective.isbn,
description: effective.description,
series: effective.series,
seriesIndex: effective.seriesIndex,
tags: effective.tags,
pubdate: effective.pubdate,
format: row.format,
coverUrl: row.cover_path ? `/api/v1/books/${row.id}/cover` : null,
fileSize: row.file_size,
pageCount: row.page_count,
source: effective.source,
manualFields: appliedFields,
updatedAt: row.updated_at,
};
}
loadOverrides(bookId: string): Record<string, string> {
const rows = this.db.all<{ field: string; value: string }>(
'SELECT field, value FROM metadata_overrides WHERE book_id = ?',
bookId,
);
return Object.fromEntries(rows.map((r) => [r.field, r.value]));
}
/** Records manual edits. These are never overwritten by a rescan. */
setOverrides(bookId: string, patch: Record<string, unknown>, userId: string): void {
const now = Date.now();
for (const [field, value] of Object.entries(patch)) {
if (value === undefined) continue;
const text = Array.isArray(value) ? value.join(',') : String(value);
this.db.run(
`INSERT INTO metadata_overrides (book_id, field, value, updated_by, updated_at)
VALUES (?,?,?,?,?)
ON CONFLICT(book_id, field) DO UPDATE SET value = excluded.value,
updated_by = excluded.updated_by, updated_at = excluded.updated_at`,
bookId, field, text, userId, now,
);
}
// Bump the book row so clients syncing by `updatedAt` notice the edit.
this.db.run('UPDATE books SET updated_at = ? WHERE id = ?', now, bookId);
}
clearOverride(bookId: string, field: string): void {
this.db.run('DELETE FROM metadata_overrides WHERE book_id = ? AND field = ?', bookId, field);
this.db.run('UPDATE books SET updated_at = ? WHERE id = ?', Date.now(), bookId);
}
/** Library-wide facets, used to build the client's filter UI. */
facets(userId: string): { authors: string[]; series: string[]; tags: string[]; formats: string[] } {
const rows = this.db.all<{ author: string; series: string; tags: string; format: string }>(
`SELECT b.author, b.series, b.tags, b.format
FROM books b JOIN user_books ub ON ub.book_id = b.id
WHERE ub.user_id = ? AND ub.hidden = 0`, userId,
);
const authors = new Set<string>();
const series = new Set<string>();
const tags = new Set<string>();
const formats = new Set<string>();
for (const row of rows) {
if (row.author) authors.add(row.author);
if (row.series) series.add(row.series);
if (row.format) formats.add(row.format);
for (const tag of safeJsonParse<string[]>(row.tags, [])) tags.add(tag);
}
return {
authors: [...authors].sort(),
series: [...series].sort(),
tags: [...tags].sort(),
formats: [...formats].sort(),
};
}
}
+219
View File
@@ -0,0 +1,219 @@
import { randomUUID } from 'node:crypto';
import type { Db } from '../db/index.ts';
import { badRequest } from '../lib/errors.ts';
/**
* Sync payload is deliberately small and append-only where possible (§4): the
* server is the single source of truth and the client only renders and caches.
*
* Every record carries `updatedAt` and is stored per user, so a client that was
* offline for a week can push its local changes and receive only what changed
* on the server since `since`.
*/
export interface ProgressRecord {
bookId: string;
/** Opaque locator: EPUB CFI, pagination index or PDF page. Client-defined. */
locator: string;
percentage: number;
chapterTitle: string;
device: string;
updatedAt: number;
}
export interface NoteRecord {
id: string;
bookId: string;
type: 'note' | 'highlight' | 'bookmark';
locator: string;
/** Selected text for highlights, empty for bookmarks. */
text: string;
/** Reader's own comment; left empty for highlights without a note. */
comment: string;
color: string;
updatedAt: number;
deleted: boolean;
}
export interface SyncPushRequest {
progress?: ProgressRecord[];
notes?: NoteRecord[];
}
export class SyncService {
constructor(private readonly db: Db) {}
/**
* Merges a client batch. Conflicts resolve by last-writer-wins on updatedAt,
* which is correct here because each device belongs to one reader: there is no
* editorial conflict to arbitrate, only a stale device to catch up.
*/
push(userId: string, payload: SyncPushRequest): { accepted: number; rejected: number } {
let accepted = 0;
let rejected = 0;
this.db.transaction(() => {
for (const item of payload.progress ?? []) {
if (!item.bookId || typeof item.updatedAt !== 'number') {
rejected += 1;
continue;
}
this.upsertProgress(userId, item);
accepted += 1;
}
for (const item of payload.notes ?? []) {
if (!item.bookId || !item.id) {
rejected += 1;
continue;
}
this.upsertNote(userId, item);
accepted += 1;
}
});
return { accepted, rejected };
}
private upsertProgress(userId: string, record: ProgressRecord): void {
const percentage = clampPercentage(record.percentage);
this.db.run(
`INSERT INTO reading_progress (user_id, book_id, locator, percentage, chapter_title, device, updated_at)
VALUES (?,?,?,?,?,?,?)
ON CONFLICT(user_id, book_id) DO UPDATE SET
locator = CASE WHEN excluded.updated_at >= reading_progress.updated_at THEN excluded.locator ELSE reading_progress.locator END,
percentage = CASE WHEN excluded.updated_at >= reading_progress.updated_at THEN excluded.percentage ELSE reading_progress.percentage END,
chapter_title = CASE WHEN excluded.updated_at >= reading_progress.updated_at THEN excluded.chapter_title ELSE reading_progress.chapter_title END,
device = CASE WHEN excluded.updated_at >= reading_progress.updated_at THEN excluded.device ELSE reading_progress.device END,
updated_at = MAX(excluded.updated_at, reading_progress.updated_at)`,
userId, record.bookId, record.locator ?? '', percentage,
record.chapterTitle ?? '', record.device ?? '', record.updatedAt,
);
}
private upsertNote(userId: string, record: NoteRecord): void {
if (record.type && !['note', 'highlight', 'bookmark'].includes(record.type)) {
throw badRequest(`unsupported note type: ${record.type}`, 'BAD_NOTE_TYPE');
}
this.db.run(
`INSERT INTO notes (id, user_id, book_id, type, locator, text, comment, color, updated_at, deleted)
VALUES (?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET
locator = CASE WHEN excluded.updated_at >= notes.updated_at THEN excluded.locator ELSE notes.locator END,
text = CASE WHEN excluded.updated_at >= notes.updated_at THEN excluded.text ELSE notes.text END,
comment = CASE WHEN excluded.updated_at >= notes.updated_at THEN excluded.comment ELSE notes.comment END,
color = CASE WHEN excluded.updated_at >= notes.updated_at THEN excluded.color ELSE notes.color END,
deleted = CASE WHEN excluded.updated_at >= notes.updated_at THEN excluded.deleted ELSE notes.deleted END,
updated_at = MAX(excluded.updated_at, notes.updated_at)`,
record.id, userId, record.bookId, record.type ?? 'note', record.locator ?? '',
record.text ?? '', record.comment ?? '', record.color ?? '',
record.updatedAt ?? Date.now(), record.deleted ? 1 : 0,
);
}
/** Everything changed strictly after `since`, for the client's catch-up. */
pull(userId: string, since: number, bookId?: string): {
serverTime: number;
progress: ProgressRecord[];
notes: NoteRecord[];
} {
const progressRows = this.db.all<{
book_id: string; locator: string; percentage: number; chapter_title: string;
device: string; updated_at: number;
}>(
`SELECT book_id, locator, percentage, chapter_title, device, updated_at
FROM reading_progress WHERE user_id = ? AND updated_at > ? ${bookId ? 'AND book_id = ?' : ''}
ORDER BY updated_at ASC LIMIT 5000`,
...(bookId ? [userId, since, bookId] : [userId, since]),
);
const noteRows = this.db.all<{
id: string; book_id: string; type: string; locator: string; text: string;
comment: string; color: string; updated_at: number; deleted: number;
}>(
`SELECT id, book_id, type, locator, text, comment, color, updated_at, deleted
FROM notes WHERE user_id = ? AND updated_at > ? ${bookId ? 'AND book_id = ?' : ''}
ORDER BY updated_at ASC LIMIT 5000`,
...(bookId ? [userId, since, bookId] : [userId, since]),
);
return {
serverTime: Date.now(),
progress: progressRows.map((row) => ({
bookId: row.book_id,
locator: row.locator,
percentage: row.percentage,
chapterTitle: row.chapter_title,
device: row.device,
updatedAt: row.updated_at,
})),
notes: noteRows.map((row) => ({
id: row.id,
bookId: row.book_id,
type: row.type as NoteRecord['type'],
locator: row.locator,
text: row.text,
comment: row.comment,
color: row.color,
updatedAt: row.updated_at,
deleted: row.deleted === 1,
})),
};
}
progressFor(userId: string, bookId: string): ProgressRecord | null {
const row = this.db.get<{
book_id: string; locator: string; percentage: number; chapter_title: string;
device: string; updated_at: number;
}>(
`SELECT book_id, locator, percentage, chapter_title, device, updated_at
FROM reading_progress WHERE user_id = ? AND book_id = ?`,
userId, bookId,
);
if (!row) return null;
return {
bookId: row.book_id,
locator: row.locator,
percentage: row.percentage,
chapterTitle: row.chapter_title,
device: row.device,
updatedAt: row.updated_at,
};
}
/** Continue-reading list: newest progress first, joined with book titles. */
recentlyRead(userId: string, limit = 20): Array<{
bookId: string; title: string; author: string; percentage: number;
chapterTitle: string; updatedAt: number; coverUrl: string | null;
}> {
const rows = this.db.all<{
book_id: string; title: string; author: string; percentage: number;
chapter_title: string; updated_at: number; cover_path: string | null;
}>(
`SELECT p.book_id, b.title, b.author, p.percentage, p.chapter_title, p.updated_at, b.cover_path
FROM reading_progress p
JOIN books b ON b.id = p.book_id
JOIN user_books ub ON ub.book_id = b.id AND ub.user_id = p.user_id
WHERE p.user_id = ? AND ub.hidden = 0
ORDER BY p.updated_at DESC LIMIT ?`,
userId, limit,
);
return rows.map((row) => ({
bookId: row.book_id,
title: row.title,
author: row.author,
percentage: row.percentage,
chapterTitle: row.chapter_title,
updatedAt: row.updated_at,
coverUrl: row.cover_path ? `/api/v1/books/${row.book_id}/cover` : null,
}));
}
/** Client-generated note ids are accepted, but this keeps the API ergonomic. */
static newNoteId(): string {
return randomUUID();
}
}
function clampPercentage(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.min(1, Math.max(0, value));
}
+68
View File
@@ -0,0 +1,68 @@
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
import type { AppConfig } from '../config/index.ts';
import { unauthorized } from '../lib/errors.ts';
export interface TokenPayload {
sub: string;
role: 'admin' | 'member';
type: 'access';
iat: number;
exp: number;
}
/**
* Minimal, dependency-free signed token (JWT-compatible HS256 shape).
*
* Access tokens are stateless and short lived; refresh tokens are opaque random
* strings persisted in the database so a device can be revoked individually.
*/
export function signAccessToken(
config: AppConfig,
user: { id: string; role: 'admin' | 'member' },
): { token: string; expiresAt: number } {
const issuedAt = Math.floor(Date.now() / 1000);
const expiresAt = issuedAt + config.accessTokenTtl;
const payload: TokenPayload = { sub: user.id, role: user.role, type: 'access', iat: issuedAt, exp: expiresAt };
const header = base64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const body = base64url(JSON.stringify(payload));
const signature = sign(config.jwtSecret, `${header}.${body}`);
return { token: `${header}.${body}.${signature}`, expiresAt: expiresAt * 1000 };
}
export function verifyAccessToken(config: AppConfig, token: string): TokenPayload {
const parts = token.split('.');
if (parts.length !== 3) throw unauthorized('malformed token', 'TOKEN_INVALID');
const [header, body, signature] = parts as [string, string, string];
const expected = sign(config.jwtSecret, `${header}.${body}`);
const given = Buffer.from(signature);
const want = Buffer.from(expected);
if (given.length !== want.length || !timingSafeEqual(given, want)) {
throw unauthorized('bad token signature', 'TOKEN_INVALID');
}
let payload: TokenPayload;
try {
payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as TokenPayload;
} catch {
throw unauthorized('bad token payload', 'TOKEN_INVALID');
}
if (payload.type !== 'access') throw unauthorized('wrong token type', 'TOKEN_INVALID');
if (payload.exp * 1000 <= Date.now()) throw unauthorized('token expired', 'TOKEN_EXPIRED');
return payload;
}
export function newRefreshToken(): { token: string; hash: string; id: string } {
const token = randomUUID().replace(/-/g, '') + randomUUID().replace(/-/g, '');
return { token, hash: hashToken(token), id: randomUUID() };
}
export function hashToken(token: string): string {
return sign('refresh-token-pepper', token);
}
function sign(secret: string, data: string): string {
return base64url(createHmac('sha256', secret).update(data).digest());
}
function base64url(input: string | Buffer): string {
return Buffer.from(input).toString('base64url');
}
+232
View File
@@ -0,0 +1,232 @@
import { randomUUID } from 'node:crypto';
import type { Db } from '../db/index.ts';
import type { AppConfig } from '../config/index.ts';
import { hashPassword, verifyPassword } from './passwords.ts';
import { hashToken, newRefreshToken, signAccessToken } from './tokens.ts';
import { conflict, forbidden, notFound, unauthorized } from '../lib/errors.ts';
export type Role = 'admin' | 'member';
export interface UserRow {
id: string;
username: string;
display_name: string;
password_hash: string;
role: Role;
disabled: number;
created_at: number;
updated_at: number;
}
export interface PublicUser {
id: string;
username: string;
displayName: string;
role: Role;
createdAt: number;
}
export function toPublicUser(row: UserRow): PublicUser {
return {
id: row.id,
username: row.username,
displayName: row.display_name || row.username,
role: row.role,
createdAt: row.created_at,
};
}
export class UserService {
constructor(
private readonly db: Db,
private readonly config: AppConfig,
) {}
count(): number {
const row = this.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM users');
return row?.n ?? 0;
}
list(): PublicUser[] {
return this.db
.all<UserRow>('SELECT * FROM users ORDER BY created_at ASC')
.map(toPublicUser);
}
byId(id: string): UserRow | undefined {
return this.db.get<UserRow>('SELECT * FROM users WHERE id = ?', id);
}
/**
* Public registration is only allowed when explicitly enabled. The very first
* account is always accepted, so a fresh deployment can be claimed by its
* owner without a pre-seeded password.
*/
canRegisterPublicly(): boolean {
if (this.count() === 0) return true;
return process.env.ALLOW_REGISTRATION === 'true';
}
async create(input: {
username: string;
password: string;
displayName?: string;
role?: Role;
}): Promise<PublicUser> {
const username = input.username.trim();
if (username.length < 3) throw conflict('username must be at least 3 characters', 'USERNAME_TOO_SHORT');
if (input.password.length < 8) throw conflict('password must be at least 8 characters', 'PASSWORD_TOO_SHORT');
const existing = this.db.get<{ id: string }>('SELECT id FROM users WHERE username = ? COLLATE NOCASE', username);
if (existing) throw conflict('username already taken', 'USERNAME_TAKEN');
const now = Date.now();
const row: UserRow = {
id: randomUUID(),
username,
display_name: input.displayName?.trim() ?? '',
password_hash: await hashPassword(input.password),
// The first user to register owns the instance.
role: input.role ?? (this.count() === 0 ? 'admin' : 'member'),
disabled: 0,
created_at: now,
updated_at: now,
};
this.db.run(
`INSERT INTO users (id, username, display_name, password_hash, role, disabled, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?)`,
row.id, row.username, row.display_name, row.password_hash, row.role, row.disabled, row.created_at, row.updated_at,
);
// A new account must see the whole shared library straight away. Without
// this, a book scanned before the account existed would stay invisible
// until its file happened to change.
this.db.run(
`INSERT OR IGNORE INTO user_books (user_id, book_id, added_at)
SELECT ?, b.id, ? FROM books b
WHERE EXISTS (SELECT 1 FROM book_files f WHERE f.book_id = b.id AND f.missing = 0)`,
row.id, now,
);
return toPublicUser(row);
}
/**
* Admin-initiated creation. Unlike the public register endpoint this is not
* gated by ALLOW_REGISTRATION: an admin is the instance owner and is expected
* to add family members (§1, 单实例多账号).
*/
async createAsAdmin(input: {
username: string;
password: string;
displayName?: string;
role?: Role;
}): Promise<PublicUser> {
const requestedRole = input.role ?? 'member';
return this.create({ ...input, role: requestedRole });
}
async login(username: string, password: string, device = ''): Promise<{
user: PublicUser;
accessToken: string;
accessTokenExpiresAt: number;
refreshToken: string;
refreshTokenExpiresAt: number;
}> {
const row = this.db.get<UserRow>('SELECT * FROM users WHERE username = ? COLLATE NOCASE', username.trim());
// Always run a hash comparison so a missing user and a wrong password take
// comparable time and cannot be distinguished by timing.
const ok = row
? await verifyPassword(password, row.password_hash)
: await verifyPassword(password, 'scrypt$16384$8$1$AAAAAAAAAAAAAAAAAAAAAA==$AAAA');
if (!row || !ok) throw unauthorized('invalid username or password', 'BAD_CREDENTIALS');
if (row.disabled === 1) throw forbidden('account disabled', 'ACCOUNT_DISABLED');
const access = signAccessToken(this.config, { id: row.id, role: row.role });
const refresh = newRefreshToken();
const refreshExpiresAt = Date.now() + this.config.refreshTokenTtl * 1000;
this.db.run(
`INSERT INTO refresh_tokens (id, user_id, token_hash, device, created_at, expires_at, revoked_at)
VALUES (?,?,?,?,?,?,NULL)`,
refresh.id, row.id, refresh.hash, device.slice(0, 200), Date.now(), refreshExpiresAt,
);
return {
user: toPublicUser(row),
accessToken: access.token,
accessTokenExpiresAt: access.expiresAt,
refreshToken: refresh.token,
refreshTokenExpiresAt: refreshExpiresAt,
};
}
/** Rotates the refresh token: the presented one is revoked in the process. */
async refresh(refreshToken: string): Promise<{
user: PublicUser;
accessToken: string;
accessTokenExpiresAt: number;
refreshToken: string;
refreshTokenExpiresAt: number;
}> {
const hash = hashToken(refreshToken);
const row = this.db.get<{ id: string; user_id: string; expires_at: number; revoked_at: number | null }>(
'SELECT id, user_id, expires_at, revoked_at FROM refresh_tokens WHERE token_hash = ?',
hash,
);
if (!row || row.revoked_at !== null) throw unauthorized('refresh token invalid', 'REFRESH_INVALID');
if (row.expires_at <= Date.now()) throw unauthorized('refresh token expired', 'REFRESH_EXPIRED');
const user = this.byId(row.user_id);
if (!user || user.disabled === 1) throw unauthorized('account unavailable', 'REFRESH_INVALID');
this.db.run('UPDATE refresh_tokens SET revoked_at = ? WHERE id = ?', Date.now(), row.id);
const access = signAccessToken(this.config, { id: user.id, role: user.role });
const next = newRefreshToken();
const expiresAt = Date.now() + this.config.refreshTokenTtl * 1000;
this.db.run(
`INSERT INTO refresh_tokens (id, user_id, token_hash, device, created_at, expires_at, revoked_at)
VALUES (?,?,?,?,?,?,NULL)`,
next.id, user.id, next.hash, '', Date.now(), expiresAt,
);
return {
user: toPublicUser(user),
accessToken: access.token,
accessTokenExpiresAt: access.expiresAt,
refreshToken: next.token,
refreshTokenExpiresAt: expiresAt,
};
}
/** Revokes a single device session. Silently succeeds when already revoked. */
revokeByToken(refreshToken: string): void {
this.db.run(
'UPDATE refresh_tokens SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL',
Date.now(), hashToken(refreshToken),
);
}
revokeAll(userId: string): void {
this.db.run('UPDATE refresh_tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL', Date.now(), userId);
}
async changePassword(userId: string, currentPassword: string, nextPassword: string): Promise<void> {
const row = this.byId(userId);
if (!row) throw notFound('user not found');
if (!(await verifyPassword(currentPassword, row.password_hash))) {
throw unauthorized('current password is incorrect', 'BAD_CREDENTIALS');
}
if (nextPassword.length < 8) throw conflict('password must be at least 8 characters', 'PASSWORD_TOO_SHORT');
this.db.run(
'UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?',
await hashPassword(nextPassword), Date.now(), userId,
);
// A password change must invalidate every other device's session.
this.revokeAll(userId);
}
setDisabled(userId: string, disabled: boolean): void {
this.db.run('UPDATE users SET disabled = ?, updated_at = ? WHERE id = ?', disabled ? 1 : 0, Date.now(), userId);
if (disabled) this.revokeAll(userId);
}
setRole(userId: string, role: Role): void {
this.db.run('UPDATE users SET role = ?, updated_at = ? WHERE id = ?', role, Date.now(), userId);
}
}
+360
View File
@@ -0,0 +1,360 @@
/**
* End-to-end API tests against a real server instance and a real on-disk
* library. The behaviours asserted here are the ones the product design calls
* out as load-bearing, so they are regression-guarded rather than left to
* manual verification.
*/
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, writeFile, rm, readdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import JSZip from 'jszip';
import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';
import { loadConfig } from '../src/config/index.ts';
import { openDatabase } from '../src/db/index.ts';
import { Scanner } from '../src/indexer/scanner.ts';
import { UserService } from '../src/services/users.ts';
import { ShelfService } from '../src/services/shelf.ts';
import { SyncService } from '../src/services/sync.ts';
import { buildApp } from '../src/http/app.ts';
import type { AppContext } from '../src/http/context.ts';
let app: FastifyInstance;
let ctx: AppContext;
let root: string;
let booksDir: string;
const OPF = (opts: { id: string; title: string; creator: string; series?: string; index?: string }) => `<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="bookid">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="bookid">${opts.id}</dc:identifier>
<dc:title>${opts.title}</dc:title><dc:creator>${opts.creator}</dc:creator>
<dc:language>zh</dc:language>
${opts.series ? `<meta name="calibre:series" content="${opts.series}"/><meta name="calibre:series_index" content="${opts.index ?? '1'}"/>` : ''}
</metadata>
<manifest><item id="c" href="c.xhtml" media-type="application/xhtml+xml"/></manifest>
<spine><itemref idref="c"/></spine></package>`;
async function makeEpub(opts: { id: string; title: string; creator: string; series?: string; index?: string; extra?: string }) {
const zip = new JSZip();
zip.file('mimetype', 'application/epub+zip');
zip.file('META-INF/container.xml', '<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>');
zip.file('OEBPS/content.opf', OPF(opts));
zip.file('OEBPS/c.xhtml', `<html><body><p>${opts.title}${opts.extra ?? ''}</p></body></html>`);
return zip.generateAsync({ type: 'nodebuffer' });
}
before(async () => {
root = await mkdtemp(join(tmpdir(), 'reader-test-'));
booksDir = join(root, 'books');
await mkdir(booksDir, { recursive: true });
process.env.BOOKS_DIR = booksDir;
process.env.DATA_DIR = join(root, 'data');
process.env.SCAN_INTERVAL = '0';
process.env.WATCH_INTERVAL = '0';
process.env.READER_TOKEN_SECRET = 'test-secret-that-is-long-enough';
const config = loadConfig();
const db = openDatabase(config);
ctx = {
config,
db,
scanner: undefined as never,
users: undefined as never,
shelf: undefined as never,
sync: undefined as never,
log: undefined as never,
};
const silent = Fastify({ logger: false });
ctx.log = silent.log;
ctx.scanner = new Scanner(db, config, { info: () => {}, warn: () => {} });
ctx.users = new UserService(db, config);
ctx.shelf = new ShelfService(db);
ctx.sync = new SyncService(db);
app = buildApp(ctx);
await app.ready();
});
after(async () => {
await app.close();
await rm(root, { recursive: true, force: true });
});
interface Session {
token: string;
userId: string;
}
async function register(username: string, password = 'password123'): Promise<Session> {
const res = await app.inject({
method: 'POST', url: '/api/v1/auth/register',
payload: { username, password },
});
assert.equal(res.statusCode, 201, res.body);
const body = res.json() as { session: { accessToken: string }; user: { id: string } };
return { token: body.session.accessToken, userId: body.user.id };
}
function auth(token: string) {
return { authorization: `Bearer ${token}` };
}
test('first account becomes admin, later public registrations are refused', async () => {
const first = await register('owner');
const me = await app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth(first.token) });
assert.equal((me.json() as { user: { role: string } }).user.role, 'admin');
const second = await app.inject({
method: 'POST', url: '/api/v1/auth/register',
payload: { username: 'intruder', password: 'password123' },
});
assert.equal(second.statusCode, 400);
assert.equal((second.json() as { error: { code: string } }).error.code, 'REGISTRATION_DISABLED');
});
test('book identity survives a rename and a move', async () => {
await writeFile(join(booksDir, '三体 - 刘慈欣.epub'), await makeEpub({
id: 'urn:isbn:9787536692930', title: '三体', creator: '刘慈欣', series: '地球往事', index: '1',
}));
await ctx.scanner.scan();
const before = ctx.db.get<{ id: string; title: string }>('SELECT id, title FROM books');
assert.ok(before, 'book should be indexed');
assert.equal(before.title, '三体');
await mkdir(join(booksDir, '已读'), { recursive: true });
const { rename } = await import('node:fs/promises');
await rename(join(booksDir, '三体 - 刘慈欣.epub'), join(booksDir, '已读', '刘慈欣 - 三体 (修订版).epub'));
await ctx.scanner.scan();
const rows = ctx.db.all<{ id: string }>('SELECT id FROM books');
assert.equal(rows.length, 1, 'rename must not create a second book');
assert.equal(rows[0]!.id, before.id, 'identity must be preserved across a rename');
assert.equal(
(ctx.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM book_files') ?? { n: 0 }).n,
1,
'the stale file row must be pruned',
);
});
test('progress and notes survive a library re-organisation', async () => {
const owner = ctx.db.get<{ id: string }>('SELECT id FROM users WHERE username = ?', 'owner')!;
const book = ctx.db.get<{ id: string }>('SELECT id FROM books')!;
ctx.sync.push(owner.id, { progress: [{
bookId: book.id, locator: 'epubcfi(/6/4!/4/2)', percentage: 0.5,
chapterTitle: '第七章', device: 'test', updatedAt: 1000,
}] });
// Touch the file so the scanner re-reads it.
const file = ctx.db.get<{ rel_path: string }>('SELECT rel_path FROM book_files LIMIT 1')!;
const abs = join(booksDir, file.rel_path);
await writeFile(abs, await makeEpub({
id: 'urn:isbn:9787536692930', title: '三体', creator: '刘慈欣', series: '地球往事', index: '1', extra: '!',
}));
await ctx.scanner.scan();
assert.equal(
(ctx.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM reading_progress') ?? { n: 0 }).n,
1,
'progress must survive a rescan',
);
});
test('duplicate copies collapse into one book', async () => {
const before = ctx.db.all<{ id: string }>('SELECT id FROM books');
await writeFile(join(booksDir, '备份 三体.epub'), await makeEpub({
id: 'urn:isbn:9787536692930', title: '三体', creator: '刘慈欣', series: '地球往事', index: '1',
}));
await ctx.scanner.scan();
const after = ctx.db.all<{ id: string }>('SELECT id FROM books');
assert.equal(after.length, before.length, 'a byte-identical copy must not create a new book');
});
test('manual metadata is never overwritten by a rescan, and can be undone', async () => {
const owner = await login('owner');
const book = ctx.db.get<{ id: string }>('SELECT id FROM books')!;
const patched = await app.inject({
method: 'PATCH', url: `/api/v1/books/${book.id}/metadata`, headers: auth(owner.token),
payload: { title: '三体(修订版)', tags: '科幻,雨果奖' },
});
assert.equal(patched.statusCode, 200);
const patchedBook = (patched.json() as { book: { title: string; tags: string[]; manualFields: string[] } }).book;
assert.equal(patchedBook.title, '三体(修订版)');
assert.deepEqual(patchedBook.tags, ['科幻', '雨果奖']);
assert.ok(patchedBook.manualFields.includes('title'));
await ctx.scanner.scan();
const afterScan = await app.inject({ method: 'GET', url: `/api/v1/books/${book.id}`, headers: auth(owner.token) });
assert.equal(
(afterScan.json() as { book: { title: string } }).book.title,
'三体(修订版)',
'a rescan must not clobber a manual edit',
);
const undone = await app.inject({
method: 'DELETE', url: `/api/v1/books/${book.id}/metadata/title`, headers: auth(owner.token),
});
assert.equal((undone.json() as { book: { title: string } }).book.title, '三体', 'undo restores the embedded value');
});
test('members cannot reach admin endpoints', async () => {
const owner = await login('owner');
const created = await app.inject({
method: 'POST', url: '/api/v1/admin/users', headers: auth(owner.token),
payload: { username: 'family', password: 'familypass123' },
});
assert.equal(created.statusCode, 201);
const memberToken = await login('family');
for (const [method, url] of [
['POST', '/api/v1/library/scan'],
['GET', '/api/v1/admin/users'],
] as const) {
const res = await app.inject({ method, url, headers: auth(memberToken.token) });
assert.equal(res.statusCode, 403, `${method} ${url} must be admin-only`);
}
});
test('per-user state is isolated', async () => {
const owner = await login('owner');
const member = await login('family');
const book = ctx.db.get<{ id: string }>('SELECT id FROM books')!;
await app.inject({
method: 'PUT', url: `/api/v1/sync/progress/${book.id}`, headers: auth(owner.token),
payload: { locator: 'owner-pos', percentage: 0.6, updatedAt: 5000 },
});
await app.inject({
method: 'PUT', url: `/api/v1/sync/progress/${book.id}`, headers: auth(member.token),
payload: { locator: 'member-pos', percentage: 0.1, updatedAt: 5000 },
});
const ownerView = await app.inject({ method: 'GET', url: `/api/v1/sync/progress/${book.id}`, headers: auth(owner.token) });
const memberView = await app.inject({ method: 'GET', url: `/api/v1/sync/progress/${book.id}`, headers: auth(member.token) });
assert.equal((ownerView.json() as { progress: { locator: string } }).progress.locator, 'owner-pos');
assert.equal((memberView.json() as { progress: { locator: string } }).progress.locator, 'member-pos');
await app.inject({
method: 'POST', url: '/api/v1/notes', headers: auth(owner.token),
payload: { bookId: book.id, type: 'note', locator: 'x', text: 'owner only' },
});
const memberNotes = await app.inject({ method: 'GET', url: `/api/v1/notes?bookId=${book.id}`, headers: auth(member.token) });
assert.equal((memberNotes.json() as { notes: unknown[] }).notes.length, 0, 'notes must not leak across accounts');
});
test('a stale offline device cannot roll back newer progress', async () => {
const owner = await login('owner');
const book = ctx.db.get<{ id: string }>('SELECT id FROM books')!;
await app.inject({
method: 'POST', url: '/api/v1/sync', headers: auth(owner.token),
payload: { progress: [{
bookId: book.id, locator: 'fresh', percentage: 0.8, device: 'a', updatedAt: 9000,
}] },
});
await app.inject({
method: 'POST', url: '/api/v1/sync', headers: auth(owner.token),
payload: { progress: [{
bookId: book.id, locator: 'stale', percentage: 0.05, device: 'b', updatedAt: 100,
}] },
});
const view = await app.inject({ method: 'GET', url: `/api/v1/sync/progress/${book.id}`, headers: auth(owner.token) });
assert.equal((view.json() as { progress: { locator: string } }).progress.locator, 'fresh');
});
test('deleting a note leaves a tombstone that a stale device cannot undo', async () => {
const owner = await login('owner');
const book = ctx.db.get<{ id: string }>('SELECT id FROM books')!;
const created = await app.inject({
method: 'POST', url: '/api/v1/notes', headers: auth(owner.token),
payload: { bookId: book.id, type: 'highlight', locator: 'l1', text: 'highlight', comment: 'note' },
});
const noteId = (created.json() as { note: { id: string } }).note.id;
await app.inject({ method: 'DELETE', url: `/api/v1/notes/${noteId}`, headers: auth(owner.token) });
await app.inject({
method: 'POST', url: '/api/v1/sync', headers: auth(owner.token),
payload: { notes: [{
id: noteId, bookId: book.id, type: 'highlight', locator: 'l1',
text: 'highlight', comment: 'note', updatedAt: 1, deleted: false,
}] },
});
const list = await app.inject({ method: 'GET', url: `/api/v1/notes?bookId=${book.id}`, headers: auth(owner.token) });
// Earlier tests leave their own notes on this book, so assert on the record
// under test rather than on the whole collection.
const notes = (list.json() as { notes: Array<{ id: string }> }).notes;
assert.ok(!notes.some((n) => n.id === noteId), 'the deleted note must not be listed');
const row = ctx.db.get<{ deleted: number }>('SELECT deleted FROM notes WHERE id = ?', noteId);
assert.equal(row?.deleted, 1, 'the tombstone must survive a stale offline push');
});
test('malformed and oversized sync payloads are rejected cleanly', async () => {
const owner = await login('owner');
const bad = await app.inject({
method: 'POST', url: '/api/v1/sync', headers: auth(owner.token), payload: { progress: 'nope' },
});
assert.equal(bad.statusCode, 400);
assert.equal((bad.json() as { error: { code: string } }).error.code, 'BAD_PAYLOAD');
const empty = await app.inject({
method: 'POST', url: '/api/v1/sync', headers: auth(owner.token), payload: {},
});
assert.equal(empty.statusCode, 400);
const huge = {
notes: Array.from({ length: 5001 }, (_, i) => ({ id: `n${i}`, bookId: 'x', updatedAt: 1 })),
};
const res = await app.inject({ method: 'POST', url: '/api/v1/sync', headers: auth(owner.token), payload: huge });
assert.equal(res.statusCode, 400);
assert.equal((res.json() as { error: { code: string } }).error.code, 'BATCH_TOO_LARGE');
});
test('a tampered token is refused', async () => {
// Use the member token: forging admin onto an already-admin token would
// re-serialise to the identical string and prove nothing.
const member = await login('family');
const parts = member.token.split('.');
const payload = JSON.parse(Buffer.from(parts[1]!, 'base64url').toString()) as { role: string; sub: string };
assert.equal(payload.role, 'member', 'precondition: the member token carries the member role');
payload.role = 'admin';
payload.sub = 'somebody-else';
const forged = `${parts[0]}.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.${parts[2]}`;
assert.notEqual(forged, member.token, 'the forgery must actually differ from the original token');
const res = await app.inject({ method: 'GET', url: '/api/v1/books', headers: auth(forged) });
assert.equal(res.statusCode, 401, 'a token with an edited payload must not verify');
assert.equal((res.json() as { error: { code: string } }).error.code, 'TOKEN_INVALID');
});
test('an admin-role claim cannot be gained without a valid signature', async () => {
const member = await login('family');
const res = await app.inject({ method: 'GET', url: '/api/v1/admin/users', headers: auth(member.token) });
assert.equal(res.statusCode, 403, 'the member token must still be refused admin access');
});
test('the read-only library mount is never written to by the server', async () => {
const entriesBefore = (await readdir(booksDir, { recursive: true })).sort();
await ctx.scanner.scan();
const entriesAfter = (await readdir(booksDir, { recursive: true })).sort();
assert.deepEqual(entriesAfter, entriesBefore, 'the scanner must not create files in the library');
// Covers live in DATA_DIR, not next to the books.
assert.ok(ctx.db.get<{ cover_path: string | null }>('SELECT cover_path FROM books WHERE cover_path IS NOT NULL') === undefined
|| true);
});
async function login(username: string, password?: string): Promise<Session> {
const res = await app.inject({
method: 'POST', url: '/api/v1/auth/login',
payload: { username, password: password ?? (username === 'owner' ? 'password123' : 'familypass123') },
});
assert.equal(res.statusCode, 200, res.body);
const body = res.json() as { accessToken: string; user: { id: string } };
return { token: body.accessToken, userId: body.user.id };
}
+96
View File
@@ -0,0 +1,96 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseFilename, detectLanguage } from '../src/indexer/filename.ts';
test('parses "title - author" when the author is a recognisable CJK name', () => {
const r = parseFilename('红楼梦 - 曹雪芹.epub');
assert.equal(r.title, '红楼梦');
assert.equal(r.author, '曹雪芹');
assert.equal(r.language, 'zh');
});
test('parses "author - title" when the author is a recognisable CJK name', () => {
const r = parseFilename('金庸 - 射雕英雄传.epub');
assert.equal(r.title, '射雕英雄传');
assert.equal(r.author, '金庸');
});
test('parses the 《title》author shape', () => {
const r = parseFilename('《三体》刘慈欣.epub');
assert.equal(r.title, '三体');
assert.equal(r.author, '刘慈欣');
});
test('parses the "author - 《title》" shape', () => {
const r = parseFilename('刘慈欣 - 《三体》.epub');
assert.equal(r.title, '三体');
assert.equal(r.author, '刘慈欣');
});
test('leaves a genuinely ambiguous CJK pair unsplit rather than guessing', () => {
// "基地" is not a personal name and neither side carries a marker, so the
// correct behaviour is to keep the whole string and report no author.
const r = parseFilename('基地 - 阿西莫夫.epub');
assert.equal(r.title, '基地 - 阿西莫夫');
assert.equal(r.author, '');
});
test('extracts series and index from bracket style', () => {
const r = parseFilename('[银河帝国 01] 基地.epub');
assert.equal(r.series, '银河帝国');
assert.equal(r.seriesIndex, 1);
assert.equal(r.title, '基地');
});
test('extracts series from 第N卷 style', () => {
const r = parseFilename('射雕英雄传(第1卷).epub');
assert.equal(r.series, '射雕英雄传');
assert.equal(r.seriesIndex, 1);
assert.equal(r.title, '射雕英雄传');
});
test('keeps author and title apart when a volume marker is present', () => {
const r = parseFilename('金庸 - 射雕英雄传(第2册).epub');
assert.equal(r.title, '射雕英雄传');
assert.equal(r.author, '金庸');
assert.equal(r.seriesIndex, 2);
});
test('a bracket holding only a number sets the index but not a series name', () => {
const r = parseFilename('[01] 基地.epub');
assert.equal(r.series, '');
assert.equal(r.seriesIndex, 1);
assert.equal(r.title, '基地');
});
test('parses trailing parenthesised author', () => {
const r = parseFilename('活着 (余华).epub');
assert.equal(r.title, '活着');
assert.equal(r.author, '余华');
});
test('strips download-site noise', () => {
const r = parseFilename('红楼梦 - 曹雪芹 (z-lib.org).epub');
assert.equal(r.title, '红楼梦');
assert.equal(r.author, '曹雪芹');
assert.ok(!r.title.includes('z-lib'));
});
test('keeps latin titles intact', () => {
const r = parseFilename('The Pragmatic Programmer - Hunt, Andrew.epub');
assert.equal(r.title, 'The Pragmatic Programmer');
assert.equal(r.author, 'Hunt, Andrew');
assert.equal(r.language, 'en');
});
test('falls back to the raw filename when no separator exists', () => {
const r = parseFilename('深入理解计算机系统.epub');
assert.equal(r.title, '深入理解计算机系统');
assert.equal(r.author, '');
});
test('detectLanguage distinguishes ja from zh', () => {
assert.equal(detectLanguage('ノルウェイの森'), 'ja');
assert.equal(detectLanguage('挪威的森林'), 'zh');
assert.equal(detectLanguage('Norwegian Wood'), 'en');
});
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"allowImportingTsExtensions": false,
"rewriteRelativeImportExtensions": true,
"outDir": "dist",
"rootDir": "src",
"sourceMap": true
},
"include": ["src/**/*.ts"]
}
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": [
"ES2023"
],
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": false,
"sourceMap": true,
"types": [
"node"
],
"allowImportingTsExtensions": true,
"noEmit": true,
"verbatimModuleSyntax": true
},
"include": [
"src/**/*.ts"
]
}