Initial commit
This commit is contained in:
1 parent
519a677598
commit
8d9651d555
37 files changed
+4415
No files matched your search
@@ -0,0 +1,81 @@
|
||||
name: Deploy to Cloudflare Workers
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
frontend/package.json
|
||||
backend/package.json
|
||||
|
||||
# 关键步骤:Wrangler 的 build 命令会调用前端构建,先进入 frontend 目录安装依赖,否则构建会失败。
|
||||
- name: Install Frontend Dependencies
|
||||
run: |
|
||||
cd frontend
|
||||
npm install
|
||||
|
||||
# 安装后端依赖 (包含 wrangler 等)
|
||||
- name: Install Backend Dependencies
|
||||
run: |
|
||||
cd backend
|
||||
npm install
|
||||
|
||||
# 动态注入 D1 数据库ID 和 数据库名称
|
||||
- name: Inject D1 Database ID
|
||||
env:
|
||||
CLOUDFLARE_D1_DATABASE_ID: ${{ secrets.CLOUDFLARE_D1_DATABASE_ID }}
|
||||
CLOUDFLARE_D1_DATABASE_NAME: ${{ secrets.CLOUDFLARE_D1_DATABASE_NAME || '2fauth-db' }}
|
||||
run: |
|
||||
# 仅替换第一个匹配到的 database_id (对应生产环境配置),保留 dev 环境配置
|
||||
sed -i "0,/\"database_id\":/s/\"database_id\": \".*\"/\"database_id\": \"$CLOUDFLARE_D1_DATABASE_ID\"/" backend/wrangler.jsonc
|
||||
sed -i "0,/\"database_name\":/s/\"database_name\": \".*\"/\"database_name\": \"$CLOUDFLARE_D1_DATABASE_NAME\"/" backend/wrangler.jsonc
|
||||
|
||||
# 初始化或更新数据库结构 (执行 schema.sql)
|
||||
- name: Update Database Schema
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
workingDirectory: 'backend'
|
||||
command: d1 execute ${{ secrets.CLOUDFLARE_D1_DATABASE_NAME || '2fauth-db' }} --remote --file=schema.sql
|
||||
|
||||
# 使用官方 Action 进行部署
|
||||
- name: Deploy to Cloudflare Workers
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
workingDirectory: 'backend'
|
||||
# 保持与 package.json 中一致的参数,开启压缩
|
||||
command: deploy --minify
|
||||
secrets: |
|
||||
OAUTH_CLIENT_ID
|
||||
OAUTH_CLIENT_SECRET
|
||||
OAUTH_BASE_URL
|
||||
OAUTH_ID
|
||||
OAUTH_REDIRECT_URI
|
||||
ENCRYPTION_KEY
|
||||
JWT_SECRET
|
||||
env:
|
||||
OAUTH_CLIENT_ID: ${{ secrets.OAUTH_CLIENT_ID }}
|
||||
OAUTH_CLIENT_SECRET: ${{ secrets.OAUTH_CLIENT_SECRET }}
|
||||
OAUTH_BASE_URL: ${{ secrets.OAUTH_BASE_URL || 'https://github.com' }}
|
||||
OAUTH_ID: ${{ secrets.OAUTH_ID }}
|
||||
OAUTH_REDIRECT_URI: ${{ secrets.OAUTH_REDIRECT_URI }}
|
||||
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
|
||||
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
logs
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
.secrets
|
||||
|
||||
.vscode/*
|
||||
.devcontainer/*
|
||||
|
||||
backend/.dev.vars
|
||||
backend/.wrangler
|
||||
backend/README.md
|
||||
backend/node_modules
|
||||
backend/package-lock.json
|
||||
|
||||
frontend/.vscode
|
||||
frontend/node_modules
|
||||
frontend/package-lock.json
|
||||
@@ -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/>.
|
||||
@@ -0,0 +1,135 @@
|
||||
# 2FAuth Worker - Serverless 2FA Manager
|
||||
|
||||
**2FAuth Worker** 是一个基于 Cloudflare Workers + D1 (SQLite) + Vue 3 构建的轻量级、安全、无服务器的二步验证(2FA/TOTP)管理工具。
|
||||
|
||||
它允许你完全掌控自己的 2FA 数据,支持多端同步、WebDAV 备份、加密导出,并且完全免费部署在 Cloudflare 的边缘网络上。
|
||||
|
||||
> **免责声明**
|
||||
> 本项目代码 `100%` 由 `Gemini 3.5 Pro` 生成,作者不对任何数据丢失负责。
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
- ☁️ **Serverless 架构**:后端运行在 Cloudflare Workers,数据库使用 D1,无需购买服务器。
|
||||
- 🔒 **安全隐私**:数据存储在你的私有 D1 数据库中,支持 AES-GCM 高强度加密导出。
|
||||
- 📱 **多端适配**:响应式设计,完美支持 PC 和移动端访问。
|
||||
- 📷 **扫码添加**:支持调用摄像头扫描二维码或上传图片识别添加账号。
|
||||
- 🔄 **数据同步**:支持 WebDAV 自动备份,数据永不丢失。
|
||||
- 📂 **导入导出**:支持从 Google Authenticator (导出文本), 2FAS, 或标准 JSON 格式导入数据。
|
||||
- 🔐 **GitHub 登录**:集成 GitHub OAuth 安全登录。
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
- **Frontend**: Vue 3, Vite, Element Plus, Pinia (State Management via Reactive)
|
||||
- **Backend**: Cloudflare Workers, Hono (Web Framework)
|
||||
- **Database**: Cloudflare D1 (Serverless SQLite)
|
||||
- **Deployment**: GitHub Actions
|
||||
|
||||
## 🚀 部署指南 (Deployment)
|
||||
|
||||
本项目设计为通过 GitHub Actions 自动部署到 Cloudflare。
|
||||
|
||||
### 1. 准备工作
|
||||
|
||||
1. 注册一个 [Cloudflare](https://dash.cloudflare.com/) 账号。
|
||||
2. Fork 本仓库到你的 GitHub 账户。
|
||||
|
||||
### 2. 配置 Cloudflare D1 数据库
|
||||
|
||||
1. 登录 Cloudflare Dashboard。
|
||||
2. 进入左侧菜单的 **存储和数据库** -> **D1 SQL 数据库**。
|
||||
3. 点击 **+ 创建数据库** 创建新数据库,命名为 **2fauth-db**,点击 **创建** 确认。
|
||||
4. 创建完成后,在数据库详情页找到 **Database ID** 并复制。
|
||||
5. 请妥善保存这个 ID,稍后我们需要将其配置到 GitHub Secrets (`CLOUDFLARE_D1_DATABASE_ID`) 中。
|
||||
|
||||
### 3. 配置 GitHub OAuth
|
||||
|
||||
1. 访问 GitHub Developer Settings -> **OAuth Apps** -> **New OAuth App**。
|
||||
2. 填写信息:
|
||||
- **Application Name**: 2FAuth (或任意名称)
|
||||
- **Homepage URL**: `https://你的Worker子域名.workers.dev` (部署后可修改为Worker绑定的自定义域名)
|
||||
- **Authorization callback URL**: `https://你的Worker子域名.workers.dev/api/oauth/callback` (部署后可修改为Worker绑定的自定义域名)
|
||||
3. 创建后,保存 `Client ID` 并生成一个新的 `Client Secret`。
|
||||
|
||||
### 4. 配置 GitHub Secrets
|
||||
|
||||
在你的 GitHub 仓库中,进入 **Settings** -> **Secrets and variables** -> **Actions** -> **New repository secret**,添加以下变量:
|
||||
|
||||
| Secret Name | 说明 | 示例值 |
|
||||
| :--- | :--- | :--- |
|
||||
| `CLOUDFLARE_API_TOKEN` | Cloudflare API 令牌 (需有 Workers 和 D1 读写权限) | `r4_...` |
|
||||
| `CLOUDFLARE_D1_DATABASE_ID` | 你的 D1 数据库 ID (步骤 2 中生成的) | `xxxxxxxx-xxxx...` |
|
||||
| `CLOUDFLARE_D1_DATABASE_NAME` | 你的 D1 数据库 名称 (步骤 2 中生成的) | 默认`2fauth-db` |
|
||||
| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare 账户 ID (在 Dash 面板右下角) | `a1b2...` |
|
||||
| `OAUTH_CLIENT_ID` | GitHub OAuth Client ID | `Ov23...` |
|
||||
| `OAUTH_CLIENT_SECRET` | GitHub OAuth Client Secret | `a1b2...` |
|
||||
| `OAUTH_BASE_URL` | GitHub API 基础地址 | 默认`https://github.com` |
|
||||
| `OAUTH_ID` | 允许登录的 GitHub 用户名 (白名单机制) | `your_github_username` |
|
||||
| `OAUTH_REDIRECT_URI` | 你的 Worker 回调地址 | `https://xxx.workers.dev/oauth/callback` |
|
||||
| `ENCRYPTION_KEY` | 用于加密敏感数据的密钥 (32位随机字符串) | `openssl rand -hex 16` 生成 |
|
||||
| `JWT_SECRET` | 用于签发登录 Token 的密钥 (随机字符串) | `random_string_here` |
|
||||
|
||||
### 5. 自动部署
|
||||
|
||||
1. 进入 GitHub 仓库的 **Actions** 页面。
|
||||
2. 手动触发 `Deploy to Cloudflare Workers` 工作流,或者直接 Push 代码到 `main` 分支。
|
||||
3. 等待工作流执行完毕。
|
||||
4. 部署成功后,访问你的 Worker URL 即可使用!
|
||||
|
||||
> **注意**:首次部署时,GitHub Action 会自动执行 `schema.sql` 初始化数据库表结构。
|
||||
|
||||
---
|
||||
|
||||
## 💻 本地开发 (Local Development)
|
||||
|
||||
### 环境要求
|
||||
- Node.js 20+
|
||||
- npm
|
||||
|
||||
### 1. 克隆项目
|
||||
```bash
|
||||
git clone https://github.com/nap0o/2fauth-worker.git
|
||||
cd 2fauth-worker
|
||||
```
|
||||
|
||||
### 2. 安装依赖
|
||||
```bash
|
||||
# 安装前端依赖
|
||||
cd frontend
|
||||
npm install
|
||||
|
||||
# 安装后端依赖
|
||||
cd ../backend
|
||||
npm install
|
||||
```
|
||||
|
||||
### 3. 配置本地环境变量
|
||||
在 `backend` 目录下创建 `.dev.vars` 文件,填入开发所需的 Secrets:
|
||||
```ini
|
||||
OAUTH_CLIENT_ID="你的测试AppID"
|
||||
OAUTH_CLIENT_SECRET="你的测试AppSecret"
|
||||
OAUTH_REDIRECT_URI="http://localhost:5173/oauth/callback"
|
||||
OAUTH_BASE_URL="https://github.com"
|
||||
OAUTH_ID="你的GitHub用户名"
|
||||
ENCRYPTION_KEY="本地测试用的32位密钥"
|
||||
JWT_SECRET="本地测试用的JWT密钥"
|
||||
```
|
||||
|
||||
### 4. 初始化本地数据库
|
||||
```bash
|
||||
cd backend
|
||||
npx wrangler d1 execute 2fauth-db-dev --local --env dev --file=schema.sql
|
||||
```
|
||||
|
||||
### 5. 启动开发服务器
|
||||
|
||||
**终端 1 (后端):**
|
||||
```bash
|
||||
cd backend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**终端 2 (前端):**
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
@@ -0,0 +1,33 @@
|
||||
# prod
|
||||
dist/
|
||||
|
||||
# dev
|
||||
.yarn/
|
||||
!.yarn/releases
|
||||
.vscode/*
|
||||
!.vscode/launch.json
|
||||
!.vscode/*.code-snippets
|
||||
.idea/workspace.xml
|
||||
.idea/usage.statistics.xml
|
||||
.idea/shelf
|
||||
|
||||
# deps
|
||||
node_modules/
|
||||
.wrangler
|
||||
|
||||
# env
|
||||
.env
|
||||
.env.production
|
||||
.dev.vars
|
||||
|
||||
# logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev src/index.ts --env dev --host 0.0.0.0 --port 8787",
|
||||
"deploy": "wrangler deploy --minify src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.11.10",
|
||||
"webdav": "^5.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"wrangler": "^3.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
-- 账号表:每一行存储一个 2FA 凭据
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
service TEXT NOT NULL,
|
||||
account TEXT NOT NULL,
|
||||
category TEXT,
|
||||
secret TEXT NOT NULL, -- 存储加密后的 JSON 字符串 {encrypted, iv, salt}
|
||||
digits INTEGER DEFAULT 6,
|
||||
period INTEGER DEFAULT 30,
|
||||
created_at INTEGER,
|
||||
created_by TEXT,
|
||||
updated_at INTEGER,
|
||||
updated_by TEXT
|
||||
);
|
||||
|
||||
-- WebDAV 配置表
|
||||
CREATE TABLE IF NOT EXISTS webdav_configs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
url TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT NOT NULL, -- 存储加密后的 JSON 字符串
|
||||
save_dir TEXT,
|
||||
updated_at INTEGER
|
||||
);
|
||||
|
||||
-- 创建索引以加速查询
|
||||
CREATE INDEX IF NOT EXISTS idx_accounts_service ON accounts(service);
|
||||
CREATE INDEX IF NOT EXISTS idx_accounts_created_at ON accounts(created_at DESC);
|
||||
@@ -0,0 +1,34 @@
|
||||
export const BASE32_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
|
||||
export const SECURITY_CONFIG = {
|
||||
MAX_LOGIN_ATTEMPTS: 5,
|
||||
LOCKOUT_TIME: 15 * 60 * 1000,
|
||||
JWT_EXPIRY: 2 * 60 * 60, // 2小时
|
||||
MAX_INPUT_LENGTH: 100,
|
||||
MIN_EXPORT_PASSWORD_LENGTH: 12,
|
||||
MAX_OAUTH_ATTEMPTS: 3,
|
||||
OAUTH_LOCKOUT_TIME: 10 * 60 * 1000,
|
||||
MAX_FILE_SIZE: 10 * 1024 * 1024,
|
||||
};
|
||||
|
||||
// Cloudflare Workers 环境变量类型定义
|
||||
export type EnvBindings = {
|
||||
DB: D1Database;
|
||||
OAUTH_BASE_URL: string;
|
||||
OAUTH_CLIENT_ID: string;
|
||||
OAUTH_CLIENT_SECRET: string;
|
||||
OAUTH_REDIRECT_URI: string;
|
||||
OAUTH_ID: string;
|
||||
JWT_SECRET: string;
|
||||
ENCRYPTION_KEY: string;
|
||||
};
|
||||
|
||||
// 自定义错误类
|
||||
export class AppError extends Error {
|
||||
statusCode: number;
|
||||
constructor(message: string, statusCode = 500) {
|
||||
super(message);
|
||||
this.name = 'AppError';
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { logger } from 'hono/logger';
|
||||
import { EnvBindings } from './config';
|
||||
|
||||
// 稍后我们会在这里引入拆分好的路由模块
|
||||
import authRoutes from './routes/auth';
|
||||
import accountsRoutes from './routes/accounts';
|
||||
import webdavRoutes from './routes/webdav';
|
||||
|
||||
// 扩展 EnvBindings 以包含 ASSETS (Cloudflare Pages/Workers Assets)
|
||||
type Bindings = EnvBindings & { ASSETS: { fetch: (req: Request) => Promise<Response> } };
|
||||
|
||||
// 初始化 Hono 应用,并绑定 Cloudflare 的环境变量类型
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
// 1. 全局中间件
|
||||
app.use('*', logger()); // 自动打印请求日志
|
||||
app.use('/api/*', cors({
|
||||
origin: '*', // 开发环境允许跨域,生产环境可配置具体域名
|
||||
allowHeaders: ['Content-Type', 'Authorization'],
|
||||
allowMethods: ['POST', 'GET', 'OPTIONS', 'PUT', 'DELETE'],
|
||||
maxAge: 86400,
|
||||
}));
|
||||
|
||||
// 2. 健康检查接口 (用于测试后端是否正常启动)
|
||||
app.get('/api', (c) => c.text('🔐 2FA Secure Manager API is running!'));
|
||||
|
||||
// 3. 挂载子路由
|
||||
app.route('/api/oauth', authRoutes);
|
||||
app.route('/api/accounts', accountsRoutes);
|
||||
app.route('/api/webdav', webdavRoutes);
|
||||
|
||||
// 4. 全局错误处理
|
||||
app.onError((err, c) => {
|
||||
const statusCode = (err as any).statusCode || (err as any).status || 500;
|
||||
|
||||
// 特殊处理: WebDAV list 接口如果返回 404,说明目录不存在,视为无备份,返回空列表
|
||||
if (c.req.path.includes('/webdav/list') && (Number(statusCode) === 404 || err.message.includes('404'))) {
|
||||
return c.json({ success: true, backups: [] });
|
||||
}
|
||||
|
||||
console.error(`[Server Error] ${err.message}`);
|
||||
return c.json({
|
||||
success: false,
|
||||
error: err.message || 'Internal Server Error'
|
||||
}, statusCode);
|
||||
});
|
||||
|
||||
// 5. 404 处理
|
||||
app.notFound((c) => {
|
||||
return c.json({ success: false, error: 'API Not Found' }, 404);
|
||||
});
|
||||
|
||||
// 6. 导出默认处理函数,实现前后端路由分发
|
||||
export default {
|
||||
async fetch(request: Request, env: Bindings, ctx: any) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// 识别 API 请求前缀
|
||||
if (url.pathname.startsWith('/api')) {
|
||||
return app.fetch(request, env, ctx);
|
||||
}
|
||||
|
||||
// 非 API 请求全部交给静态资源 (Frontend)
|
||||
return env.ASSETS.fetch(request);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,420 @@
|
||||
import { Hono } from 'hono';
|
||||
import { EnvBindings, AppError, SECURITY_CONFIG } from '../config';
|
||||
import { authMiddleware, sanitizeInput, validateBase32Secret, validateServiceName, validateAccountName, parseOTPAuthURI } from '../utils/helper';
|
||||
import { encryptData, decryptData, generateTOTP } from '../utils/crypto';
|
||||
|
||||
// 定义带有环境变量和用户变量的 Hono 实例
|
||||
const accounts = new Hono<{ Bindings: EnvBindings, Variables: { user: any } }>();
|
||||
|
||||
// 🛡️ 为这下面所有的路由挂载鉴权中间件 (必须登录才能访问)
|
||||
accounts.use('*', authMiddleware);
|
||||
|
||||
// 辅助:加密单个字段并序列化为字符串存储
|
||||
async function encryptField(data: any, key: string) {
|
||||
const encrypted = await encryptData(data, key);
|
||||
return JSON.stringify(encrypted);
|
||||
}
|
||||
|
||||
// 辅助:反序列化并解密单个字段
|
||||
async function decryptField(encryptedStr: string, key: string) {
|
||||
try {
|
||||
const encryptedObj = JSON.parse(encryptedStr);
|
||||
return await decryptData(encryptedObj, key);
|
||||
} catch (e) {
|
||||
console.error('Decryption failed', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 1. 获取所有账号
|
||||
// ==========================================
|
||||
accounts.get('/', async (c) => {
|
||||
const pageStr = c.req.query('page');
|
||||
const limitStr = c.req.query('limit');
|
||||
const search = c.req.query('search') || '';
|
||||
const page = pageStr ? parseInt(pageStr) : 1;
|
||||
const limit = limitStr ? parseInt(limitStr) : 50; // 恢复默认 50 条,配合自动迁移策略
|
||||
|
||||
let whereClause = '';
|
||||
let params: any[] = [];
|
||||
|
||||
if (search) {
|
||||
whereClause = " WHERE (service LIKE ? OR account LIKE ? OR category LIKE ?)";
|
||||
const likeTerm = `%${search}%`;
|
||||
params.push(likeTerm, likeTerm, likeTerm);
|
||||
}
|
||||
|
||||
const countResult = await c.env.DB.prepare(`SELECT COUNT(*) as total FROM accounts${whereClause}`).bind(...params).first();
|
||||
const total = countResult?.total as number || 0;
|
||||
|
||||
const query = `SELECT * FROM accounts${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`;
|
||||
params.push(limit, (page - 1) * limit);
|
||||
|
||||
const { results } = await c.env.DB.prepare(query).bind(...params).all();
|
||||
|
||||
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
|
||||
|
||||
// 并行解密所有账号的 secret
|
||||
const accountsList = await Promise.all(results.map(async (row: any) => {
|
||||
const secret = await decryptField(row.secret, key);
|
||||
|
||||
// 🚀 [自动迁移] 检查是否为旧格式 (含 salt),如果是则异步升级为极速格式
|
||||
try {
|
||||
const raw = JSON.parse(row.secret);
|
||||
if (raw.salt && raw.salt.length > 0) {
|
||||
// 重新加密 (encryptField 现已使用极速模式)
|
||||
const newSecret = await encryptField(secret, key);
|
||||
// 异步更新数据库 (不阻塞响应)
|
||||
c.executionCtx.waitUntil(
|
||||
c.env.DB.prepare("UPDATE accounts SET secret = ? WHERE id = ?")
|
||||
.bind(newSecret, row.id)
|
||||
.run()
|
||||
);
|
||||
}
|
||||
} catch (e) { /* ignore error */ }
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
service: row.service,
|
||||
account: row.account,
|
||||
category: row.category,
|
||||
secret: secret || '', // 解密失败则为空
|
||||
digits: row.digits,
|
||||
period: row.period,
|
||||
createdAt: row.created_at
|
||||
};
|
||||
}));
|
||||
|
||||
const response: any = { success: true, accounts: accountsList };
|
||||
|
||||
response.pagination = {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit)
|
||||
};
|
||||
|
||||
return c.json(response);
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 2. 添加新账号
|
||||
// ==========================================
|
||||
accounts.post('/', async (c) => {
|
||||
const { service, category, account, secret, digits = 6, period = 30 } = await c.req.json();
|
||||
const user = c.get('user');
|
||||
|
||||
if (!validateServiceName(service) || !validateAccountName(account) || !validateBase32Secret(secret)) {
|
||||
throw new AppError('Invalid input format', 400);
|
||||
}
|
||||
if (![6, 8].includes(digits) || ![30, 60].includes(period)) {
|
||||
throw new AppError('Invalid digits or period', 400);
|
||||
}
|
||||
|
||||
// D1: 查重
|
||||
const existing = await c.env.DB.prepare(
|
||||
"SELECT id FROM accounts WHERE service = ? AND account = ?"
|
||||
).bind(service, account).first();
|
||||
|
||||
if (existing) throw new AppError('Account already exists', 409);
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
|
||||
const secretEncrypted = await encryptField(secret.replace(/\s/g, '').toUpperCase(), key);
|
||||
|
||||
// D1: 插入
|
||||
await c.env.DB.prepare(
|
||||
`INSERT INTO accounts (id, service, account, category, secret, digits, period, created_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).bind(
|
||||
id, sanitizeInput(service, 50), sanitizeInput(account, 100), category ? sanitizeInput(category, 30) : '',
|
||||
secretEncrypted, digits, period, Date.now(), user.username
|
||||
).run();
|
||||
|
||||
const newAccount = { id, service, account, category, digits, period };
|
||||
|
||||
return c.json({ success: true, account: { ...newAccount, secret: '[PROTECTED]' } });
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 3. 修改账号信息
|
||||
// ==========================================
|
||||
accounts.put('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const { service, category, account } = await c.req.json();
|
||||
const user = c.get('user');
|
||||
|
||||
if (!validateServiceName(service) || !validateAccountName(account)) {
|
||||
throw new AppError('Invalid input format', 400);
|
||||
}
|
||||
|
||||
const result = await c.env.DB.prepare(
|
||||
`UPDATE accounts
|
||||
SET service = ?, account = ?, category = ?, updated_at = ?, updated_by = ?
|
||||
WHERE id = ?`
|
||||
).bind(
|
||||
sanitizeInput(service, 50), sanitizeInput(account, 100), category ? sanitizeInput(category, 30) : '',
|
||||
Date.now(), user.username, id
|
||||
).run();
|
||||
|
||||
if (!result.meta.changes) throw new AppError('Account not found', 404);
|
||||
|
||||
return c.json({ success: true, message: 'Account updated successfully' });
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 4. 删除指定账号
|
||||
// ==========================================
|
||||
accounts.delete('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const result = await c.env.DB.prepare("DELETE FROM accounts WHERE id = ?").bind(id).run();
|
||||
|
||||
if (!result.meta.changes) throw new AppError('Account not found', 404);
|
||||
|
||||
return c.json({ success: true, message: 'Account deleted successfully' });
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 5. 清空所有账号
|
||||
// ==========================================
|
||||
accounts.delete('/clear-all', async (c) => {
|
||||
await c.env.DB.prepare("DELETE FROM accounts").run();
|
||||
return c.json({ success: true, message: 'All accounts cleared' });
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 6. 核心功能:生成 TOTP 验证码
|
||||
// ==========================================
|
||||
accounts.post('/generate-totp', async (c) => {
|
||||
const { secret, period = 30, digits = 6 } = await c.req.json();
|
||||
|
||||
if (!validateBase32Secret(secret)) throw new AppError('Invalid secret format', 400);
|
||||
if (![6, 8].includes(digits) || ![30, 60].includes(period)) throw new AppError('Invalid parameters', 400);
|
||||
|
||||
const code = await generateTOTP(secret, period, digits);
|
||||
return c.json({ success: true, code });
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 7. 扫码支持:解析 TOTP URI
|
||||
// ==========================================
|
||||
accounts.post('/parse-uri', async (c) => {
|
||||
const { uri } = await c.req.json();
|
||||
if (!uri) throw new AppError('URI is required', 400);
|
||||
|
||||
const account = parseOTPAuthURI(uri);
|
||||
if (!account) throw new AppError('Invalid OTP Auth URI', 400);
|
||||
|
||||
return c.json({ success: true, account });
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 8. 从扫码结果添加账户 (修复架构师遗漏的接口)
|
||||
// ==========================================
|
||||
accounts.post('/add-from-uri', async (c) => {
|
||||
const { uri, category } = await c.req.json();
|
||||
const user = c.get('user');
|
||||
|
||||
if (!uri) throw new AppError('URI is required', 400);
|
||||
|
||||
const parsedAccount = parseOTPAuthURI(uri);
|
||||
if (!parsedAccount) throw new AppError('Invalid OTP Auth URI', 400);
|
||||
|
||||
// D1: 查重
|
||||
const existing = await c.env.DB.prepare(
|
||||
"SELECT id FROM accounts WHERE service = ? AND account = ?"
|
||||
).bind(parsedAccount.issuer, parsedAccount.account).first();
|
||||
if (existing) throw new AppError('该账号已经存在金库中', 409);
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
|
||||
const secretEncrypted = await encryptField(parsedAccount.secret, key);
|
||||
|
||||
await c.env.DB.prepare(
|
||||
`INSERT INTO accounts (id, service, account, category, secret, digits, period, created_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).bind(
|
||||
id, sanitizeInput(parsedAccount.issuer || 'Unknown', 50), sanitizeInput(parsedAccount.account || 'Unknown', 100),
|
||||
category ? sanitizeInput(category, 30) : '', secretEncrypted, parsedAccount.digits, parsedAccount.period, Date.now(), user.username
|
||||
).run();
|
||||
|
||||
const newAccount = { id, service: parsedAccount.issuer, account: parsedAccount.account };
|
||||
|
||||
return c.json({ success: true, account: { ...newAccount, secret: '[PROTECTED]' } });
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 9. 加密导出金库数据
|
||||
// ==========================================
|
||||
accounts.post('/export-secure', async (c) => {
|
||||
const { password } = await c.req.json();
|
||||
if (!password || password.length < SECURITY_CONFIG.MIN_EXPORT_PASSWORD_LENGTH) {
|
||||
throw new AppError(`导出密码至少需要 ${SECURITY_CONFIG.MIN_EXPORT_PASSWORD_LENGTH} 个字符`, 400);
|
||||
}
|
||||
|
||||
const { results } = await c.env.DB.prepare("SELECT * FROM accounts").all();
|
||||
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
|
||||
|
||||
// 解密所有数据以准备导出
|
||||
const plainAccounts = await Promise.all(results.map(async (row: any) => ({
|
||||
service: row.service,
|
||||
account: row.account,
|
||||
category: row.category,
|
||||
secret: await decryptField(row.secret, key),
|
||||
digits: row.digits,
|
||||
period: row.period
|
||||
})));
|
||||
|
||||
// 组装标准导出格式
|
||||
const exportData = {
|
||||
version: "2.0",
|
||||
app: "2fa-secure-manager",
|
||||
encrypted: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
accounts: plainAccounts
|
||||
};
|
||||
|
||||
// 使用用户提供的密码进行 AES-GCM 加密
|
||||
const encrypted = await encryptData(exportData, password);
|
||||
const exportFile = {
|
||||
version: "2.0", app: "2fa-secure-manager", encrypted: true,
|
||||
timestamp: new Date().toISOString(), data: encrypted,
|
||||
note: "This file is encrypted with your export password. Keep it safe!"
|
||||
};
|
||||
|
||||
return c.json(exportFile);
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 10. 全能导入通道 (支持 加密/JSON/2FAS/文本)
|
||||
// ==========================================
|
||||
accounts.post('/import', async (c) => {
|
||||
const { content, type, password } = await c.req.json();
|
||||
const user = c.get('user');
|
||||
|
||||
if (!content || !type) throw new AppError('内容和类型不能为空', 400);
|
||||
|
||||
let validAccounts: any[] = [];
|
||||
|
||||
try {
|
||||
if (type === 'encrypted') {
|
||||
if (!password) throw new AppError('加密导入需要密码', 400);
|
||||
const encryptedFile = JSON.parse(content);
|
||||
const decryptedData = await decryptData(encryptedFile.data, password);
|
||||
validAccounts = decryptedData.accounts || [];
|
||||
} else if (type === 'json') {
|
||||
const data = JSON.parse(content);
|
||||
if (data.app && data.app.includes('2fauth') && Array.isArray(data.data)) {
|
||||
validAccounts = data.data.map((item: any) => ({...item, category: ''}));
|
||||
} else if (data.accounts) {
|
||||
validAccounts = data.accounts;
|
||||
} else if (Array.isArray(data)) {
|
||||
validAccounts = data;
|
||||
} else if (data.services) {
|
||||
validAccounts = data.services.map((s: any) => ({
|
||||
service: s.service || s.name, account: s.account || s.login,
|
||||
secret: s.secret, digits: s.digits, period: s.period, category: s.group
|
||||
}));
|
||||
}
|
||||
} else if (type === '2fas') {
|
||||
const data = JSON.parse(content);
|
||||
if (data.services) {
|
||||
validAccounts = data.services.map((s: any) => ({
|
||||
service: s.name, account: s.account || s.username,
|
||||
secret: s.secret, digits: s.digits, period: s.period, category: s.category || s.group
|
||||
}));
|
||||
}
|
||||
} else if (type === 'text') {
|
||||
const lines = content.split('\n').filter((line: string) => line.trim());
|
||||
for (const line of lines) {
|
||||
if (line.trim().startsWith('otpauth://')) {
|
||||
const parsed = parseOTPAuthURI(line.trim());
|
||||
if (parsed) validAccounts.push({
|
||||
service: parsed.issuer, account: parsed.account,
|
||||
secret: parsed.secret, digits: parsed.digits, period: parsed.period, category: ''
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
throw new AppError('解析失败,请检查文件格式或密码是否正确', 400);
|
||||
}
|
||||
|
||||
// 过滤有效账户并去重保存
|
||||
let addedCount = 0;
|
||||
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
|
||||
|
||||
// D1 批量插入 (使用事务或批量执行)
|
||||
const stmt = c.env.DB.prepare(
|
||||
`INSERT INTO accounts (id, service, account, category, secret, digits, period, created_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
const batch = [];
|
||||
|
||||
for (const acc of validAccounts) {
|
||||
if (acc.service && acc.account && validateBase32Secret(acc.secret)) {
|
||||
// 简单查重
|
||||
const existing = await c.env.DB.prepare("SELECT id FROM accounts WHERE service = ? AND account = ?")
|
||||
.bind(acc.service, acc.account).first();
|
||||
|
||||
if (!existing) {
|
||||
const secretEncrypted = await encryptField(acc.secret.replace(/\s/g, '').toUpperCase(), key);
|
||||
batch.push(stmt.bind(
|
||||
crypto.randomUUID(), sanitizeInput(acc.service, 50), sanitizeInput(acc.account, 100),
|
||||
acc.category ? sanitizeInput(acc.category, 30) : '', secretEncrypted,
|
||||
acc.digits || 6, acc.period || 30, Date.now(), user.username
|
||||
));
|
||||
addedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (batch.length > 0) await c.env.DB.batch(batch);
|
||||
|
||||
return c.json({ success: true, count: addedCount, total: validAccounts.length, duplicates: validAccounts.length - addedCount });
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 11. 手动触发数据迁移 (旧格式 -> 极速格式)
|
||||
// ==========================================
|
||||
accounts.post('/migrate-crypto', async (c) => {
|
||||
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
|
||||
const limit = 50; // 每次处理 50 条以防超时
|
||||
|
||||
// D1 JSON 查询:查找仍包含 salt 的记录
|
||||
const { results } = await c.env.DB.prepare(
|
||||
"SELECT id, secret FROM accounts WHERE json_extract(secret, '$.salt') IS NOT NULL LIMIT ?"
|
||||
).bind(limit).all();
|
||||
|
||||
if (results.length === 0) {
|
||||
return c.json({ success: true, message: '所有数据已是最新格式', migrated: 0, remaining: 0 });
|
||||
}
|
||||
|
||||
const updates = [];
|
||||
for (const row of results) {
|
||||
try {
|
||||
const plain = await decryptField(row.secret as string, key);
|
||||
const newSecret = await encryptField(plain, key);
|
||||
updates.push(
|
||||
c.env.DB.prepare("UPDATE accounts SET secret = ? WHERE id = ?").bind(newSecret, row.id)
|
||||
);
|
||||
} catch (e) { console.error(`Failed to migrate account ${row.id}`, e); }
|
||||
}
|
||||
|
||||
if (updates.length > 0) await c.env.DB.batch(updates);
|
||||
|
||||
// 检查剩余数量
|
||||
const remaining = await c.env.DB.prepare(
|
||||
"SELECT COUNT(*) as count FROM accounts WHERE json_extract(secret, '$.salt') IS NOT NULL"
|
||||
).first('count');
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
migrated: updates.length,
|
||||
remaining: remaining,
|
||||
message: `已迁移 ${updates.length} 条数据,剩余 ${remaining} 条。请继续点击直到剩余为 0。`
|
||||
});
|
||||
});
|
||||
|
||||
export default accounts;
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Hono } from 'hono';
|
||||
import { EnvBindings, AppError } from '../config';
|
||||
import { generateSecureJWT } from '../utils/crypto';
|
||||
|
||||
const auth = new Hono<{ Bindings: EnvBindings }>();
|
||||
|
||||
// 内部辅助函数:获取 GitHub 用户信息
|
||||
async function fetchGitHubUser(accessToken: string, oauthBaseUrl: string) {
|
||||
const isGitHub = oauthBaseUrl.includes('github.com');
|
||||
const url = isGitHub ? 'https://api.github.com/user' : `${oauthBaseUrl}/api/user`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `token ${accessToken}`,
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': '2FA-Manager-Backend/1.0'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new AppError(`GitHub API error: ${response.status}`, 502);
|
||||
}
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 1. 获取授权地址 (前端调用此接口获取跳转链接)
|
||||
// ==========================================
|
||||
auth.get('/authorize', async (c) => {
|
||||
const state = crypto.randomUUID();
|
||||
const env = c.env;
|
||||
const isGitHub = env.OAUTH_BASE_URL.includes('github.com');
|
||||
const authPath = isGitHub ? '/login/oauth/authorize' : '/oauth2/authorize';
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: env.OAUTH_CLIENT_ID,
|
||||
redirect_uri: env.OAUTH_REDIRECT_URI, // 注意:后续在环境变量中,这里要填入 Vue 前端的地址
|
||||
state: state,
|
||||
scope: isGitHub ? 'read:user user:email' : 'openid profile email'
|
||||
});
|
||||
|
||||
const authUrl = `${env.OAUTH_BASE_URL.replace(/\/$/, '')}${authPath}?${params}`;
|
||||
|
||||
// 返回 URL 和 State 让 Vue 前端存入 localStorage 后自行跳转
|
||||
return c.json({ success: true, authUrl, state });
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 2. 核心逻辑:用 Code 换取系统的 JWT 令牌
|
||||
// ==========================================
|
||||
auth.post('/callback', async (c) => {
|
||||
const { code } = await c.req.json();
|
||||
const env = c.env;
|
||||
|
||||
if (!code) {
|
||||
throw new AppError('Missing OAuth code', 400);
|
||||
}
|
||||
|
||||
const isGitHub = env.OAUTH_BASE_URL.includes('github.com');
|
||||
const tokenPath = isGitHub ? '/login/oauth/access_token' : '/oauth2/token';
|
||||
|
||||
// 去 GitHub 换取 Access Token
|
||||
const tokenResponse = await fetch(`${env.OAUTH_BASE_URL.replace(/\/$/, '')}${tokenPath}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': '2FA-Manager-Backend/1.0'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: env.OAUTH_CLIENT_ID,
|
||||
client_secret: env.OAUTH_CLIENT_SECRET,
|
||||
code: code,
|
||||
redirect_uri: env.OAUTH_REDIRECT_URI,
|
||||
})
|
||||
});
|
||||
|
||||
const tokenData = await tokenResponse.json();
|
||||
if (!tokenData.access_token) {
|
||||
throw new AppError('Failed to exchange code for token', 502);
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
const userData = await fetchGitHubUser(tokenData.access_token, env.OAUTH_BASE_URL);
|
||||
|
||||
// 严密的安全校验:比对 OAUTH_ID (改为比对用户名)
|
||||
// GitHub API 返回的用户名在 login 字段,这里统一转小写比较以防大小写差异
|
||||
const currentUsername = userData.login || userData.username;
|
||||
if (!currentUsername || currentUsername.toLowerCase() !== env.OAUTH_ID.toLowerCase()) {
|
||||
throw new AppError('Unauthorized user', 403);
|
||||
}
|
||||
|
||||
// 签发我们系统的专属 JWT 令牌
|
||||
const payload = {
|
||||
userInfo: {
|
||||
id: userData.id,
|
||||
username: userData.login || userData.username || 'GitHub User',
|
||||
email: userData.email,
|
||||
avatar_template: userData.avatar_url || userData.avatar_template
|
||||
}
|
||||
};
|
||||
|
||||
const token = await generateSecureJWT(payload, env.JWT_SECRET);
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
token,
|
||||
userInfo: payload.userInfo
|
||||
});
|
||||
});
|
||||
|
||||
export default auth;
|
||||
@@ -0,0 +1,310 @@
|
||||
import { Hono } from 'hono';
|
||||
import { createClient } from 'webdav';
|
||||
import { EnvBindings, AppError, SECURITY_CONFIG } from '../config';
|
||||
import { authMiddleware, sanitizeInput } from '../utils/helper';
|
||||
import { encryptData, decryptData } from '../utils/crypto';
|
||||
|
||||
const webdav = new Hono<{ Bindings: EnvBindings, Variables: { user: any } }>();
|
||||
|
||||
// 🛡️ 挂载鉴权中间件
|
||||
webdav.use('*', authMiddleware);
|
||||
|
||||
// 辅助:加密单个字段并序列化为字符串存储
|
||||
async function encryptField(data: any, key: string) {
|
||||
const encrypted = await encryptData(data, key);
|
||||
return JSON.stringify(encrypted);
|
||||
}
|
||||
|
||||
// 辅助:反序列化并解密单个字段
|
||||
async function decryptField(encryptedStr: string, key: string) {
|
||||
try {
|
||||
const encryptedObj = JSON.parse(encryptedStr);
|
||||
return await decryptData(encryptedObj, key);
|
||||
} catch (e) {
|
||||
console.error('Decryption failed', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 内部辅助:创建 WebDAV 客户端
|
||||
function getWebDavClient(config: any) {
|
||||
if (!config || !config.url || !config.username || !config.password) {
|
||||
throw new AppError('WebDAV configuration is incomplete', 400);
|
||||
}
|
||||
return createClient(config.url, {
|
||||
username: config.username,
|
||||
password: config.password
|
||||
});
|
||||
}
|
||||
|
||||
// 内部辅助:生成备份文件名
|
||||
function generateBackupFilename(dir: string) {
|
||||
const date = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const cleanDir = dir.endsWith('/') ? dir.slice(0, -1) : dir;
|
||||
return `${cleanDir}/2fa-backup-${date}.json`;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 1. 获取 WebDAV 配置 (解密后返回给前端)
|
||||
// ==========================================
|
||||
webdav.get('/configs', async (c) => {
|
||||
const { results } = await c.env.DB.prepare("SELECT * FROM webdav_configs LIMIT 1").all();
|
||||
if (results.length === 0) return c.json({ success: true, configs: [] });
|
||||
|
||||
const row: any = results[0];
|
||||
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
|
||||
const password = await decryptField(row.password, key);
|
||||
|
||||
// 🚀 [自动迁移] 升级 WebDAV 密码加密格式
|
||||
try {
|
||||
const raw = JSON.parse(row.password);
|
||||
if (raw.salt && raw.salt.length > 0) {
|
||||
const newPass = await encryptField(password, key);
|
||||
c.executionCtx.waitUntil(
|
||||
c.env.DB.prepare("UPDATE webdav_configs SET password = ? WHERE id = ?").bind(newPass, row.id).run()
|
||||
);
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
const configs = [{
|
||||
url: row.url,
|
||||
username: row.username,
|
||||
password: password,
|
||||
saveDir: row.save_dir
|
||||
}];
|
||||
return c.json({ success: true, configs });
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 2. 保存 WebDAV 配置 (加密后存入 KV)
|
||||
// ==========================================
|
||||
webdav.post('/configs', async (c) => {
|
||||
const { configs } = await c.req.json();
|
||||
|
||||
if (!Array.isArray(configs)) {
|
||||
throw new AppError('Invalid format: configs must be an array', 400);
|
||||
}
|
||||
|
||||
const config = configs[0];
|
||||
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
|
||||
const passwordEncrypted = await encryptField(config.password, key);
|
||||
|
||||
await c.env.DB.batch([
|
||||
c.env.DB.prepare("DELETE FROM webdav_configs"),
|
||||
c.env.DB.prepare("INSERT INTO webdav_configs (url, username, password, save_dir, updated_at) VALUES (?, ?, ?, ?, ?)").bind(config.url, config.username, passwordEncrypted, config.saveDir, Date.now())
|
||||
]);
|
||||
|
||||
return c.json({ success: true, message: 'Configuration saved securely' });
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 3. 测试连接 (示例桩代码)
|
||||
// ==========================================
|
||||
webdav.post('/test', async (c) => {
|
||||
// 前端通常会把当前填写的配置传过来测试,所以这里直接用 body 里的
|
||||
const config = await c.req.json();
|
||||
|
||||
try {
|
||||
const client = getWebDavClient(config);
|
||||
// 尝试读取根目录来验证凭证
|
||||
await client.getDirectoryContents('/');
|
||||
return c.json({ success: true, message: 'Connection successful' });
|
||||
} catch (error: any) {
|
||||
console.error('WebDAV Test Error:', error);
|
||||
throw new AppError(`Connection failed: ${error.message}`, 400);
|
||||
}
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 4. 获取备份文件列表
|
||||
// ==========================================
|
||||
webdav.post('/list', async (c) => {
|
||||
const { results } = await c.env.DB.prepare("SELECT * FROM webdav_configs LIMIT 1").all();
|
||||
if (results.length === 0) throw new AppError('No WebDAV config found', 404);
|
||||
|
||||
const row: any = results[0];
|
||||
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
|
||||
const password = await decryptField(row.password, key);
|
||||
|
||||
// 🚀 [自动迁移] 升级 WebDAV 密码加密格式 (在 list 接口也做检查)
|
||||
try {
|
||||
const raw = JSON.parse(row.password);
|
||||
if (raw.salt && raw.salt.length > 0) {
|
||||
const newPass = await encryptField(password, key);
|
||||
c.executionCtx.waitUntil(
|
||||
c.env.DB.prepare("UPDATE webdav_configs SET password = ? WHERE id = ?").bind(newPass, row.id).run()
|
||||
);
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
const configs = [{
|
||||
url: row.url,
|
||||
username: row.username,
|
||||
password: password,
|
||||
saveDir: row.save_dir || '/'
|
||||
}];
|
||||
|
||||
if (configs.length === 0) throw new AppError('No WebDAV config found', 404);
|
||||
|
||||
// 默认使用第一个配置
|
||||
const config = configs[0];
|
||||
const client = getWebDavClient(config);
|
||||
|
||||
try {
|
||||
const items = await client.getDirectoryContents(config.saveDir);
|
||||
// 过滤出我们的备份文件
|
||||
const backups = (items as any[])
|
||||
.filter(item => item.type === 'file' && item.basename.startsWith('2fa-backup-') && item.basename.endsWith('.json'))
|
||||
.map(item => {
|
||||
// 尝试获取时间:优先用服务器返回的 lastmod,如果没有则尝试从文件名解析 (文件名包含 ISO 时间戳)
|
||||
let displayTime = item.lastmod;
|
||||
if (!displayTime) {
|
||||
try {
|
||||
// 文件名格式: 2fa-backup-2024-03-20T10-00-00-000Z.json
|
||||
// 简单提取中间的时间部分用于显示
|
||||
const match = item.basename.match(/2fa-backup-(.+)\.json/);
|
||||
if (match) displayTime = match[1].replace(/-/g, ':').replace('T', ' ');
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
return {
|
||||
name: item.basename,
|
||||
filename: item.basename, // 兼容前端可能使用的 filename 字段
|
||||
size: item.size,
|
||||
lastMod: displayTime,
|
||||
lastModified: displayTime // 兼容前端可能使用的 lastModified 字段
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.name.localeCompare(a.name)); // 按时间倒序
|
||||
|
||||
return c.json({ success: true, backups });
|
||||
} catch (error: any) {
|
||||
throw new AppError(`Failed to list backups: ${error.message}`, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 5. 导出当前金库到 WebDAV
|
||||
// ==========================================
|
||||
webdav.post('/export', async (c) => {
|
||||
const { password } = await c.req.json();
|
||||
if (!password || password.length < SECURITY_CONFIG.MIN_EXPORT_PASSWORD_LENGTH) {
|
||||
throw new AppError(`导出密码至少需要 ${SECURITY_CONFIG.MIN_EXPORT_PASSWORD_LENGTH} 个字符`, 400);
|
||||
}
|
||||
|
||||
// 获取 WebDAV 配置
|
||||
const { results: configResults } = await c.env.DB.prepare("SELECT * FROM webdav_configs LIMIT 1").all();
|
||||
if (configResults.length === 0) throw new AppError('No WebDAV config found', 404);
|
||||
const configRow: any = configResults[0];
|
||||
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
|
||||
const webdavPassword = await decryptField(configRow.password, key);
|
||||
const webdavConfig = { url: configRow.url, username: configRow.username, password: webdavPassword, saveDir: configRow.save_dir || '/' };
|
||||
|
||||
// 1. 从 D1 获取并解密当前数据
|
||||
const { results: accountResults } = await c.env.DB.prepare("SELECT * FROM accounts").all();
|
||||
|
||||
const accounts = await Promise.all(accountResults.map(async (row: any) => ({
|
||||
service: row.service,
|
||||
account: row.account,
|
||||
category: row.category,
|
||||
secret: await decryptField(row.secret, key),
|
||||
digits: row.digits,
|
||||
period: row.period
|
||||
})));
|
||||
|
||||
// 2. 准备导出数据结构 (清洗数据,仅保留必要字段)
|
||||
const exportPayload = {
|
||||
version: "2.0",
|
||||
app: "2fa-secure-manager",
|
||||
encrypted: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
accounts: accounts.map((acc: any) => ({
|
||||
service: acc.service, category: acc.category, account: acc.account,
|
||||
secret: acc.secret, digits: acc.digits, period: acc.period
|
||||
}))
|
||||
};
|
||||
|
||||
// 3. 使用用户密码重新加密
|
||||
const userEncrypted = await encryptData(exportPayload, password);
|
||||
const fileContent = JSON.stringify({ ...exportPayload, data: userEncrypted, accounts: undefined });
|
||||
|
||||
// 4. 上传到 WebDAV
|
||||
const client = getWebDavClient(webdavConfig);
|
||||
const filename = generateBackupFilename(webdavConfig.saveDir);
|
||||
|
||||
// 尝试创建目录(如果不存在)
|
||||
if (webdavConfig.saveDir !== '/' && await client.exists(webdavConfig.saveDir) === false) {
|
||||
await client.createDirectory(webdavConfig.saveDir);
|
||||
}
|
||||
|
||||
await client.putFileContents(filename, fileContent);
|
||||
|
||||
return c.json({ success: true, message: 'Backup uploaded successfully', filename });
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 6. 从 WebDAV 恢复备份
|
||||
// ==========================================
|
||||
webdav.post('/restore', async (c) => {
|
||||
let body;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch (e) {
|
||||
throw new AppError('Invalid JSON body', 400);
|
||||
}
|
||||
|
||||
// 规范化参数
|
||||
const filename = body.filename || body.name;
|
||||
const password = body.password;
|
||||
|
||||
if (!filename) throw new AppError('Filename is required', 400);
|
||||
if (!password) throw new AppError('Decryption password is required', 400);
|
||||
|
||||
const { results: configResults } = await c.env.DB.prepare("SELECT * FROM webdav_configs LIMIT 1").all();
|
||||
if (configResults.length === 0) throw new AppError('No WebDAV config found', 404);
|
||||
const configRow: any = configResults[0];
|
||||
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
|
||||
const webdavPassword = await decryptField(configRow.password, key);
|
||||
const saveDir = configRow.save_dir || '/';
|
||||
|
||||
const client = getWebDavClient({ url: configRow.url, username: configRow.username, password: webdavPassword });
|
||||
|
||||
// 1. 下载文件
|
||||
// 拼接完整路径,因为 filename 只是文件名
|
||||
const fullPath = saveDir.endsWith('/') ? `${saveDir}${filename}` : `${saveDir}/${filename}`;
|
||||
const content = await client.getFileContents(fullPath, { format: 'text' });
|
||||
|
||||
// 2. 使用用户密码解密
|
||||
let accounts = [];
|
||||
try {
|
||||
const backupFile = JSON.parse(content as string);
|
||||
// 兼容性检查:如果是旧版直接上传的 KV 数据(没有 data 字段),则无法用密码解密,直接报错
|
||||
if (!backupFile.data) throw new Error('Legacy format');
|
||||
|
||||
const decrypted = await decryptData(backupFile.data, password);
|
||||
accounts = decrypted.accounts || [];
|
||||
} catch (e) {
|
||||
throw new AppError('解密失败:密码错误或文件格式不兼容', 400);
|
||||
}
|
||||
|
||||
// 3. 覆盖写入 D1 (先清空,再批量插入)
|
||||
await c.env.DB.prepare("DELETE FROM accounts").run();
|
||||
|
||||
const stmt = c.env.DB.prepare(
|
||||
`INSERT INTO accounts (id, service, account, category, secret, digits, period, created_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
const batch = [];
|
||||
for (const acc of accounts) {
|
||||
const secretEncrypted = await encryptField(acc.secret, key);
|
||||
batch.push(stmt.bind(
|
||||
crypto.randomUUID(), sanitizeInput(acc.service, 50), sanitizeInput(acc.account, 100),
|
||||
acc.category || '', secretEncrypted, acc.digits || 6, acc.period || 30, Date.now(), 'restore'
|
||||
));
|
||||
}
|
||||
if (batch.length > 0) await c.env.DB.batch(batch);
|
||||
|
||||
return c.json({ success: true, message: 'Restore successful' });
|
||||
});
|
||||
|
||||
export default webdav;
|
||||
@@ -0,0 +1,153 @@
|
||||
import { BASE32_CHARS, SECURITY_CONFIG } from '../config';
|
||||
|
||||
// ==========================================
|
||||
// 1. Base32 与 TOTP 核心算法
|
||||
// ==========================================
|
||||
export function base32Decode(encoded: string): Uint8Array {
|
||||
const cleanInput = encoded.toUpperCase().replace(/[^A-Z2-7]/g, '');
|
||||
const buffer = new Uint8Array(Math.floor(cleanInput.length * 5 / 8));
|
||||
let bits = 0, value = 0, index = 0;
|
||||
|
||||
for (let i = 0; i < cleanInput.length; i++) {
|
||||
const charValue = BASE32_CHARS.indexOf(cleanInput[i]);
|
||||
if (charValue === -1) continue;
|
||||
value = (value << 5) | charValue;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
buffer[index++] = (value >>> (bits - 8)) & 255;
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export async function hmacSHA1(key: Uint8Array | string, data: number): Promise<Uint8Array> {
|
||||
const keyBuffer = typeof key === 'string' ? new TextEncoder().encode(key) : key;
|
||||
const dataBuffer = new ArrayBuffer(8);
|
||||
new DataView(dataBuffer).setBigUint64(0, BigInt(data), false);
|
||||
|
||||
const cryptoKey = await crypto.subtle.importKey('raw', keyBuffer, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
|
||||
const signature = await crypto.subtle.sign('HMAC', cryptoKey, dataBuffer);
|
||||
return new Uint8Array(signature);
|
||||
}
|
||||
|
||||
export async function generateTOTP(secret: string | Uint8Array, timeStep = 30, digits = 6): Promise<string> {
|
||||
const time = Math.floor(Date.now() / 1000 / timeStep);
|
||||
const secretBytes = typeof secret === 'string' ? base32Decode(secret) : secret;
|
||||
|
||||
const hmac = await hmacSHA1(secretBytes, time);
|
||||
const offset = hmac[hmac.length - 1] & 0xf;
|
||||
|
||||
const code = (((hmac[offset] & 0x7f) << 24) |
|
||||
((hmac[offset + 1] & 0xff) << 16) |
|
||||
((hmac[offset + 2] & 0xff) << 8) |
|
||||
(hmac[offset + 3] & 0xff)) % Math.pow(10, digits);
|
||||
|
||||
return code.toString().padStart(digits, '0');
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 2. JWT 签发与验证 (原生轻量实现)
|
||||
// ==========================================
|
||||
export async function generateSecureJWT(payload: Record<string, any>, secret: string): Promise<string> {
|
||||
const header = { alg: 'HS256', typ: 'JWT', iat: Math.floor(Date.now() / 1000) };
|
||||
const enhancedPayload = {
|
||||
...payload,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
exp: Math.floor(Date.now() / 1000) + SECURITY_CONFIG.JWT_EXPIRY,
|
||||
jti: crypto.randomUUID()
|
||||
};
|
||||
|
||||
const headerB64 = btoa(JSON.stringify(header)).replace(/[+/=]/g, (m) => ({'+':'-','/':'_','=':''}[m as keyof typeof map] || m));
|
||||
const payloadB64 = btoa(JSON.stringify(enhancedPayload)).replace(/[+/=]/g, (m) => ({'+':'-','/':'_','=':''}[m as keyof typeof map] || m));
|
||||
|
||||
const data = `${headerB64}.${payloadB64}`;
|
||||
const encoder = new TextEncoder();
|
||||
const cryptoKey = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
||||
const signature = await crypto.subtle.sign('HMAC', cryptoKey, encoder.encode(data));
|
||||
const signatureB64 = btoa(String.fromCharCode(...new Uint8Array(signature))).replace(/[+/=]/g, (m) => ({'+':'-','/':'_','=':''}[m as keyof typeof map] || m));
|
||||
|
||||
return `${data}.${signatureB64}`;
|
||||
}
|
||||
|
||||
export async function verifySecureJWT(token: string, secret: string): Promise<any | null> {
|
||||
try {
|
||||
const [headerB64, payloadB64, signatureB64] = token.split('.');
|
||||
if (!headerB64 || !payloadB64 || !signatureB64) return null;
|
||||
|
||||
const data = `${headerB64}.${payloadB64}`;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const cryptoKey = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
|
||||
const signatureBytes = Uint8Array.from(atob(signatureB64.replace(/[-_]/g, (m) => ({'-':'+','_':'/'}[m as keyof typeof map] || m))), c => c.charCodeAt(0));
|
||||
const isValid = await crypto.subtle.verify('HMAC', cryptoKey, signatureBytes, encoder.encode(data));
|
||||
|
||||
if (isValid) {
|
||||
const payload = JSON.parse(atob(payloadB64.replace(/[-_]/g, (m) => ({'-':'+','_':'/'}[m as keyof typeof map] || m))));
|
||||
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) return null;
|
||||
return payload;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 3. AES-GCM 数据加解密 (用于保护 KV 里的 2FA 密钥)
|
||||
// ==========================================
|
||||
// ⚡️ 极速模式:直接使用密钥的哈希值作为 AES 密钥 (无需 PBKDF2 慢速派生)
|
||||
async function getFastKey(secret: string): Promise<CryptoKey> {
|
||||
const encoder = new TextEncoder();
|
||||
// 使用 SHA-256 确保密钥长度固定为 256 位 (32字节)
|
||||
const keyBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(secret));
|
||||
return crypto.subtle.importKey('raw', keyBuffer, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
|
||||
}
|
||||
|
||||
// 🐢 兼容模式:PBKDF2 (用于解密旧数据)
|
||||
export async function deriveKey(password: string, salt: Uint8Array): Promise<CryptoKey> {
|
||||
const encoder = new TextEncoder();
|
||||
const keyMaterial = await crypto.subtle.importKey('raw', encoder.encode(password), { name: 'PBKDF2' }, false, ['deriveKey']);
|
||||
return await crypto.subtle.deriveKey(
|
||||
{ name: 'PBKDF2', salt: salt, iterations: 100000, hash: 'SHA-256' },
|
||||
keyMaterial,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
}
|
||||
|
||||
export async function encryptData(data: any, masterKey: string): Promise<{ encrypted: number[], iv: number[], salt?: number[] }> {
|
||||
const encoder = new TextEncoder();
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
|
||||
// 🚀 默认使用极速模式 (不再生成 salt,不再进行 PBKDF2 计算)
|
||||
const key = await getFastKey(masterKey);
|
||||
|
||||
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoder.encode(JSON.stringify(data)));
|
||||
return {
|
||||
encrypted: Array.from(new Uint8Array(encrypted)),
|
||||
iv: Array.from(iv)
|
||||
// salt: undefined <-- 新数据不再包含 salt
|
||||
};
|
||||
}
|
||||
|
||||
export async function decryptData(encryptedData: { encrypted: number[], iv: number[], salt?: number[] }, masterKey: string): Promise<any> {
|
||||
const decoder = new TextDecoder();
|
||||
const iv = new Uint8Array(encryptedData.iv);
|
||||
const encrypted = new Uint8Array(encryptedData.encrypted);
|
||||
|
||||
let key: CryptoKey;
|
||||
|
||||
// 🔄 智能兼容:如果发现数据中有 salt,说明是旧数据,使用 PBKDF2 解密
|
||||
if (encryptedData.salt && encryptedData.salt.length > 0) {
|
||||
const salt = new Uint8Array(encryptedData.salt);
|
||||
key = await deriveKey(masterKey, salt);
|
||||
} else {
|
||||
// 🚀 否则使用极速模式解密
|
||||
key = await getFastKey(masterKey);
|
||||
}
|
||||
|
||||
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, encrypted);
|
||||
return JSON.parse(decoder.decode(decrypted));
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Context, Next } from 'hono';
|
||||
import { SECURITY_CONFIG, AppError, EnvBindings } from '../config';
|
||||
import { verifySecureJWT } from './crypto';
|
||||
|
||||
// 1. 输入净化与验证
|
||||
export function sanitizeInput(input: any, maxLength = SECURITY_CONFIG.MAX_INPUT_LENGTH): string {
|
||||
if (typeof input !== 'string') return '';
|
||||
return input.replace(/[<>"'&\x00-\x1F\x7F]/g, '').trim().substring(0, maxLength);
|
||||
}
|
||||
|
||||
export function validateBase32Secret(secret: any): boolean {
|
||||
if (!secret || typeof secret !== 'string') return false;
|
||||
const cleaned = secret.replace(/\s/g, '').toUpperCase();
|
||||
return /^[A-Z2-7]+=*$/.test(cleaned) && cleaned.length >= 16;
|
||||
}
|
||||
|
||||
export function validateServiceName(service: any): boolean {
|
||||
const cleaned = sanitizeInput(service, 50);
|
||||
return cleaned.length >= 1 && cleaned.length <= 50;
|
||||
}
|
||||
|
||||
export function validateAccountName(account: any): boolean {
|
||||
const cleaned = sanitizeInput(account, 100);
|
||||
return cleaned.length >= 1 && cleaned.length <= 100;
|
||||
}
|
||||
|
||||
// 2. 解析 二维码 URI
|
||||
export function parseOTPAuthURI(uri: string) {
|
||||
try {
|
||||
if (!uri || typeof uri !== 'string' || uri.length > 1000) return null;
|
||||
const url = new URL(uri);
|
||||
if (url.protocol !== 'otpauth:') return null;
|
||||
|
||||
const type = url.hostname;
|
||||
if (type !== 'totp' && type !== 'hotp') return null;
|
||||
|
||||
const label = decodeURIComponent(url.pathname.substring(1));
|
||||
const params = new URLSearchParams(url.search);
|
||||
|
||||
const secret = params.get('secret');
|
||||
if (!validateBase32Secret(secret)) return null;
|
||||
|
||||
const [issuer, account] = label.includes(':') ? label.split(':', 2) : ['', label];
|
||||
const digits = parseInt(params.get('digits') || '6');
|
||||
const period = parseInt(params.get('period') || '30');
|
||||
|
||||
if (digits < 6 || digits > 8 || period < 15 || period > 300) return null;
|
||||
|
||||
return {
|
||||
type,
|
||||
label: sanitizeInput(label, 100),
|
||||
issuer: sanitizeInput(params.get('issuer') || issuer, 50),
|
||||
account: sanitizeInput(account || label, 100),
|
||||
secret: secret!,
|
||||
algorithm: (params.get('algorithm') || 'SHA1').toUpperCase(),
|
||||
digits,
|
||||
period
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Hono JWT 鉴权中间件
|
||||
export async function authMiddleware(c: Context<{ Bindings: EnvBindings, Variables: { user: any } }>, next: Next) {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw new AppError('Unauthorized: Missing or invalid token', 401);
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const payload = await verifySecureJWT(token, c.env.JWT_SECRET);
|
||||
|
||||
if (!payload || !payload.userInfo) {
|
||||
throw new AppError('Unauthorized: Token expired or invalid', 401);
|
||||
}
|
||||
|
||||
// 验证通过,将用户信息挂载到上下文,供后续路由使用
|
||||
c.set('user', payload.userInfo);
|
||||
await next();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": [
|
||||
"ESNext"
|
||||
],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "hono/jsx"
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "2fauth-worker",
|
||||
"main": "src/index.ts",
|
||||
"compatibility_date": "2024-04-01",
|
||||
|
||||
// ====== 静态资源托管 ======
|
||||
"assets": {
|
||||
"binding": "ASSETS",
|
||||
"directory": "../frontend/dist",
|
||||
"not_found_handling": "single-page-application",
|
||||
"run_worker_first": true
|
||||
},
|
||||
|
||||
// ====== 自动构建前端 ======
|
||||
"build": {
|
||||
"command": "npm run build --prefix ../frontend"
|
||||
},
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "DB",
|
||||
"database_name": "2fauth-db",
|
||||
"database_id": "d2ca3b30-408b-4529-9f09-eb1d3ade0dd5"
|
||||
}
|
||||
],
|
||||
|
||||
// ====== 本地开发环境配置 ======
|
||||
"env": {
|
||||
"dev": {
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "DB",
|
||||
"database_name": "2fauth-db-dev",
|
||||
"database_id": "9e591736-5dbb-4f09-8769-0270d7257731"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>2FAuth - 您的专属云端双因素认证管家!</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"element-plus": "^2.13.2",
|
||||
"jsqr": "^1.4.0",
|
||||
"vue": "^3.5.25",
|
||||
"vue-router": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg data-v-3890df48="" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path fill="currentColor" d="M224 448a32 32 0 0 0-32 32v384a32 32 0 0 0 32 32h576a32 32 0 0 0 32-32V480a32 32 0 0 0-32-32zm0-64h576a96 96 0 0 1 96 96v384a96 96 0 0 1-96 96H224a96 96 0 0 1-96-96V480a96 96 0 0 1 96-96"></path><path fill="currentColor" d="M512 544a32 32 0 0 1 32 32v192a32 32 0 1 1-64 0V576a32 32 0 0 1 32-32m192-160v-64a192 192 0 1 0-384 0v64zM512 64a256 256 0 0 1 256 256v128H256V320A256 256 0 0 1 512 64"></path></svg>
|
||||
|
After Width: | Height: | Size: 518 B |
@@ -0,0 +1,56 @@
|
||||
<script setup>
|
||||
import { onMounted, onBeforeUnmount } from 'vue'
|
||||
import { RouterView, useRoute } from 'vue-router'
|
||||
import TheHeader from './components/TheHeader.vue'
|
||||
import TheFooter from './components/TheFooter.vue'
|
||||
import { layoutState } from './states/layout'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const checkMobile = () => {
|
||||
layoutState.isMobile = window.innerWidth < 768
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkMobile()
|
||||
window.addEventListener('resize', checkMobile)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', checkMobile)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<!-- 登录页通常不显示头部,可以通过路由 meta 控制,这里简单示例默认显示 -->
|
||||
<TheHeader v-if="!route.meta.hideHeader" />
|
||||
|
||||
<main>
|
||||
<RouterView />
|
||||
</main>
|
||||
<TheFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh; /* 改为固定高度,以便内部滚动 */
|
||||
overflow: hidden;
|
||||
}
|
||||
main {
|
||||
flex: 1;
|
||||
overflow: hidden; /* 防止 main 自身滚动,让子元素决定 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
<svg data-v-3890df48="" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path fill="currentColor" d="M224 448a32 32 0 0 0-32 32v384a32 32 0 0 0 32 32h576a32 32 0 0 0 32-32V480a32 32 0 0 0-32-32zm0-64h576a96 96 0 0 1 96 96v384a96 96 0 0 1-96 96H224a96 96 0 0 1-96-96V480a96 96 0 0 1 96-96"></path><path fill="currentColor" d="M512 544a32 32 0 0 1 32 32v192a32 32 0 1 1-64 0V576a32 32 0 0 1 32-32m192-160v-64a192 192 0 1 0-384 0v64zM512 64a256 256 0 0 1 256 256v128H256V320A256 256 0 0 1 512 64"></path></svg>
|
||||
|
After Width: | Height: | Size: 518 B |
@@ -0,0 +1,259 @@
|
||||
<template>
|
||||
<div class="migration-container">
|
||||
<el-row :gutter="30">
|
||||
|
||||
<el-col :xs="24" :md="10" style="margin-bottom: 20px;">
|
||||
<el-card class="action-card export-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header"><el-icon><Upload /></el-icon> <span>数据导出</span></div>
|
||||
</template>
|
||||
<div class="card-body">
|
||||
<el-alert title="🛡️ 极密保护" type="warning" description="为保护您的资产,所有导出数据必须强制加密!" show-icon :closable="false" style="margin-bottom: 20px;" />
|
||||
<p class="desc">将当前金库中的所有账号打包,并使用您设置的密码进行 AES-GCM 强加密。</p>
|
||||
<el-button type="warning" size="large" class="full-btn" @click="showExportDialog = true">
|
||||
<el-icon><Lock /></el-icon> 设置密码并导出
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="24" :md="14">
|
||||
<el-card class="action-card import-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header"><el-icon><Download /></el-icon> <span>数据导入</span></div>
|
||||
</template>
|
||||
<div class="card-body">
|
||||
<p class="desc">支持从各类 2FA 软件或本系统的备份文件中恢复数据。导入时会自动跳过已存在的重复账号。</p>
|
||||
<div class="import-options">
|
||||
<el-button plain @click="triggerImport('encrypted', '.json')">🔒 本系统加密备份 (.json)</el-button>
|
||||
<el-button plain @click="triggerImport('json', '.json')">📄 标准 JSON / 2FAuth (.json)</el-button>
|
||||
<el-button plain @click="triggerImport('2fas', '.2fas,.json')">📱 2FAS 备份 (.2fas)</el-button>
|
||||
<el-button plain @click="triggerImport('text', '.txt')">📝 纯文本 URI (.txt)</el-button>
|
||||
</div>
|
||||
<input type="file" ref="fileInputRef" :accept="acceptType" style="display: none" @change="handleFileUpload" />
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
</el-row>
|
||||
|
||||
<el-dialog v-model="showExportDialog" title="设置导出密码" width="400px" destroy-on-close>
|
||||
<el-form :model="exportForm" label-position="top" v-loading="isExporting" :element-loading-text="loadingText">
|
||||
<el-form-item label="加密密码 (至少 12 位)">
|
||||
<el-input v-model="exportForm.password" type="password" show-password placeholder="请输入高强度密码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码">
|
||||
<el-input v-model="exportForm.confirm" type="password" show-password placeholder="请再次输入" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showExportDialog = false">取消</el-button>
|
||||
<el-button type="warning" :loading="isExporting" @click="handleExport">开始加密并下载</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="showDecryptDialog" title="🔓 解密备份文件" width="400px" destroy-on-close>
|
||||
<el-alert title="检测到加密文件" type="success" :closable="false" style="margin-bottom: 15px;" />
|
||||
<el-form label-position="top" v-loading="isImporting" :element-loading-text="loadingText">
|
||||
<el-form-item label="请输入该备份的解密密码:">
|
||||
<el-input v-model="importPassword" type="password" show-password placeholder="输入当时设置的导出密码" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showDecryptDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="isImporting" @click="submitImportData">确认解密并导入</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Upload, Download, Lock } from '@element-plus/icons-vue'
|
||||
import { request } from '../utils/request'
|
||||
|
||||
// 告诉老爸组件 (Home.vue) 导入成功了,赶紧刷新列表
|
||||
const emit = defineEmits(['import-success'])
|
||||
|
||||
// 导出相关状态
|
||||
const showExportDialog = ref(false)
|
||||
const isExporting = ref(false)
|
||||
const exportForm = ref({ password: '', confirm: '' })
|
||||
const loadingText = ref('')
|
||||
|
||||
// 导入相关状态
|
||||
const fileInputRef = ref(null)
|
||||
const acceptType = ref('.json')
|
||||
const currentImportType = ref('')
|
||||
const currentFileContent = ref('')
|
||||
const showDecryptDialog = ref(false)
|
||||
const importPassword = ref('')
|
||||
const isImporting = ref(false)
|
||||
|
||||
// ==========================================
|
||||
// 1. 导出逻辑
|
||||
// ==========================================
|
||||
const handleExport = async () => {
|
||||
if (exportForm.value.password !== exportForm.value.confirm) {
|
||||
return ElMessage.error('两次输入的密码不一致!')
|
||||
}
|
||||
if (exportForm.value.password.length < 12) {
|
||||
return ElMessage.error('密码太弱!至少需要 12 个字符。')
|
||||
}
|
||||
|
||||
loadingText.value = '正在进行高强度 AES-GCM 加密...'
|
||||
isExporting.value = true
|
||||
try {
|
||||
const data = await request('/api/accounts/export-secure', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password: exportForm.value.password })
|
||||
})
|
||||
|
||||
// 纯前端触发文件下载
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `2fa-backup-encrypted-${new Date().toISOString().split('T')[0]}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
ElMessage.success('🎉 加密备份导出成功!请妥善保管好您的密码!')
|
||||
showExportDialog.value = false
|
||||
exportForm.value = { password: '', confirm: '' }
|
||||
} catch (error) {
|
||||
// 错误已被 request 拦截器处理
|
||||
} finally {
|
||||
isExporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 2. 导入逻辑
|
||||
// ==========================================
|
||||
const triggerImport = (type, accept) => {
|
||||
currentImportType.value = type
|
||||
acceptType.value = accept
|
||||
// 必须用 setTimeout 等待 DOM 更新 accept 属性后再点击
|
||||
setTimeout(() => fileInputRef.value.click(), 0)
|
||||
}
|
||||
|
||||
const handleFileUpload = (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
ElMessage.error('文件太大,不能超过 10MB')
|
||||
e.target.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
currentFileContent.value = event.target.result
|
||||
|
||||
if (currentImportType.value === 'encrypted') {
|
||||
// 如果是加密导入,弹出密码框
|
||||
importPassword.value = ''
|
||||
showDecryptDialog.value = true
|
||||
} else {
|
||||
// 否则直接发送给后端
|
||||
submitImportData()
|
||||
}
|
||||
}
|
||||
reader.onerror = () => ElMessage.error('文件读取失败')
|
||||
reader.readAsText(file)
|
||||
e.target.value = '' // 清空,允许重复上传
|
||||
}
|
||||
|
||||
const submitImportData = async () => {
|
||||
if (currentImportType.value === 'encrypted' && !importPassword.value) {
|
||||
return ElMessage.warning('请输入解密密码')
|
||||
}
|
||||
|
||||
loadingText.value = '正在解密并导入数据...'
|
||||
isImporting.value = true
|
||||
try {
|
||||
const data = await request('/api/accounts/import', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
type: currentImportType.value,
|
||||
content: currentFileContent.value,
|
||||
password: importPassword.value
|
||||
})
|
||||
})
|
||||
|
||||
if (data.success) {
|
||||
let msg = `✅ 成功导入 ${data.count} 个账户!`
|
||||
if (data.duplicates > 0) msg += ` (自动跳过了 ${data.duplicates} 个重复账户)`
|
||||
ElMessage.success({ message: msg, duration: 5000 })
|
||||
|
||||
showDecryptDialog.value = false
|
||||
emit('import-success') // 通知 Home.vue 刷新列表并切回 Tab
|
||||
}
|
||||
} catch (error) {
|
||||
} finally {
|
||||
isImporting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.migration-container {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
height: 100%;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.desc {
|
||||
color: #606266;
|
||||
margin-bottom: 25px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.full-btn {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.import-options {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 15px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.import-options .el-button {
|
||||
margin: 0;
|
||||
justify-content: flex-start;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.import-options {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<div class="qr-scanner-wrapper">
|
||||
<div class="scan-actions">
|
||||
<el-button type="primary" size="large" @click="startCamera" :loading="isStarting">
|
||||
<el-icon><Camera /></el-icon> 启动摄像头扫描
|
||||
</el-button>
|
||||
<el-button size="large" @click="triggerFileUpload">
|
||||
<el-icon><Picture /></el-icon> 上传图片识别
|
||||
</el-button>
|
||||
<input
|
||||
type="file"
|
||||
ref="fileInputRef"
|
||||
accept="image/*"
|
||||
style="display: none"
|
||||
@change="handleFileUpload"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-show="isScanning" class="camera-container">
|
||||
<video ref="videoRef" class="video-preview" autoplay muted playsinline></video>
|
||||
<div class="scan-overlay">
|
||||
<div class="scan-line"></div>
|
||||
</div>
|
||||
<div class="camera-controls">
|
||||
<el-button type="danger" round @click="stopCamera">关闭摄像头</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onBeforeUnmount } from 'vue'
|
||||
import { Camera, Picture } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import jsQR from 'jsqr'
|
||||
|
||||
// 定义向父组件 (Home.vue) 传递结果的事件
|
||||
const emit = defineEmits(['scan-success'])
|
||||
|
||||
const videoRef = ref(null)
|
||||
const fileInputRef = ref(null)
|
||||
const isScanning = ref(false)
|
||||
const isStarting = ref(false)
|
||||
|
||||
let stream = null
|
||||
let scanInterval = null
|
||||
|
||||
// ==========================================
|
||||
// 1. 摄像头实时扫描逻辑
|
||||
// ==========================================
|
||||
const startCamera = async () => {
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
ElMessage.error('您的浏览器不支持摄像头功能')
|
||||
return
|
||||
}
|
||||
|
||||
isStarting.value = true
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'environment' } // 优先调用后置摄像头
|
||||
})
|
||||
|
||||
videoRef.value.srcObject = stream
|
||||
|
||||
// 等待视频流加载完成后开始扫描
|
||||
videoRef.value.onloadedmetadata = () => {
|
||||
videoRef.value.play()
|
||||
isScanning.value = true
|
||||
isStarting.value = false
|
||||
scanInterval = setInterval(scanVideoFrame, 500) // 每半秒截取一帧分析
|
||||
ElMessage.success('摄像头已启动,请将二维码对准取景框')
|
||||
}
|
||||
} catch (err) {
|
||||
isStarting.value = false
|
||||
ElMessage.error('无法访问摄像头,请检查权限设置')
|
||||
console.error('Camera error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const stopCamera = () => {
|
||||
if (stream) {
|
||||
stream.getTracks().forEach(track => track.stop())
|
||||
stream = null
|
||||
}
|
||||
if (scanInterval) {
|
||||
clearInterval(scanInterval)
|
||||
scanInterval = null
|
||||
}
|
||||
isScanning.value = false
|
||||
}
|
||||
|
||||
// 抽取视频帧进行二维码解析
|
||||
const scanVideoFrame = () => {
|
||||
if (!videoRef.value || videoRef.value.readyState !== videoRef.value.HAVE_ENOUGH_DATA) return
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true })
|
||||
|
||||
canvas.width = videoRef.value.videoWidth
|
||||
canvas.height = videoRef.value.videoHeight
|
||||
ctx.drawImage(videoRef.value, 0, 0, canvas.width, canvas.height)
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||||
const code = jsQR(imageData.data, imageData.width, imageData.height)
|
||||
|
||||
if (code && code.data.startsWith('otpauth://')) {
|
||||
stopCamera() // 扫到了就马上关掉摄像头
|
||||
emit('scan-success', code.data) // 把扫到的 URI 传给 Home.vue
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 2. 静态图片上传识别逻辑
|
||||
// ==========================================
|
||||
const triggerFileUpload = () => {
|
||||
fileInputRef.value.click()
|
||||
}
|
||||
|
||||
const handleFileUpload = (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
canvas.width = img.width
|
||||
canvas.height = img.height
|
||||
ctx.drawImage(img, 0, 0)
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||||
const code = jsQR(imageData.data, imageData.width, imageData.height)
|
||||
|
||||
if (code && code.data.startsWith('otpauth://')) {
|
||||
emit('scan-success', code.data)
|
||||
} else {
|
||||
ElMessage.error('未能从图片中识别出有效的 2FA 二维码')
|
||||
}
|
||||
}
|
||||
img.src = event.target.result
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
e.target.value = '' // 清空 input,允许重复上传同一张图片
|
||||
}
|
||||
|
||||
// 组件销毁前务必关掉摄像头,防止偷偷耗电
|
||||
onBeforeUnmount(() => {
|
||||
stopCamera()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.qr-scanner-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.scan-actions {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.camera-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.2);
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
.video-preview {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.scan-overlay {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
border: 2px solid rgba(64, 158, 255, 0.8);
|
||||
box-shadow: 0 0 0 4000px rgba(0, 0, 0, 0.4);
|
||||
border-radius: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 炫酷的扫描线动画 */
|
||||
.scan-line {
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background-color: #409EFF;
|
||||
box-shadow: 0 0 10px #409EFF;
|
||||
animation: scan 2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes scan {
|
||||
0% { transform: translateY(0); }
|
||||
50% { transform: translateY(250px); }
|
||||
100% { transform: translateY(0); }
|
||||
}
|
||||
|
||||
.camera-controls {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<footer class="global-footer">
|
||||
<div class="footer-content">
|
||||
<p>
|
||||
©{{ new Date().getFullYear() }} <a href="https://github.com/nap0o/2fauth-worker" target="_blank" rel="noopener noreferrer">2FAuth</a>
|
||||
<span class="divider">|</span>
|
||||
您的专属云端双因素认证管家
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// 这里可以添加页脚需要的逻辑,例如版本号显示等
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.global-footer {
|
||||
width: 100%;
|
||||
padding: 0.25rem 0;
|
||||
background-color: #f8f9fa;
|
||||
border-top: 1px solid #e9ecef;
|
||||
text-align: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
color: #6c757d;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: 0 0.5rem;
|
||||
color: #dee2e6;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #409EFF;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<header class="header">
|
||||
<div class="logo">
|
||||
<el-button v-if="layoutState.isMobile && route.path !== '/login'" link @click="layoutState.showMobileMenu = true" style="margin-right: 5px; padding: 0;">
|
||||
<el-icon :size="24" color="#606266"><Menu /></el-icon>
|
||||
</el-button>
|
||||
<el-icon :size="24" color="#409EFC" style="margin-right: 10px;"><Lock /></el-icon>
|
||||
<h2>2FAuth</h2>
|
||||
</div>
|
||||
<div class="user-actions" v-if="route.path !== '/login'">
|
||||
<div class="user-profile" v-if="!layoutState.isMobile">
|
||||
<el-avatar :size="32" :src="userState.userInfo?.avatar_template" />
|
||||
<span class="username">{{ userState.userInfo?.username }}</span>
|
||||
</div>
|
||||
<el-button type="danger" circle @click="handleLogout" title="退出登录">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { Lock, SwitchButton, Menu } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { layoutState } from '../states/layout'
|
||||
import { userState } from '../states/user'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const handleLogout = () => {
|
||||
userState.clearUserInfo()
|
||||
router.push('/login')
|
||||
ElMessage.success('已安全退出')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
background: white;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
height: 60px;
|
||||
flex-shrink: 0; /* 防止被压缩 */
|
||||
z-index: 100;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.logo, .user-actions, .user-profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo h2 { margin: 0; color: #303133; font-size: 20px; }
|
||||
.user-actions { gap: 20px; }
|
||||
.user-profile { gap: 10px; background: #f0f2f5; padding: 4px 15px 4px 4px; border-radius: 20px; }
|
||||
.username { font-weight: 600; color: #606266; font-size: 14px; }
|
||||
|
||||
@media (max-width: 768px) { .header { padding: 0 15px; } .logo h2 { font-size: 18px; } }
|
||||
</style>
|
||||
@@ -0,0 +1,303 @@
|
||||
<template>
|
||||
<div class="webdav-container">
|
||||
<el-row :gutter="30">
|
||||
|
||||
<el-col :xs="24" :md="10" style="margin-bottom: 20px;">
|
||||
<el-card class="config-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header" @click="hasConfig ? showConfig = !showConfig : null" :style="{ cursor: hasConfig ? 'pointer' : 'default', justifyContent: 'space-between' }">
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<el-icon><Setting /></el-icon> <span>WebDAV 配置</span>
|
||||
</div>
|
||||
<el-icon v-if="hasConfig">
|
||||
<ArrowUp v-if="showConfig" />
|
||||
<ArrowDown v-else />
|
||||
</el-icon>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-show="showConfig">
|
||||
<el-alert title="数据主权" type="info" description="配置您的私有云盘。所有备份上传前均会进行高强度 AES-GCM 加密,即使网盘被攻破,数据也绝对安全。" show-icon :closable="false" style="margin-bottom: 20px;" />
|
||||
|
||||
<el-form :model="config" label-position="top" v-loading="isTesting || isSaving" :element-loading-text="loadingText">
|
||||
<el-form-item label="WebDAV 地址">
|
||||
<el-input v-model="config.url" placeholder="https://your-server.com/remote.php/webdav/" clearable />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="config.username" placeholder="输入网盘账号" clearable />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="密码或应用授权码">
|
||||
<el-input v-model="config.password" type="password" show-password placeholder="强烈建议使用应用专用密码" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="网盘保存目录">
|
||||
<el-input v-model="config.saveDir" placeholder="/2fauth-backups" />
|
||||
<div class="form-tip">系统会自动在此目录下创建按日期分类的文件夹</div>
|
||||
</el-form-item>
|
||||
|
||||
<div class="action-buttons">
|
||||
<el-button type="info" plain :loading="isTesting" @click="testConnection">
|
||||
<el-icon><Link /></el-icon> 测试连接
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="isSaving" @click="saveConfig">
|
||||
<el-icon><Select /></el-icon> 保存配置
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="24" :md="14">
|
||||
<el-card class="backup-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header" style="justify-content: space-between;">
|
||||
<div><el-icon><Cloudy /></el-icon> <span>云端备份库</span></div>
|
||||
<el-button type="success" :loading="isExporting" @click="showExportDialog = true" :disabled="!hasConfig">
|
||||
<el-icon><Upload /></el-icon> 立即备份到云端
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="!hasConfig" class="empty-tip">
|
||||
<el-empty description="请先在左侧保存 WebDAV 配置" :image-size="100" />
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div style="margin-bottom: 15px; display: flex; justify-content: space-between;">
|
||||
<span style="font-weight: 600; color: #606266;">云端文件列表</span>
|
||||
<el-button size="small" circle @click="loadBackups" :loading="isLoadingList" title="刷新列表">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="backupList" v-loading="isLoadingList" style="width: 100%" height="300" empty-text="没有备份文件存在,请先备份.">
|
||||
<el-table-column prop="filename" label="文件名" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="lastModified" label="修改时间" width="160">
|
||||
<template #default="scope">
|
||||
{{ scope.row.lastModified ? new Date(scope.row.lastModified).toLocaleString() : '未知' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="size" label="大小" width="100">
|
||||
<template #default="scope">
|
||||
{{ formatSize(scope.row.size) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" fixed="right" align="center">
|
||||
<template #default="scope">
|
||||
<el-button type="primary" link @click="triggerRestore(scope.row)">恢复</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
</el-row>
|
||||
|
||||
<el-dialog v-model="showExportDialog" title="设置云端加密密码" width="400px" destroy-on-close>
|
||||
<el-alert title="极其重要!" type="error" description="系统不会保存此密码!一旦遗忘,云端的备份将变成一堆无法解密的乱码,神仙难救!" show-icon :closable="false" style="margin-bottom: 20px;" />
|
||||
<el-form label-position="top" v-loading="isExporting" :element-loading-text="loadingText">
|
||||
<el-form-item label="加密密码 (至少 12 位)">
|
||||
<el-input v-model="exportPassword" type="password" show-password placeholder="请输入高强度密码" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showExportDialog = false">取消</el-button>
|
||||
<el-button type="success" :loading="isExporting" @click="handleExportToWebDAV">加密并上传</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="showRestoreDialog" title="🔓 从云端恢复" width="400px" destroy-on-close>
|
||||
<el-alert title="高危操作" type="warning" description="恢复成功后,将清空覆盖当前金库里的所有数据!" show-icon :closable="false" style="margin-bottom: 15px;" />
|
||||
<el-form label-position="top" v-loading="isRestoring" :element-loading-text="loadingText">
|
||||
<el-form-item label="请输入该备份的解密密码:">
|
||||
<el-input v-model="restorePassword" type="password" show-password placeholder="输入备份时设置的密码" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showRestoreDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="isRestoring" @click="handleRestore">确认解密覆盖</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, onBeforeUnmount } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Setting, Cloudy, Link, Select, Upload, Refresh, ArrowDown, ArrowUp } from '@element-plus/icons-vue'
|
||||
import { request } from '../utils/request'
|
||||
|
||||
const emit = defineEmits(['restore-success'])
|
||||
|
||||
// 表单与状态
|
||||
const config = ref({ url: '', username: '', password: '', saveDir: '/2fauth-backups' })
|
||||
const backupList = ref([])
|
||||
const hasConfig = computed(() => !!config.value.url && !!config.value.username)
|
||||
|
||||
// Loading 状态
|
||||
const isTesting = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const isLoadingList = ref(false)
|
||||
const isExporting = ref(false)
|
||||
const isRestoring = ref(false)
|
||||
|
||||
// 弹窗状态
|
||||
const showExportDialog = ref(false)
|
||||
const exportPassword = ref('')
|
||||
const showRestoreDialog = ref(false)
|
||||
const restorePassword = ref('')
|
||||
const selectedBackupPath = ref('')
|
||||
const loadingText = ref('')
|
||||
|
||||
// 移动端适配与折叠逻辑
|
||||
const isMobile = ref(window.innerWidth < 768)
|
||||
const showConfig = ref(true)
|
||||
const checkMobile = () => { isMobile.value = window.innerWidth < 768 }
|
||||
|
||||
// ==========================================
|
||||
// 1. 初始化拉取配置
|
||||
// ==========================================
|
||||
onMounted(async () => {
|
||||
window.addEventListener('resize', checkMobile)
|
||||
try {
|
||||
const data = await request('/api/webdav/configs')
|
||||
if (data.success && data.configs && data.configs.length > 0) {
|
||||
// 我们目前先只取第一个配置
|
||||
config.value = { ...config.value, ...data.configs[0] }
|
||||
loadBackups()
|
||||
|
||||
// 移动端且有配置时,自动收起,方便用户直接看到下方的备份列表
|
||||
if (isMobile.value && hasConfig.value) {
|
||||
showConfig.value = false
|
||||
}
|
||||
}
|
||||
} catch (e) { console.error('Load config failed', e) }
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', checkMobile)
|
||||
})
|
||||
|
||||
// ==========================================
|
||||
// 2. 测试与保存配置
|
||||
// ==========================================
|
||||
const testConnection = async () => {
|
||||
if (!config.value.url || !config.value.username || !config.value.password) {
|
||||
return ElMessage.warning('请填写完整的连接信息')
|
||||
}
|
||||
loadingText.value = '正在连接 WebDAV 服务器...'
|
||||
isTesting.value = true
|
||||
try {
|
||||
const data = await request('/api/webdav/test', {
|
||||
method: 'POST', body: JSON.stringify(config.value)
|
||||
})
|
||||
if (data.success) ElMessage.success('🚀 测试成功!您的 WebDAV 服务器已连通!')
|
||||
} catch (e) {
|
||||
} finally { isTesting.value = false }
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
if (!config.value.url) return ElMessage.warning('配置不能为空')
|
||||
loadingText.value = '正在保存配置...'
|
||||
isSaving.value = true
|
||||
try {
|
||||
const data = await request('/api/webdav/configs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ configs: [config.value] })
|
||||
})
|
||||
if (data.success) {
|
||||
ElMessage.success('✅ 配置已保存')
|
||||
loadBackups()
|
||||
}
|
||||
} catch (e) {
|
||||
} finally { isSaving.value = false }
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 3. 获取云端列表
|
||||
// ==========================================
|
||||
const loadBackups = async () => {
|
||||
if (!hasConfig.value) return
|
||||
isLoadingList.value = true
|
||||
try {
|
||||
const data = await request('/api/webdav/list', {
|
||||
method: 'POST', body: JSON.stringify(config.value)
|
||||
})
|
||||
if (data.success) {
|
||||
backupList.value = data.backups || []
|
||||
}
|
||||
} catch (e) {
|
||||
} finally { isLoadingList.value = false }
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 4. 一键加密上传
|
||||
// ==========================================
|
||||
const handleExportToWebDAV = async () => {
|
||||
if (exportPassword.value.length < 12) return ElMessage.warning('密码太弱!至少需要 12 位。')
|
||||
loadingText.value = '正在加密并上传至云端...'
|
||||
isExporting.value = true
|
||||
try {
|
||||
const data = await request('/api/webdav/export', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password: exportPassword.value, webdavConfig: config.value })
|
||||
})
|
||||
if (data.success) {
|
||||
ElMessage.success('🎉 备份已加密并安全抵达云端!')
|
||||
showExportDialog.value = false
|
||||
exportPassword.value = ''
|
||||
loadBackups() // 刷新列表
|
||||
}
|
||||
} catch (e) {
|
||||
} finally { isExporting.value = false }
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 5. 恢复数据
|
||||
// ==========================================
|
||||
const triggerRestore = (row) => {
|
||||
selectedBackupPath.value = row.filename
|
||||
restorePassword.value = ''
|
||||
showRestoreDialog.value = true
|
||||
}
|
||||
|
||||
const handleRestore = async () => {
|
||||
if (!restorePassword.value) return ElMessage.warning('请输入解密密码')
|
||||
loadingText.value = '正在下载并解密恢复...'
|
||||
isRestoring.value = true
|
||||
try {
|
||||
const data = await request('/api/webdav/restore', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ filename: selectedBackupPath.value, password: restorePassword.value, webdavConfig: config.value })
|
||||
})
|
||||
if (data.success) {
|
||||
ElMessage.success(`✅ 云端同步完成!数据已恢复。`)
|
||||
showRestoreDialog.value = false
|
||||
emit('restore-success') // 通知父组件刷新金库
|
||||
}
|
||||
} catch (e) {
|
||||
} finally { isRestoring.value = false }
|
||||
}
|
||||
|
||||
// 工具函数:格式化文件大小
|
||||
const formatSize = (bytes) => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024, sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.webdav-container { padding: 10px 0; }
|
||||
.config-card, .backup-card { height: 100%; border-radius: 12px; border: none; }
|
||||
.card-header { font-size: 18px; font-weight: 600; display: flex; align-items: center; gap: 10px; }
|
||||
.form-tip { font-size: 12px; color: #909399; margin-top: 4px; line-height: 1.4; }
|
||||
.action-buttons { display: flex; gap: 15px; margin-top: 25px; }
|
||||
.empty-tip { padding: 40px 0; }
|
||||
:deep(.el-table__row) { cursor: pointer; }
|
||||
</style>
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createApp } from 'vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: () => import('../views/Home.vue'),
|
||||
meta: { requiresAuth: true } // 需要登录才能访问
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('../views/Login.vue')
|
||||
},
|
||||
{
|
||||
path: '/oauth/callback', // 匹配 GitHub 回调的路径
|
||||
name: 'OAuthCallback',
|
||||
component: () => import('../views/OAuthCallback.vue')
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
// 路由守卫:拦截未登录用户的访问
|
||||
router.beforeEach((to, from, next) => {
|
||||
const isAuthenticated = !!localStorage.getItem('authToken')
|
||||
|
||||
if (to.meta.requiresAuth && !isAuthenticated) {
|
||||
next('/login')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,6 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export const layoutState = reactive({
|
||||
isMobile: false,
|
||||
showMobileMenu: false
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
const getStoredUser = () => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem('userInfo') || '{}')
|
||||
} catch (e) {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export const userState = reactive({
|
||||
userInfo: getStoredUser(),
|
||||
|
||||
setUserInfo(info) {
|
||||
this.userInfo = info
|
||||
localStorage.setItem('userInfo', JSON.stringify(info))
|
||||
},
|
||||
|
||||
clearUserInfo() {
|
||||
this.userInfo = {}
|
||||
localStorage.removeItem('userInfo')
|
||||
localStorage.removeItem('authToken')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ElMessage } from 'element-plus'
|
||||
import router from '../router'
|
||||
|
||||
export async function request(url, options = {}) {
|
||||
// 自动携带 Token
|
||||
const token = localStorage.getItem('authToken')
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { ...options, headers })
|
||||
const data = await response.json()
|
||||
|
||||
// 处理 401 未登录或 Token 过期
|
||||
if (response.status === 401) {
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
localStorage.removeItem('authToken')
|
||||
localStorage.removeItem('userInfo')
|
||||
router.push('/login')
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
// 处理其他报错
|
||||
if (!response.ok) {
|
||||
ElMessage.error(data.error || data.message || '请求失败')
|
||||
throw new Error(data.error || '请求失败')
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('API Request Error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,745 @@
|
||||
<template>
|
||||
<el-container class="home-container">
|
||||
<el-container class="main-body">
|
||||
<el-aside width="240px" class="left-aside" v-if="!layoutState.isMobile">
|
||||
<el-menu
|
||||
:default-active="activeTab"
|
||||
class="side-menu"
|
||||
@select="handleMenuSelect"
|
||||
background-color="#fff"
|
||||
text-color="#606266"
|
||||
active-text-color="#409EFF"
|
||||
>
|
||||
<el-menu-item index="accounts">
|
||||
<el-icon><Iphone /></el-icon>
|
||||
<span>我的账户</span>
|
||||
</el-menu-item>
|
||||
|
||||
<el-sub-menu index="tools">
|
||||
<template #title>
|
||||
<el-icon><Tools /></el-icon><span>实用工具</span>
|
||||
</template>
|
||||
<el-menu-item index="add-account">添加账号</el-menu-item>
|
||||
<el-menu-item index="migration">数据迁移</el-menu-item>
|
||||
<el-menu-item index="webdav">数据备份</el-menu-item>
|
||||
</el-sub-menu>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<!-- 移动端抽屉菜单 -->
|
||||
<el-drawer
|
||||
v-model="layoutState.showMobileMenu"
|
||||
direction="ltr"
|
||||
size="240px"
|
||||
:with-header="false"
|
||||
>
|
||||
<el-menu
|
||||
:default-active="activeTab"
|
||||
class="side-menu"
|
||||
@select="handleMenuSelect"
|
||||
style="border-right: none;"
|
||||
>
|
||||
<el-menu-item index="accounts">
|
||||
<el-icon><Iphone /></el-icon>
|
||||
<span>我的账户</span>
|
||||
</el-menu-item>
|
||||
<el-sub-menu index="tools">
|
||||
<template #title>
|
||||
<el-icon><Tools /></el-icon><span>实用工具</span>
|
||||
</template>
|
||||
<el-menu-item index="add-account">添加账号</el-menu-item>
|
||||
<el-menu-item index="migration">数据迁移</el-menu-item>
|
||||
<el-menu-item index="webdav">数据备份</el-menu-item>
|
||||
</el-sub-menu>
|
||||
</el-menu>
|
||||
</el-drawer>
|
||||
|
||||
<el-main class="main-content">
|
||||
<!-- 1. 我的账户视图 -->
|
||||
<div v-if="activeTab === 'accounts'" class="view-container">
|
||||
<div class="account-list-wrapper" v-loading="loading" element-loading-text="数据加载中..." style="min-height: 400px;">
|
||||
<div v-if="!loading && accounts.length === 0 && !searchQuery" class="empty-state">
|
||||
<el-empty description="空空如也,快去添加你的第一个 2FA 账号吧!">
|
||||
<el-button type="primary" @click="activeTab = 'add-account'">去添加账号</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="toolbar" style="margin-bottom: 20px; display: flex; gap: 15px; align-items: center; justify-content: space-between; flex-wrap: wrap;">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="🔍 搜索服务名称、账号或分类..."
|
||||
clearable
|
||||
style="max-width: 400px; flex: 1;"
|
||||
/>
|
||||
|
||||
<div class="batch-actions" v-if="selectedIds.length > 0" style="display: flex; align-items: center; gap: 10px;">
|
||||
<span style="color: #606266; font-size: 14px;">已选择 {{ selectedIds.length }} 项</span>
|
||||
<el-button type="danger" plain @click="handleBulkDelete" :loading="isBulkDeleting">
|
||||
<el-icon><Delete /></el-icon> 批量删除
|
||||
</el-button>
|
||||
<el-button @click="selectedIds = []" plain>取消选择</el-button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<el-button @click="selectAllVisible" plain>全选本页</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="20" v-if="accounts.length > 0">
|
||||
<el-col :xs="24" :sm="12" :md="8" :lg="6" v-for="account in accounts" :key="account.id" style="margin-bottom: 20px;">
|
||||
<el-card class="account-card" :class="{ 'is-selected': selectedIds.includes(account.id) }" shadow="hover">
|
||||
<div class="card-header">
|
||||
<div class="service-info" style="display: flex; align-items: center; gap: 10px;">
|
||||
<el-checkbox :model-value="selectedIds.includes(account.id)" @change="toggleSelection(account.id)" @click.stop />
|
||||
<h3 class="service-name">{{ account.service }}</h3>
|
||||
<el-tag size="small" v-if="account.category" effect="light">{{ account.category }}</el-tag>
|
||||
</div>
|
||||
|
||||
<el-dropdown trigger="click" @command="(cmd) => handleCommand(cmd, account)">
|
||||
<el-icon class="more-icon"><MoreFilled /></el-icon>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="edit">
|
||||
<el-icon><Edit /></el-icon> 编辑账号
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="delete" style="color: #F56C6C;">
|
||||
<el-icon><Delete /></el-icon> 删除账号
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
|
||||
<p class="account-name" style="margin-left: 24px;">{{ account.account }}</p>
|
||||
|
||||
<div class="card-actions">
|
||||
<el-button type="primary" class="code-btn" @click="showTotpCode(account)">
|
||||
<el-icon><Key /></el-icon> 获取验证码
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<div class="pagination-wrapper" v-if="total > 0" style="margin-top: 10px; display: flex; justify-content: center;">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[12, 24, 48, 96]"
|
||||
background
|
||||
:layout="layoutState.isMobile ? 'prev, pager, next' : 'total, sizes, prev, pager, next, jumper'"
|
||||
:small="layoutState.isMobile"
|
||||
:total="total"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!loading && accounts.length === 0 && searchQuery" description="没有找到匹配的账号" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. 添加账号视图 (整合扫码与手动) -->
|
||||
<div v-if="activeTab === 'add-account'" class="view-container">
|
||||
<div class="tab-card-wrapper">
|
||||
<h2 style="text-align: center; margin-bottom: 20px;">添加账号</h2>
|
||||
<el-tabs type="border-card" style="max-width: 600px; margin: 0 auto;">
|
||||
<el-tab-pane label="📷 扫码添加">
|
||||
<div style="text-align: center; margin-bottom: 20px; margin-top: 10px;">
|
||||
<p style="color: #909399;">请允许浏览器使用摄像头,或直接上传微信/系统截图。</p>
|
||||
</div>
|
||||
<QrScanner @scan-success="handleScanSuccess" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="⌨️ 手动输入">
|
||||
<el-form :model="newAccount" label-position="top" :rules="rules" ref="addFormRef" style="padding: 10px 0;">
|
||||
<el-form-item label="服务名称 (如 Google, GitHub)" prop="service">
|
||||
<el-input v-model="newAccount.service" placeholder="请输入服务名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="账号标识 (如 邮箱地址)" prop="account">
|
||||
<el-input v-model="newAccount.account" placeholder="请输入账号或邮箱" />
|
||||
</el-form-item>
|
||||
<el-form-item label="安全密钥 (Base32格式)" prop="secret">
|
||||
<el-input v-model="newAccount.secret" placeholder="请输入 16 位以上的安全密钥" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类 (可选)" prop="category">
|
||||
<el-input v-model="newAccount.category" placeholder="如 工作, 个人" />
|
||||
</el-form-item>
|
||||
<el-form-item style="margin-top: 30px;">
|
||||
<el-button type="primary" :loading="submitting" @click="submitAddAccount" style="width: 100%;" size="large">确认添加</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. 数据迁移视图 -->
|
||||
<div v-if="activeTab === 'migration'" class="view-container">
|
||||
<div class="tab-card-wrapper">
|
||||
<div style="text-align: center; margin-bottom: 30px;">
|
||||
<h2>数据迁移中心</h2>
|
||||
<p style="color: #909399;">安全地备份您的数据,或从其他软件无缝迁移数据。</p>
|
||||
</div>
|
||||
<DataMigration @import-success="handleImportSuccess" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. WebDAV 备份视图 -->
|
||||
<div v-if="activeTab === 'webdav'" class="view-container">
|
||||
<div class="tab-card-wrapper">
|
||||
<div style="text-align: center; margin-bottom: 30px;">
|
||||
<h2>私有云自动备份中心</h2>
|
||||
<p style="color: #909399;">将加密数据安全托管至您的个人网盘,永远不用担心手机丢失。</p>
|
||||
</div>
|
||||
<WebDavConfig @restore-success="handleImportSuccess" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</el-main>
|
||||
</el-container>
|
||||
|
||||
<el-dialog v-model="showEditDialog" title="✏️ 编辑账号" :width="layoutState.isMobile ? '90%' : '400px'" destroy-on-close>
|
||||
<el-form :model="editAccountData" label-position="top">
|
||||
<el-form-item label="服务名称 (如 Google, GitHub)">
|
||||
<el-input v-model="editAccountData.service" />
|
||||
</el-form-item>
|
||||
<el-form-item label="账号标识 (如 邮箱地址)">
|
||||
<el-input v-model="editAccountData.account" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类 (可选)">
|
||||
<el-input v-model="editAccountData.category" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="showEditDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="isEditing" @click="submitEditAccount">保存修改</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- TOTP 验证码弹窗 (新增) -->
|
||||
<el-dialog v-model="showTotpDialog" :title="currentTotpAccount?.account" width="300px" center align-center @closed="stopTotpTimer" destroy-on-close>
|
||||
<div class="totp-container">
|
||||
<div class="totp-service">{{ currentTotpAccount?.service }}</div>
|
||||
<div class="totp-code" @click="copyCode">{{ currentTotpCode }}</div>
|
||||
<div class="totp-timer">
|
||||
<el-progress type="circle" :percentage="totpPercentage" :width="80" :stroke-width="6" :color="totpColor">
|
||||
<template #default>
|
||||
<span class="timer-text">{{ totpRemaining }}s</span>
|
||||
</template>
|
||||
</el-progress>
|
||||
</div>
|
||||
<div class="totp-tip">点击验证码复制</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch, h, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Key, MoreFilled, Edit, Delete, Tools, Setting, Iphone } from '@element-plus/icons-vue'
|
||||
import { request } from '../utils/request'
|
||||
import { layoutState } from '../states/layout'
|
||||
|
||||
import QrScanner from '../components/QrScanner.vue'
|
||||
import DataMigration from '../components/DataMigration.vue'
|
||||
import WebDavConfig from '../components/WebDavConfig.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const accounts = ref([])
|
||||
const loading = ref(true)
|
||||
const activeTab = ref('accounts') // 默认选中的标签页
|
||||
const total = ref(0) // 新增:存储服务端返回的总数
|
||||
|
||||
// ====== 搜索与分页状态 ======
|
||||
const searchQuery = ref('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(12) // 默认每页显示 12 个(正好排满 3 行)
|
||||
|
||||
// 2. 移除客户端分页切片逻辑 (paginatedAccounts),因为 accounts 现在已经是当前页的数据了
|
||||
// const paginatedAccounts = computed(() => { ... })
|
||||
|
||||
// 3. 分页事件处理
|
||||
const handleSizeChange = (val) => {
|
||||
pageSize.value = val
|
||||
currentPage.value = 1 // 切换每页条数时,重置回第一页,防止数据为空
|
||||
fetchAccounts()
|
||||
}
|
||||
|
||||
const handleCurrentChange = (val) => {
|
||||
currentPage.value = val
|
||||
fetchAccounts()
|
||||
}
|
||||
|
||||
// 4. 监听搜索词变化:只要用户一打字,就把页码重置回第 1 页
|
||||
let searchTimer = null
|
||||
watch(searchQuery, () => {
|
||||
currentPage.value = 1
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => {
|
||||
fetchAccounts()
|
||||
}, 300)
|
||||
})
|
||||
|
||||
// 添加表单相关状态
|
||||
const submitting = ref(false)
|
||||
const addFormRef = ref(null)
|
||||
const newAccount = ref({
|
||||
service: '', account: '', secret: '', category: ''
|
||||
})
|
||||
|
||||
const rules = {
|
||||
service: [{ required: true, message: '请输入服务名称', trigger: 'blur' }],
|
||||
account: [{ required: true, message: '请输入账号标识', trigger: 'blur' }],
|
||||
secret: [
|
||||
{ required: true, message: '请输入安全密钥', trigger: 'blur' },
|
||||
{ min: 16, message: '密钥长度通常不少于 16 位', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// ====== 移动端适配逻辑 ======
|
||||
const handleMenuSelect = (index) => {
|
||||
activeTab.value = index
|
||||
if (layoutState.isMobile) {
|
||||
layoutState.showMobileMenu = false
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 获取账号列表
|
||||
const fetchAccounts = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
// 构造分页参数
|
||||
const query = new URLSearchParams({
|
||||
page: currentPage.value,
|
||||
limit: pageSize.value,
|
||||
search: searchQuery.value
|
||||
}).toString()
|
||||
|
||||
const data = await request(`/api/accounts?${query}`)
|
||||
if (data.success) {
|
||||
accounts.value = data.accounts || []
|
||||
// 更新总数
|
||||
if (data.pagination) {
|
||||
total.value = data.pagination.total
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch accounts', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 提交手动添加账号
|
||||
const submitAddAccount = async () => {
|
||||
if (!addFormRef.value) return
|
||||
await addFormRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
submitting.value = true
|
||||
try {
|
||||
const data = await request('/api/accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(newAccount.value)
|
||||
})
|
||||
if (data.success) {
|
||||
ElMessage.success('账号添加成功!')
|
||||
newAccount.value = { service: '', account: '', secret: '', category: '' }
|
||||
fetchAccounts()
|
||||
activeTab.value = 'accounts' // 自动切回列表
|
||||
}
|
||||
} catch (error) {
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 3. 点击获取验证码 (重构:支持倒计时与自动刷新)
|
||||
const showTotpDialog = ref(false)
|
||||
const currentTotpAccount = ref(null)
|
||||
const currentTotpCode = ref('------')
|
||||
const totpRemaining = ref(30)
|
||||
let totpInterval = null
|
||||
let lastTotpEpoch = -1
|
||||
|
||||
const totpPercentage = computed(() => {
|
||||
const period = currentTotpAccount.value?.period || 30
|
||||
return (totpRemaining.value / period) * 100
|
||||
})
|
||||
|
||||
const totpColor = computed(() => {
|
||||
if (totpRemaining.value > 10) return '#67C23A'
|
||||
if (totpRemaining.value > 5) return '#E6A23C'
|
||||
return '#F56C6C'
|
||||
})
|
||||
|
||||
const showTotpCode = (account) => {
|
||||
currentTotpAccount.value = account
|
||||
currentTotpCode.value = '------'
|
||||
lastTotpEpoch = -1
|
||||
showTotpDialog.value = true
|
||||
|
||||
updateTotpLogic()
|
||||
if (totpInterval) clearInterval(totpInterval)
|
||||
totpInterval = setInterval(updateTotpLogic, 1000)
|
||||
}
|
||||
|
||||
const updateTotpLogic = () => {
|
||||
if (!currentTotpAccount.value) return
|
||||
|
||||
const period = currentTotpAccount.value.period || 30
|
||||
const now = Date.now() / 1000
|
||||
const epoch = Math.floor(now / period)
|
||||
const remaining = Math.ceil(period - (now % period))
|
||||
|
||||
totpRemaining.value = remaining
|
||||
|
||||
// 如果进入了新的周期(epoch 变化),或者当前没有验证码,则刷新
|
||||
if (epoch > lastTotpEpoch) {
|
||||
fetchTotpCode()
|
||||
lastTotpEpoch = epoch
|
||||
}
|
||||
}
|
||||
|
||||
const fetchTotpCode = async () => {
|
||||
try {
|
||||
const data = await request('/api/accounts/generate-totp', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ secret: currentTotpAccount.value.secret })
|
||||
})
|
||||
if (data.success) {
|
||||
currentTotpCode.value = data.code
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
const stopTotpTimer = () => {
|
||||
if (totpInterval) clearInterval(totpInterval)
|
||||
totpInterval = null
|
||||
}
|
||||
|
||||
const copyCode = async () => {
|
||||
if (currentTotpCode.value === '------') return
|
||||
try {
|
||||
await navigator.clipboard.writeText(currentTotpCode.value)
|
||||
ElMessage.success('验证码已复制')
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// 4. 删除账号
|
||||
// ====== 批量选择与删除逻辑 ======
|
||||
const selectedIds = ref([])
|
||||
const isBulkDeleting = ref(false)
|
||||
|
||||
const toggleSelection = (id) => {
|
||||
const index = selectedIds.value.indexOf(id)
|
||||
if (index > -1) selectedIds.value.splice(index, 1)
|
||||
else selectedIds.value.push(id)
|
||||
}
|
||||
|
||||
const selectAllVisible = () => {
|
||||
accounts.value.forEach(acc => {
|
||||
if (!selectedIds.value.includes(acc.id)) selectedIds.value.push(acc.id)
|
||||
})
|
||||
}
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除选中的 ${selectedIds.value.length} 个账号吗?删除后无法恢复!`,
|
||||
'高危操作警告',
|
||||
{ confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'error' }
|
||||
)
|
||||
|
||||
isBulkDeleting.value = true
|
||||
// ⚠️ 极其关键:必须使用 for...of 串行发送请求,防止 Cloudflare KV 数据库并发写入冲突导致数据丢失!
|
||||
for (const id of selectedIds.value) {
|
||||
await request(`/api/accounts/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
ElMessage.success(`成功删除了 ${selectedIds.value.length} 个账号`)
|
||||
selectedIds.value = []
|
||||
fetchAccounts() // 刷新列表
|
||||
} catch (e) {
|
||||
} finally {
|
||||
isBulkDeleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ====== 编辑账号逻辑 ======
|
||||
const showEditDialog = ref(false)
|
||||
const isEditing = ref(false)
|
||||
const editAccountData = ref({ id: '', service: '', account: '', category: '' })
|
||||
|
||||
// 处理卡片右上角的下拉菜单
|
||||
const handleCommand = async (command, account) => {
|
||||
if (command === 'delete') {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除 [${account.service}] 吗?`, '警告', { type: 'error' })
|
||||
const data = await request(`/api/accounts/${account.id}`, { method: 'DELETE' })
|
||||
if (data.success) {
|
||||
ElMessage.success('账号已删除')
|
||||
fetchAccounts()
|
||||
}
|
||||
} catch (e) {}
|
||||
} else if (command === 'edit') {
|
||||
// 弹出编辑框并回显数据
|
||||
editAccountData.value = {
|
||||
id: account.id,
|
||||
service: account.service,
|
||||
account: account.account,
|
||||
category: account.category || ''
|
||||
}
|
||||
showEditDialog.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 提交编辑保存
|
||||
const submitEditAccount = async () => {
|
||||
if (!editAccountData.value.service || !editAccountData.value.account) {
|
||||
return ElMessage.warning('服务名称和账号不能为空')
|
||||
}
|
||||
isEditing.value = true
|
||||
try {
|
||||
const data = await request(`/api/accounts/${editAccountData.value.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
service: editAccountData.value.service,
|
||||
account: editAccountData.value.account,
|
||||
category: editAccountData.value.category
|
||||
})
|
||||
})
|
||||
if (data.success) {
|
||||
ElMessage.success('账号修改成功')
|
||||
showEditDialog.value = false
|
||||
fetchAccounts()
|
||||
}
|
||||
} catch (e) {
|
||||
} finally {
|
||||
isEditing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 扫码成功后的处理逻辑
|
||||
const handleScanSuccess = async (uri) => {
|
||||
try {
|
||||
// 调用后端解析 URI
|
||||
const data = await request('/api/accounts/parse-uri', {
|
||||
// ⚠️ 注意:这里也由于路由重构,改成了 /api/accounts/parse-uri
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ uri })
|
||||
})
|
||||
|
||||
if (data.success && data.account) {
|
||||
const acc = data.account
|
||||
// 弹窗确认
|
||||
await ElMessageBox.confirm(
|
||||
h('div', { style: 'text-align:left; background:#f5f7fa; padding:15px; border-radius:8px;' }, [
|
||||
h('p', [h('strong', '服务方:'), acc.issuer || '未知']),
|
||||
h('p', [h('strong', '账号:'), acc.account || '未知']),
|
||||
h('p', [h('strong', '算法:'), `${acc.algorithm} (${acc.digits}位 / ${acc.period}秒)`])
|
||||
]),
|
||||
'✅ 二维码解析成功,确认添加?',
|
||||
{
|
||||
confirmButtonText: '确认添加',
|
||||
cancelButtonText: '取消',
|
||||
type: 'success'
|
||||
}
|
||||
)
|
||||
|
||||
// 确认后调用添加接口
|
||||
const addData = await request('/api/accounts/add-from-uri', {
|
||||
// ⚠️ 同理,改成了 /api/accounts/add-from-uri
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ uri, category: '手机扫码' })
|
||||
})
|
||||
|
||||
if (addData.success) {
|
||||
ElMessage.success('扫码账号添加成功!')
|
||||
fetchAccounts() // 刷新列表
|
||||
activeTab.value = 'accounts' // 切回列表页
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err !== 'cancel') console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 数据导入成功后的联动处理
|
||||
// ==========================================
|
||||
const handleImportSuccess = () => {
|
||||
fetchAccounts() // 重新拉取最新的账号列表
|
||||
activeTab.value = 'accounts' // 自动把标签页切回“我的账户”
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAccounts()
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home-container {
|
||||
height: 100%; /* 占满父容器 (App.vue 的 main) */
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.mobile-profile {
|
||||
padding: 4px !important;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
justify-content: center;
|
||||
border-radius: 50% !important;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-weight: 600;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.main-body {
|
||||
height: 100%; /* 自动填充 */
|
||||
display: flex;
|
||||
overflow: hidden; /* 内部滚动 */
|
||||
}
|
||||
|
||||
.left-aside {
|
||||
background: white;
|
||||
border-right: 1px solid #e6e6e6;
|
||||
}
|
||||
|
||||
.side-menu {
|
||||
border-right: none;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 30px 20px;
|
||||
width: 100%;
|
||||
background-color: #f5f7fa;
|
||||
overflow-y: auto; /* 内容区独立滚动 */
|
||||
}
|
||||
|
||||
.tab-card-wrapper {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.account-card {
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.account-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.service-name {
|
||||
margin: 0 0 5px 0;
|
||||
font-size: 18px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.account-name {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
margin: 0 0 20px 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.more-icon {
|
||||
cursor: pointer;
|
||||
color: #909399;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.more-icon:hover {
|
||||
color: #409EFF;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.code-btn {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.loading-state, .empty-state {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.account-card.is-selected {
|
||||
border: 1px solid #409EFF;
|
||||
background-color: #f4f9ff;
|
||||
}
|
||||
|
||||
.totp-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.totp-service {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.totp-code {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: #409EFF;
|
||||
letter-spacing: 6px;
|
||||
margin: 15px 0;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
.totp-code:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.totp-timer {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.timer-text {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.totp-tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<el-card class="login-card" shadow="hover">
|
||||
<div class="logo-container">
|
||||
<el-icon :size="54" color="#409EFC"><Lock /></el-icon>
|
||||
<h2>2FAuth</h2>
|
||||
<p class="subtitle">您的专属云端双因素认证管家</p>
|
||||
</div>
|
||||
|
||||
<div class="action-container">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="github-btn"
|
||||
:loading="loading"
|
||||
@click="handleGitHubLogin"
|
||||
>
|
||||
<template #icon>
|
||||
<el-icon><Platform /></el-icon>
|
||||
</template>
|
||||
通过 GitHub 授权登录
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="footer-tips">
|
||||
<el-alert
|
||||
title="安全隐私提示"
|
||||
type="info"
|
||||
description="系统基于 OAuth 2.0 协议验证身份,绝不会获取、记录或传输您的 GitHub 密码信息。"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Lock, Platform } from '@element-plus/icons-vue'
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const handleGitHubLogin = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
// 调用后端的授权接口
|
||||
const response = await fetch('/api/oauth/authorize')
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success && data.authUrl) {
|
||||
// 记录 state 防御 CSRF(可选,后端也有校验机制)
|
||||
localStorage.setItem('oauth_state', data.state)
|
||||
// 直接把浏览器跳转到 GitHub 授权页
|
||||
window.location.href = data.authUrl
|
||||
} else {
|
||||
ElMessage.error(data.error || '获取授权链接失败')
|
||||
loading.value = false
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error)
|
||||
ElMessage.error('网络请求失败,请检查后端 API 是否正常运行')
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
/* 这里的背景色会覆盖 App.vue 的全局背景 */
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.15);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.logo-container h2 {
|
||||
margin: 16px 0 8px;
|
||||
color: #303133;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.action-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.github-btn {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
font-size: 16px;
|
||||
border-radius: 8px;
|
||||
background-color: #24292e;
|
||||
border-color: #24292e;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.github-btn:hover,
|
||||
.github-btn:focus {
|
||||
background-color: #4a4e53;
|
||||
border-color: #4a4e53;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(36, 41, 46, 0.3);
|
||||
}
|
||||
|
||||
.footer-tips {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="callback-container">
|
||||
<el-card class="loading-card" shadow="hover">
|
||||
<el-result
|
||||
v-if="!errorMsg"
|
||||
icon="info"
|
||||
title="正在安全登录中"
|
||||
sub-title="请稍候,系统正在与 GitHub 交换安全凭证..."
|
||||
>
|
||||
<template #extra>
|
||||
<el-icon class="is-loading" :size="40" color="#409eff"><Loading /></el-icon>
|
||||
</template>
|
||||
</el-result>
|
||||
|
||||
<el-result
|
||||
v-else
|
||||
icon="error"
|
||||
title="授权失败"
|
||||
:sub-title="errorMsg"
|
||||
>
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="goBackToLogin">返回登录页</el-button>
|
||||
</template>
|
||||
</el-result>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import { userState } from '../states/user'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const errorMsg = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
const code = route.query.code
|
||||
const state = route.query.state
|
||||
const error = route.query.error
|
||||
|
||||
if (error) {
|
||||
errorMsg.value = route.query.error_description || '您拒绝了 GitHub 的授权请求'
|
||||
return
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
errorMsg.value = 'URL 缺少必要的授权凭证参数'
|
||||
return
|
||||
}
|
||||
|
||||
// 🛡️ 前端 State 校验:防止 CSRF 攻击
|
||||
const savedState = localStorage.getItem('oauth_state')
|
||||
if (!savedState || savedState !== state) {
|
||||
errorMsg.value = '安全警告:State 校验失败,请求可能被篡改'
|
||||
return
|
||||
}
|
||||
localStorage.removeItem('oauth_state') // 校验通过后立即清除,防止重放
|
||||
|
||||
try {
|
||||
// 关键步骤:把 code 发给后端换取我们自己的 JWT
|
||||
const response = await fetch('/api/oauth/callback', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code, state })
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
// 登录成功!把后端发的系统门票存进浏览器
|
||||
localStorage.setItem('authToken', data.token)
|
||||
userState.setUserInfo(data.userInfo)
|
||||
|
||||
// 一把推开大门,进入主界面
|
||||
router.push('/')
|
||||
} else {
|
||||
errorMsg.value = data.error || '登录验证被后端拒绝'
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('OAuth Callback Error:', err)
|
||||
errorMsg.value = '网络请求异常,请稍后再试'
|
||||
}
|
||||
})
|
||||
|
||||
const goBackToLogin = () => {
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.callback-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.loading-card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
host: '0.0.0.0', // 兼容 Docker DevContainer
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8787', // 你的 Hono 后端地址
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user