commit aa4befa0ff17ff59534246d1e263a2867e69c0fb Author: shirainbown Date: Mon Aug 3 00:25:15 2026 +0800 feat: sharelink 资源下载管理插件 v1.1.0 - 下载资源管理与 /download/{slug} 下载链接 - 下载统计(次数/去重人数/明细/CSV 导出) - 按资源邮箱验证(Halo 通知中心发信,互认评论插件已验证邮箱) - 本地附件防直链(/upload/** 404) - 文章引用扫描 - Console 前端:Vue3 + ui-plugin-bundler-kit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a726e0a --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# ---- Gradle ---- +.gradle/ +build/ +!gradle/wrapper/gradle-wrapper.jar + +# ---- 前端(ui) ---- +node_modules/ +ui/node_modules/ +# console 产物由 `pnpm -C ui build` 生成,勿提交 +src/main/resources/console/ + +# ---- IDE ---- +.idea/ +*.iml +.vscode/ +.classpath +.project +.settings/ +bin/ + +# ---- 系统 ---- +.DS_Store + +# ---- 本地临时 ---- +workplace/ +*.log diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..78ca3e3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,39 @@ +# AGENTS.md — sharelink(Halo 资源下载管理插件) + +## 项目概述 + +Halo 2.x(>=2.22,目标实例 Halo Pro 2.25.4)插件。管理文章中的资源下载链接:按资源的下载统计、邮箱验证、防附件直链、文章引用扫描。邮箱验证体系复制改造自 `~/Documents/Code/Ai/blog-comment`(plugin-comment-widget fork 的 emailcode/guard 包)。 + +## 结构 + +- `src/main/java/run/halo/sharelink/` + - `model/` 自定义 Extension(@GVK group `sharelink.halo.run`):DownloadResource(spec.slug 唯一索引)、DownloadRecord(spec.resourceSlug 索引)、VerifiedDownloader(name=SHA-256(归一化邮箱)) + - `emailcode/` 验证码:Guava 内存缓存、限流、NotificationCenter 发信(REASON_TYPE `sharelink-download-verification`,模板在 `resources/extensions/notification.yaml`) + - `verify/` 公开端点 `api.sharelink.halo.run/v1alpha1`(email-verify/-/send|/-/check)+ VerifiedEmailService(本插件 VerifiedDownloader ∪ 评论插件 VerifiedCommenter,后者运行时 Unstructured fetch,缺席自动降级) + - `download/` RouterFunction Bean:`GET /download/{slug}`(自包含 HTML 页)、`GET /download/{slug}/file`(一次性 token 核销 → 记录 → 流式下载);FileStreamer 本地附件直接读盘(`halo.work-dir`/attachments + `storage.halo.run/local-relative-path` 注解),外部存储回环 HTTP(带 `X-Sharelink-Internal` 头) + - `protect/UploadProtectFilter` AdditionalWebFilter:GET/HEAD `/upload/**` 命中受保护附件 → 404 + - `reference/PostReferenceService` 扫描已发布文章 releaseSnapshot 内容匹配 `/download/{slug}`,5min 缓存 + - `console/ConsoleEndpoint` `console.api.sharelink.halo.run/v1alpha1`:资源 CRUD(body 为扁平 spec)、记录分页/删除/CSV、references 查询/刷新 +- `src/main/resources/extensions/` settings.yaml、notification.yaml、role-templates.yaml(匿名放行公开 API;console view/manage 角色,`ui-permissions: plugin:sharelink:view/manage`) +- `ui/` console 前端:Vue 3 + @halo-dev/ui-shared + ui-plugin-bundler-kit(rsbuild),产物输出到 `src/main/resources/console/`(**构建前会覆盖该目录,勿手改**)。API 层 `ui/src/api/index.ts` 的 `normalizeResource()` 把后端扁平 ResourceVo 转成 Halo 风格结构 + +## 构建 / 部署 + +```bash +pnpm -C ui install && pnpm -C ui build +export JAVA_HOME=~/.gradle/jdks/jdk-21.0.12+8/Contents/Home # 本机无系统 JDK +./gradlew build -x test # build/libs/sharelink-.jar +``` + +关键经验(踩过的坑): +1. 改代码后发布必须递增 `gradle.properties` 的 version——console 静态资源按 `?version=` 缓存,不递增浏览器会用旧包。 +2. Console UI 升级插件会弹「插件已存在,是否升级?」确认框,不点确定不会真正替换 jar。 +3. 插件数据存 ExtensionStore(MySQL),卸载/重装插件不丢数据;但 Halo 的插件静态资源目录在重装时才重新解压。 +4. 计数在「token 核销 + 附件可流式输出」之后才写入,下载失败不计数;免验证资源按 slug+IP 内存窗口去重。 +5. UI 安装有两条路:①「远程下载」需要 Halo 服务器能反向访问本机 jar 服务(本机换网/IP 变化后会失败);②「本地上传」用 webbridge 时 CDP `setFileInputFiles` 被浏览器禁用,可用兜底方案:分块 base64 经 `evaluate` 推到页面 `window.__jarB64` → JS 构造 `File` + `DataTransfer` → 对 `.uppy-Dashboard-inner` 派发合成 `dragenter/dragover/drop` 事件触发 Uppy 上传 → 再点升级确认框「确定」。 +6. 下载页自包含 HTML 里若给元素自定义了 `display`(如 flex),必须补 `[hidden] { display: none !important; }`,否则 `hidden` 属性失效导致元素误显示。 + +## 测试 + +端到端验证依赖目标实例(http://192.168.3.2:8090)。公开 API 可用 curl 匿名测试: +`POST /apis/api.sharelink.halo.run/v1alpha1/downloads/-/token {"slug":"..."}` → `GET /download/{slug}/file?token=...`。 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is 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. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + 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. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + 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 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. Use with the GNU Affero General Public License. + + 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 Affero 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 special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU 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 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 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 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. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + 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 GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bc28e7e --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# sharelink — Halo 资源下载管理插件 + +管理文章中的资源下载链接:下载统计、按资源的邮箱验证、防止附件直链下载、文章引用扫描。 + +📖 **[详细配置指南与使用说明(含截图)](docs/使用指南.md)** + +## 功能 + +- **下载资源管理**(Console「内容 → 下载管理」):选择附件创建下载资源,生成 `/download/{slug}` 下载链接,复制后粘贴到文章即可。 +- **下载统计**:每个资源的下载次数、去重下载人数、详细下载记录(时间/邮箱/IP/UA),支持 CSV 导出。 +- **邮箱验证**:按资源开关。访客输入邮箱 → 收验证码(走 Halo 通知中心,复用站点 SMTP)→ 验证后下载。已验证邮箱持久化,后续免验证;开启「信任评论插件已验证的邮箱」后,与评论组件(plugin-comment-widget fork)的已验证邮箱互认。 +- **防直链**:注册为资源的本地附件,其 `/upload/**` 直链对外返回 404;文件由插件从磁盘流式输出,URL 不暴露。 +- **文章引用扫描**:扫描已发布文章内容,展示每个资源被哪些文章引用(可跳转编辑/访问),5 分钟缓存 + 手动刷新。 + +## 工作原理(简) + +- 访客流程:`/download/{slug}`(自包含 HTML 页)→(可选邮箱验证)→ POST `downloads/-/token` 换一次性 token(默认 60s 有效)→ `/download/{slug}/file?token=` 核销并流式下载、记录计数。 +- 防直链:`AdditionalWebFilter` 拦截 GET/HEAD `/upload/**`,命中受保护附件 permalink 集合返回 404。 +- 免验证资源:同一 IP 在去重窗口(默认 10 分钟)内重复下载只计 1 次。 + +## 构建 + +```bash +cd ui && pnpm install && pnpm build # 前端产物输出到 src/main/resources/console +cd .. && ./gradlew build -x test # 产出 build/libs/sharelink-.jar +``` + +- 无本地 JDK 时:`settings.gradle` 已配 foojay-resolver 自动下载 JDK 21 到 `~/.gradle`。 +- 前端独立构建(不集成进 gradle),改前端后记得先 `pnpm -C ui build` 再打 jar。 + +## 部署 / 升级注意事项 + +1. Console「插件 → 安装 → 远程下载/本地上传」安装 jar。 +2. **升级已安装的插件时**,UI 会弹「插件已存在,是否升级?」确认框,必须点「确定」才会真正替换。 +3. **前端静态资源按 `?version=` 缓存**:发布新版本务必递增 `gradle.properties` 的 `version`,否则浏览器会沿用旧 console 包。 +4. 升级后如行为未变,停用再启用插件强制重启。 + +## 使用注意 + +- 不要把文章内需要直接显示的图片注册为下载资源(其 `/upload/` 直链会被 404,图片将无法显示)。 +- 外部对象存储(S3 等)附件无法拦截直链(防直链仅对本地存储策略生效);此类附件走回环 HTTP 转发下载。 +- 邮箱验证依赖「设置 → 通知设置」中已配置的邮件通知器。 + +## 设置项 + +| 组 | 项 | 默认 | +|---|---|---| +| 基本设置 | 下载令牌有效期(秒) | 60 | +| 基本设置 | 免验证资源下载去重窗口(分钟) | 10 | +| 邮箱验证 | 验证码有效期(分钟) | 10 | +| 邮箱验证 | 重发间隔(秒) | 60 | +| 邮箱验证 | 同一邮箱每日发送上限 | 5 | +| 邮箱验证 | 验证码最大错误尝试次数 | 5 | +| 邮箱验证 | 同一 IP 每小时发送上限 | 20 | +| 邮箱验证 | 信任评论插件已验证的邮箱 | 开 | diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..fdbc627 --- /dev/null +++ b/build.gradle @@ -0,0 +1,44 @@ +plugins { + id 'java' + id "io.freefair.lombok" version "8.14" + id "run.halo.plugin.devtools" version "0.6.2" +} + +group 'run.halo.sharelink' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = "UTF-8" + options.release = 21 +} + +repositories { + mavenCentral() +} + +dependencies { + implementation platform('run.halo.tools.platform:plugin:2.21.0') + compileOnly 'run.halo.app:api' + + implementation 'com.google.guava:guava:33.4.8-jre' + + testImplementation 'run.halo.app:api' + testImplementation 'org.springframework.boot:spring-boot-starter-test' +} + +test { + useJUnitPlatform() +} + +// 前端单独构建:cd ui && pnpm install && pnpm build(产物输出到 src/main/resources/console) +// 见 ui/README.md。不在 gradle 中集成 node,避免重复下载 node 运行时。 + +halo { + version = "2.21.7" + debug = true +} diff --git a/docs/images/01-console-list.png b/docs/images/01-console-list.png new file mode 100644 index 0000000..93e0c3f Binary files /dev/null and b/docs/images/01-console-list.png differ diff --git a/docs/images/02-console-create-modal.png b/docs/images/02-console-create-modal.png new file mode 100644 index 0000000..7886f21 Binary files /dev/null and b/docs/images/02-console-create-modal.png differ diff --git a/docs/images/03-console-records.png b/docs/images/03-console-records.png new file mode 100644 index 0000000..7f6d7b0 Binary files /dev/null and b/docs/images/03-console-records.png differ diff --git a/docs/images/03b-console-records-email.png b/docs/images/03b-console-records-email.png new file mode 100644 index 0000000..714c45a Binary files /dev/null and b/docs/images/03b-console-records-email.png differ diff --git a/docs/images/04-console-references.png b/docs/images/04-console-references.png new file mode 100644 index 0000000..7badaf2 Binary files /dev/null and b/docs/images/04-console-references.png differ diff --git a/docs/images/05-plugin-settings.png b/docs/images/05-plugin-settings.png new file mode 100644 index 0000000..1bbf3cb Binary files /dev/null and b/docs/images/05-plugin-settings.png differ diff --git a/docs/images/06-plugin-settings-email.png b/docs/images/06-plugin-settings-email.png new file mode 100644 index 0000000..b162e0e Binary files /dev/null and b/docs/images/06-plugin-settings-email.png differ diff --git a/docs/images/11-download-page-free.png b/docs/images/11-download-page-free.png new file mode 100644 index 0000000..9fcc939 Binary files /dev/null and b/docs/images/11-download-page-free.png differ diff --git a/docs/images/12-download-page-verify-sent.png b/docs/images/12-download-page-verify-sent.png new file mode 100644 index 0000000..6303917 Binary files /dev/null and b/docs/images/12-download-page-verify-sent.png differ diff --git a/docs/images/13-download-page-verified.png b/docs/images/13-download-page-verified.png new file mode 100644 index 0000000..4f8246c Binary files /dev/null and b/docs/images/13-download-page-verified.png differ diff --git a/docs/使用指南.md b/docs/使用指南.md new file mode 100644 index 0000000..bf19a54 --- /dev/null +++ b/docs/使用指南.md @@ -0,0 +1,257 @@ +# 资源下载管理(sharelink)插件 · 配置指南与使用说明 + +> 适用版本:sharelink 1.0.x / Halo ≥ 2.22(已在 Halo Pro 2.25.4 实测) +> +> 本文配合截图说明插件的功能、配置方法、使用流程和注意事项。 + +--- + +## 目录 + +1. [插件是做什么的](#一插件是做什么的) +2. [安装与启用](#二安装与启用) +3. [全局配置](#三全局配置) +4. [日常使用:创建下载资源](#四日常使用创建下载资源) +5. [访客看到的下载页](#五访客看到的下载页) +6. [下载统计与记录](#六下载统计与记录) +7. [文章引用扫描](#七文章引用扫描) +8. [邮箱验证详解](#八邮箱验证详解) +9. [防直链原理与边界](#九防直链原理与边界) +10. [注意事项汇总](#十注意事项汇总) +11. [常见问题 FAQ](#十一常见问题-faq) + +--- + +## 一、插件是做什么的 + +Halo 默认的附件可以通过 `/upload/文件名` 直链被任何人直接下载,无法统计、无法设防。本插件把「资源下载」变成可管理的一等公民: + +| 能力 | 说明 | +|---|---| +| **下载链接管理** | 为每个资源生成 `/download/{slug}` 链接,粘贴到文章即可 | +| **下载统计** | 每个资源的下载次数、去重下载人数、逐条下载记录(时间/邮箱/IP/UA),可导出 CSV | +| **邮箱验证** | 按资源开关。访客需输入邮箱收取验证码,验证通过才能下载;验证过的邮箱长期免验证 | +| **防直链** | 注册为资源的附件,`/upload/**` 直链对外直接 404,文件真实地址不暴露 | +| **文章引用扫描** | 一键扫描全站文章,告诉你每个下载资源被哪些文章引用 | + +### 工作流程(一图流) + +``` +文章中粘贴 /download/xxx 链接 + │ + ▼ +访客打开下载页(插件生成的独立页面) + │ + ├─ 不需要验证:点击「立即下载」 + └─ 需要验证:输入邮箱 → 收验证码 → 填验证码 + │ + ▼ +换取一次性下载令牌(默认 60 秒有效)→ 开始下载 + │ + ▼ +后台记录一次下载(次数 +1,写入记录) +``` + +--- + +## 二、安装与启用 + +1. 进入 **Console → 系统 → 插件**,点击右上角 **「安装」**。 +2. 选择 **「本地上传」** 上传 `sharelink-x.y.z.jar`(或「远程下载」粘贴 jar 地址)。 +3. 安装后在插件列表找到 **「资源下载管理」**,点击启用。 +4. 启用成功后,左侧菜单 **「内容」** 分组下会出现 **「下载管理」** 入口。 + +> ⚠️ **升级插件时的两个坑**(Halo 通用行为,非本插件问题): +> - 用同名单 jar 覆盖安装时,会弹出「插件已存在,是否升级?」确认框,**必须点「确定」** 才会真正替换。 +> - 后台界面静态资源按 `?version=` 缓存。**每次发布新版本必须递增版本号**,否则浏览器会继续使用旧界面。 + +--- + +## 三、全局配置 + +进入 **系统 → 插件 → 资源下载管理 → 设置**,有两个标签页。 + +### 3.1 基本设置 + +![基本设置](images/05-plugin-settings.png) + +| 配置项 | 默认值 | 说明 | +|---|---|---| +| **下载令牌有效期(秒)** | 60 | 访客在下载页点击下载后,插件签发一次性下载令牌,需在此时长内开始下载。过期或已使用的令牌会跳回下载页重新获取,防止下载地址被传播复用 | +| **免验证资源下载去重窗口(分钟)** | 10 | 不需要邮箱验证的资源,同一 IP 在窗口内重复下载只计 1 次(防止刷新刷量),但下载行为本身不受限 | + +### 3.2 邮箱验证 + +![邮箱验证设置](images/06-plugin-settings-email.png) + +| 配置项 | 默认值 | 说明 | +|---|---|---| +| **验证码有效期(分钟)** | 10 | 验证码超过该时长未使用即失效 | +| **重发间隔(秒)** | 60 | 同一邮箱两次发码的最小间隔(下载页按钮有倒计时) | +| **同一邮箱每日发送上限** | 5 | 防止针对单个邮箱的轰炸 | +| **验证码最大错误尝试次数** | 5 | 连续输错超过该次数,验证码作废,需重新获取 | +| **同一 IP 每小时发送上限** | 20 | 防止单个 IP 批量发码 | +| **信任评论插件已验证的邮箱** | 开启 | 开启后,在评论组件中已验证过邮箱的访客,下载需要验证的资源时**无需再次验证**(需安装评论组件插件) | + +> ⚠️ 邮箱验证依赖 Halo 的邮件通知能力。请确认 **设置 → 通知设置** 中已配置可用的邮件通知器(SMTP),否则验证码邮件发不出去。你的站点配置评论插件时应该已经配好。 + +--- + +## 四、日常使用:创建下载资源 + +进入 **内容 → 下载管理**,这里是所有下载资源的统一管理中心。 + +![下载管理列表](images/01-console-list.png) + +### 4.1 新建资源 + +点击右上角 **「新建资源」**: + +![新建资源弹窗](images/02-console-create-modal.png) + +| 字段 | 说明 | +|---|---| +| **资源名称** * | 显示给访客的名称,也是下载文件的文件名(无扩展名时自动补上附件原扩展名) | +| **slug** * | 下载链接的标识,如填 `whitepaper-2024`,下载链接就是 `/download/whitepaper-2024`。仅限小写字母、数字、中划线。**创建后不可修改** | +| **描述** | 可选,显示在下载页标题下方 | +| **附件** | 点击「选择附件」从附件库选择(也可先上传新附件) | +| **需要邮箱验证** | 开启后访客必须验证邮箱才能下载 | +| **启用** | 停用后下载页和下载链接立即 404,但配置保留 | + +保存后回到列表,点击该行的 **「复制」** 按钮即可拿到完整下载链接(含域名),粘贴到文章的任意位置(普通链接、按钮、卡片都可以)。 + +### 4.2 列表各列含义 + +- **邮箱验证**:该资源是否需要验证(需要 / 不需要) +- **启用**:开关即改即存 +- **引用文章**:引用该资源下载链接的文章数,点击展开详情(见第七节) +- **下载数 / 下载人数**:累计下载次数 / 去重后的下载人数(验证资源按邮箱去重,免验证资源按 IP 去重) +- **操作**:记录(下载记录)、编辑、删除 + +> ⚠️ 删除资源会**连同其全部下载记录一起删除**,且下载链接立即失效,请谨慎操作。 + +--- + +## 五、访客看到的下载页 + +### 5.1 免验证资源 + +![免验证下载页](images/11-download-page-free.png) + +页面展示资源名称和描述,点击 **「立即下载」** 即开始下载。简单直接。 + +### 5.2 需要邮箱验证的资源 + +![需验证下载页](images/12-download-page-verify-sent.png) + +1. 输入邮箱地址; +2. 点击 **「发送验证码」**,按钮进入 60 秒倒计时,页面提示「验证码已发送,请查收邮件(10 分钟内有效)」; +3. 将邮件中的 6 位验证码填入,点击 **「立即下载」**。 + +**已验证过的邮箱**:下次再访问任何需要验证的资源时,输入邮箱后页面会提示「该邮箱已完成验证,可直接下载」,无需验证码(包括在评论区验证过的邮箱): + +![已验证邮箱直接下载](images/13-download-page-verified.png) + +> 下载页为插件自带的独立页面,不依赖主题,任何主题下表现一致。 + +--- + +## 六、下载统计与记录 + +在资源列表点击 **「记录」**,打开该资源的下载记录: + +![下载记录](images/03-console-records.png) + +- 每条记录包含:**时间、邮箱**(免验证资源显示「匿名」)、**IP、User-Agent**; +- 支持分页浏览、删除单条记录; +- 点击 **「导出 CSV」** 可下载全部记录(带 BOM,Excel 直接打开不乱码)。 + +需要邮箱验证的资源,记录中可以看到具体是哪个邮箱下载的: + +![验证资源的下载记录](images/03b-console-records-email.png) + +**计数口径**(重要,避免误解数字): + +- 只有**真正开始下载**才计数:令牌核销成功且文件可输出时记 1 次,下载页浏览不计数、失败不计数; +- 免验证资源:同一 IP 在去重窗口(默认 10 分钟)内重复下载只计 1 次; +- 需要验证的资源:每次换令牌下载都计数(同一人多次下载会体现为次数 > 人数)。 + +--- + +## 七、文章引用扫描 + +资源多了之后最容易遇到的问题:「这个资源到底在哪些文章里用过?删了会不会有文章变死链?」 + +列表的 **「引用文章」** 列给出答案。点击数字展开: + +![引用文章展开](images/04-console-references.png) + +- 显示每篇引用文章的标题,点击 **「编辑器」** 直接跳转到文章编辑页,**「访问」** 打开前台文章页; +- 扫描结果缓存 5 分钟。刚改完文章想立即看到最新结果,点击页面顶部的 **「刷新引用扫描」** 强制重扫。 + +**扫描口径**:只统计**已发布**文章的正式内容中出现的 `/download/{slug}`;草稿、回收站文章、历史快照不计入。 + +--- + +## 八、邮箱验证详解 + +### 8.1 验证状态的来龙去脉 + +- 验证通过后,邮箱会被**永久登记**为「已验证」,之后下载任何需要验证的资源都无需再验证; +- 「已验证邮箱」有两个来源(可在设置中关掉第二个): + 1. **本插件验证过的**:在下载页完成验证码验证的邮箱; + 2. **评论插件验证过的**(互认):评论组件中「已验证邮箱」名单里的访客。开启「信任评论插件已验证的邮箱」后,在评论区验证过的读者下载时直接免验证,体验无缝。 + +### 8.2 安全设计(了解即可) + +- 验证码 6 位数字,10 分钟有效,一次性使用,连续输错 5 次作废; +- 验证码比对使用恒时比较,防时序攻击; +- 发码有三级限流:同邮箱 60 秒重发间隔、同邮箱每天 5 封、同 IP 每小时 20 封; +- 邮件通过 Halo 通知中心发出,复用站点已有 SMTP 配置,插件不直接接触邮件密码。 + +--- + +## 九、防直链原理与边界 + +**原理**:插件在请求层面拦截 `GET/HEAD /upload/**`,如果目标附件已被注册为「启用的下载资源」,对外直接返回 404(就像文件不存在一样);访客只能通过 `/download/{slug}` 下载页获取文件——文件由插件直接从服务器磁盘流式输出,**真实存储路径全程不暴露**。 + +**边界(务必了解)**: + +| 场景 | 是否生效 | +|---|---| +| 本地存储策略的附件(你当前的使用方式) | ✅ 直链 404 | +| 外部对象存储(S3/OSS 等)的附件 | ❌ 无法拦截(文件不经 Halo 发出),但下载统计和邮箱验证仍正常 | +| 同一附件被文章当图片直接引用 | ⚠️ 会被一并拦截,图片变 404! | + +> ⚠️ **最重要的使用纪律**:不要把文章中需要**直接显示**的图片/附件注册为下载资源。注册即等于"此文件只能经下载页获取"。 + +--- + +## 十、注意事项汇总 + +1. **图片与下载资源分离**:需要直接展示的图片不要注册为资源;建议下载类文件(zip/pdf 等)单独建一个附件分组管理。 +2. **slug 创建后不可改**:改 slug 等于换链接,旧链接立即失效。如确需更换,新建资源并在文章里换链接(可用引用扫描找出所有旧链接位置)。 +3. **删除资源会级联删除下载记录**。 +4. **令牌有效期短是特性**:把 `/download/xxx/file?token=...` 发给别人是无用的(60 秒 + 一次性),分享请分享 `/download/xxx` 页面链接。 +5. **大文件**:文件经 Halo 应用中转流式输出,不占内存,但占用服务器带宽;GB 级大文件建议评估带宽。 +6. **引用扫描是按需的**:不实时监听文章变更,改完文章点「刷新引用扫描」或等 5 分钟缓存过期。 +7. **升级插件**:确认弹窗要点「确定」;版本号每次递增;升级后界面异常先强刷浏览器。 + +--- + +## 十一、常见问题 FAQ + +**Q:访客点下载没反应?** +A:下载令牌默认 60 秒有效,网络慢导致跳转超时时会自动回到下载页,重新点击即可。 + +**Q:收不到验证码邮件?** +A:① 检查 设置 → 通知设置 的 SMTP 是否可用(评论验证能用即正常);② 检查垃圾邮件;③ 同一邮箱每天最多发 5 封、同一 IP 每小时 20 封,超限会提示稍后再试。 + +**Q:为什么图片附件注册成资源后文章里图片裂了?** +A:这是防直链的预期行为。把该图片从资源中移除(删除资源或换用专用下载文件),图片即可恢复显示。 + +**Q:下载数为什么比下载人数多?** +A:正常。同一个人多次下载,次数累加、人数去重。 + +**Q:对象存储附件能防直链吗?** +A:不能。防直链只对本地存储策略生效;但统计与邮箱验证对所有存储策略都有效。 diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..de652db --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +version=1.1.0 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..8bdaf60 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..264fad7 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-9.0.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..ef07e01 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..4f78965 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,5 @@ +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0' +} + +rootProject.name = 'sharelink' diff --git a/src/main/java/run/halo/sharelink/SharelinkPlugin.java b/src/main/java/run/halo/sharelink/SharelinkPlugin.java new file mode 100644 index 0000000..4fb56df --- /dev/null +++ b/src/main/java/run/halo/sharelink/SharelinkPlugin.java @@ -0,0 +1,53 @@ +package run.halo.sharelink; + +import static run.halo.app.extension.index.IndexAttributeFactory.simpleAttribute; + +import org.springframework.stereotype.Component; +import run.halo.app.extension.Scheme; +import run.halo.app.extension.SchemeManager; +import run.halo.app.extension.index.IndexSpec; +import run.halo.app.plugin.BasePlugin; +import run.halo.app.plugin.PluginContext; +import run.halo.sharelink.model.DownloadRecord; +import run.halo.sharelink.model.DownloadResource; +import run.halo.sharelink.model.VerifiedDownloader; + +@Component +public class SharelinkPlugin extends BasePlugin { + + private final SchemeManager schemeManager; + + public SharelinkPlugin(PluginContext pluginContext, SchemeManager schemeManager) { + super(pluginContext); + this.schemeManager = schemeManager; + } + + @Override + public void start() { + schemeManager.register(DownloadResource.class, indexSpecs -> indexSpecs.add( + new IndexSpec() + .setName("spec.slug") + .setUnique(true) + .setIndexFunc(simpleAttribute(DownloadResource.class, + resource -> resource.getSpec() == null ? null : resource.getSpec().getSlug())))); + schemeManager.register(DownloadRecord.class, indexSpecs -> { + indexSpecs.add(new IndexSpec() + .setName("spec.resourceSlug") + .setIndexFunc(simpleAttribute(DownloadRecord.class, + record -> record.getSpec() == null ? null : record.getSpec().getResourceSlug()))); + indexSpecs.add(new IndexSpec() + .setName("spec.downloadedAt") + .setIndexFunc(simpleAttribute(DownloadRecord.class, + record -> record.getSpec() == null || record.getSpec().getDownloadedAt() == null + ? null : record.getSpec().getDownloadedAt().toString()))); + }); + schemeManager.register(VerifiedDownloader.class); + } + + @Override + public void stop() { + schemeManager.unregister(Scheme.buildFromType(DownloadResource.class)); + schemeManager.unregister(Scheme.buildFromType(DownloadRecord.class)); + schemeManager.unregister(Scheme.buildFromType(VerifiedDownloader.class)); + } +} diff --git a/src/main/java/run/halo/sharelink/SharelinkSettingConfigGetter.java b/src/main/java/run/halo/sharelink/SharelinkSettingConfigGetter.java new file mode 100644 index 0000000..f629dd4 --- /dev/null +++ b/src/main/java/run/halo/sharelink/SharelinkSettingConfigGetter.java @@ -0,0 +1,54 @@ +package run.halo.sharelink; + +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; +import run.halo.app.plugin.ReactiveSettingFetcher; + +/** + * Typed access to this plugin's setting groups ({@code basic} and {@code emailVerify}). + * Every getter falls back to built-in defaults and never returns {@link Mono#empty()}. + */ +@Component +@RequiredArgsConstructor +public class SharelinkSettingConfigGetter { + + private final ReactiveSettingFetcher settingFetcher; + + public Mono getBasicConfig() { + return settingFetcher.fetch(BasicConfig.GROUP, BasicConfig.class) + .defaultIfEmpty(new BasicConfig()); + } + + public Mono getEmailVerifyConfig() { + return settingFetcher.fetch(EmailVerifyConfig.GROUP, EmailVerifyConfig.class) + .defaultIfEmpty(new EmailVerifyConfig()); + } + + @Data + public static class BasicConfig { + public static final String GROUP = "basic"; + + private int tokenTtlSeconds = 60; + + private int dedupeWindowMinutes = 10; + } + + @Data + public static class EmailVerifyConfig { + public static final String GROUP = "emailVerify"; + + private int codeExpireMinutes = 10; + + private int resendIntervalSeconds = 60; + + private int dailySendLimitPerEmail = 5; + + private int maxVerifyAttempts = 5; + + private int ipHourlySendLimit = 20; + + private boolean trustCommentVerified = true; + } +} diff --git a/src/main/java/run/halo/sharelink/console/ConsoleEndpoint.java b/src/main/java/run/halo/sharelink/console/ConsoleEndpoint.java new file mode 100644 index 0000000..6eabf00 --- /dev/null +++ b/src/main/java/run/halo/sharelink/console/ConsoleEndpoint.java @@ -0,0 +1,394 @@ +package run.halo.sharelink.console; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.regex.Pattern; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.reactive.function.server.ServerResponse; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.server.ServerWebInputException; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.attachment.Attachment; +import run.halo.app.core.extension.endpoint.CustomEndpoint; +import run.halo.app.extension.GroupVersion; +import run.halo.app.extension.ListOptions; +import run.halo.app.extension.ListResult; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.PageRequestImpl; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.extension.index.query.QueryFactory; +import run.halo.sharelink.model.DownloadRecord; +import run.halo.sharelink.model.DownloadResource; +import run.halo.sharelink.protect.UploadProtectFilter; +import run.halo.sharelink.reference.PostReferenceService; + +/** + * Console endpoints for download resource administration, mounted under + * {@code /apis/console.api.sharelink.halo.run/v1alpha1}, so authentication and RBAC are + * enforced by Halo. + */ +@Component +@RequiredArgsConstructor +public class ConsoleEndpoint implements CustomEndpoint { + + private static final Pattern SLUG_PATTERN = Pattern.compile("[a-z0-9][a-z0-9-]{0,63}"); + private static final int STATS_CONCURRENCY = 8; + private static final int MAX_PAGE_SIZE = 200; + private static final MediaType CSV_MEDIA_TYPE = + MediaType.parseMediaType("text/csv; charset=UTF-8"); + private static final String CSV_DISPOSITION = + "attachment; filename=\"download-records.csv\""; + /** + * BOM prefix so spreadsheet applications detect the UTF-8 encoding. + */ + private static final String CSV_BOM = "\uFEFF"; + + private final ReactiveExtensionClient client; + private final PostReferenceService referenceService; + private final UploadProtectFilter uploadProtectFilter; + + @Override + public RouterFunction endpoint() { + return RouterFunctions.route() + .GET("download-resources", this::listResources) + .POST("download-resources", this::createResource) + .GET("download-resources/{name}", this::getResource) + .PUT("download-resources/{name}", this::updateResource) + .DELETE("download-resources/{name}", this::deleteResource) + .GET("download-records", this::listRecords) + .DELETE("download-records/{name}", this::deleteRecord) + .GET("download-records/-/export", this::exportRecords) + .GET("references", this::getReferences) + .POST("references/-/refresh", this::refreshReferences) + .build(); + } + + // ---------- resources ---------- + + private Mono listResources(ServerRequest request) { + var references = referenceService.references() + .onErrorResume(e -> Mono.just(Map.>of())); + return references.flatMap(refMap -> client.listAll(DownloadResource.class, + new ListOptions(), Sort.unsorted()) + .sort(Comparator.comparing(ConsoleEndpoint::creationTimestamp, + Comparator.nullsLast(Comparator.reverseOrder()))) + .flatMap(resource -> toVo(resource, refMap), STATS_CONCURRENCY) + .collectList()) + .flatMap(vos -> ServerResponse.ok().bodyValue(vos)); + } + + private Mono createResource(ServerRequest request) { + return request.bodyToMono(ResourceRequest.class) + .switchIfEmpty(Mono.error(new ServerWebInputException("请求体不能为空"))) + .flatMap(body -> validateResourceRequest(body, null) + .then(Mono.defer(() -> { + var resource = new DownloadResource(); + var metadata = new Metadata(); + metadata.setName(UUID.randomUUID().toString()); + resource.setMetadata(metadata); + var spec = new DownloadResource.Spec(); + applyRequest(spec, body); + resource.setSpec(spec); + var status = new DownloadResource.Status(); + status.setDownloadCount(0); + resource.setStatus(status); + return client.create(resource); + }))) + .doOnSuccess(created -> uploadProtectFilter.invalidate()) + .flatMap(this::toVo) + .flatMap(vo -> ServerResponse.ok().bodyValue(vo)); + } + + private Mono getResource(ServerRequest request) { + return client.fetch(DownloadResource.class, request.pathVariable("name")) + .switchIfEmpty(Mono.error(new ResponseStatusException( + HttpStatus.NOT_FOUND, "下载资源不存在"))) + .flatMap(this::toVo) + .flatMap(vo -> ServerResponse.ok().bodyValue(vo)); + } + + private Mono updateResource(ServerRequest request) { + var name = request.pathVariable("name"); + return request.bodyToMono(ResourceRequest.class) + .switchIfEmpty(Mono.error(new ServerWebInputException("请求体不能为空"))) + .flatMap(body -> client.fetch(DownloadResource.class, name) + .switchIfEmpty(Mono.error(new ResponseStatusException( + HttpStatus.NOT_FOUND, "下载资源不存在"))) + .flatMap(resource -> validateResourceRequest(body, name) + .then(Mono.defer(() -> { + applyRequest(resource.getSpec(), body); + return client.update(resource); + })))) + .doOnSuccess(updated -> uploadProtectFilter.invalidate()) + .flatMap(this::toVo) + .flatMap(vo -> ServerResponse.ok().bodyValue(vo)); + } + + private Mono deleteResource(ServerRequest request) { + var name = request.pathVariable("name"); + return client.fetch(DownloadResource.class, name) + .switchIfEmpty(Mono.error(new ResponseStatusException( + HttpStatus.NOT_FOUND, "下载资源不存在"))) + .flatMap(resource -> { + var slug = resource.getSpec().getSlug(); + return client.delete(resource) + .then(deleteRecordsOfSlug(slug)); + }) + .doOnSuccess(ignored -> uploadProtectFilter.invalidate()) + .then(ServerResponse.ok().build()); + } + + private Mono deleteRecordsOfSlug(String slug) { + if (StringUtils.isBlank(slug)) { + return Mono.empty(); + } + return recordsOfSlug(slug) + .flatMap(client::delete) + .then(); + } + + /** + * Validates slug format/uniqueness and attachment existence. + * + * @param selfName the name of the resource being updated, excluded from the + * uniqueness check; {@code null} on creation + */ + private Mono validateResourceRequest(ResourceRequest body, String selfName) { + var slug = StringUtils.trimToNull(body.slug()); + if (slug == null || !SLUG_PATTERN.matcher(slug).matches()) { + return Mono.error(new ServerWebInputException( + "slug 格式不正确,需匹配 [a-z0-9][a-z0-9-]{0,63}")); + } + if (StringUtils.isBlank(body.attachmentName())) { + return Mono.error(new ServerWebInputException("附件不能为空")); + } + var slugAvailable = findBySlug(slug) + .filter(existing -> selfName == null + || !selfName.equals(existing.getMetadata().getName())) + .flatMap(existing -> Mono.error(new ResponseStatusException( + HttpStatus.CONFLICT, "slug 已被其他资源占用"))) + .then(); + var attachmentExists = client.fetch(Attachment.class, body.attachmentName()) + .switchIfEmpty(Mono.error(new ServerWebInputException("附件不存在"))) + .then(); + return Mono.when(slugAvailable, attachmentExists); + } + + private static void applyRequest(DownloadResource.Spec spec, ResourceRequest body) { + spec.setSlug(body.slug().trim()); + spec.setDisplayName(StringUtils.defaultIfBlank( + StringUtils.trimToNull(body.displayName()), spec.getSlug())); + spec.setDescription(StringUtils.trimToNull(body.description())); + spec.setAttachmentName(body.attachmentName().trim()); + spec.setRequireEmailVerify(body.requireEmailVerify()); + spec.setEnabled(body.enabled()); + } + + // ---------- records ---------- + + private Mono listRecords(ServerRequest request) { + var resourceSlug = request.queryParam("resourceSlug") + .map(StringUtils::trimToNull) + .orElse(null); + var page = parsePositiveInt(request, "page", 1); + var size = Math.min(parsePositiveInt(request, "size", 20), MAX_PAGE_SIZE); + var optionsBuilder = ListOptions.builder(); + if (resourceSlug != null) { + optionsBuilder.fieldQuery(QueryFactory.equal("spec.resourceSlug", resourceSlug)); + } + var pageRequest = PageRequestImpl.of(page, size, + Sort.by(Sort.Direction.DESC, "spec.downloadedAt")); + return client.listBy(DownloadRecord.class, optionsBuilder.build(), pageRequest) + .flatMap(result -> ServerResponse.ok().bodyValue(result)); + } + + private Mono deleteRecord(ServerRequest request) { + return client.fetch(DownloadRecord.class, request.pathVariable("name")) + .switchIfEmpty(Mono.error(new ResponseStatusException( + HttpStatus.NOT_FOUND, "下载记录不存在"))) + .flatMap(client::delete) + .then(ServerResponse.ok().build()); + } + + private Mono exportRecords(ServerRequest request) { + var resourceSlug = request.queryParam("resourceSlug") + .map(StringUtils::trimToNull) + .orElse(null); + var records = resourceSlug == null + ? client.listAll(DownloadRecord.class, new ListOptions(), Sort.unsorted()) + : recordsOfSlug(resourceSlug); + return records + .sort(Comparator.comparing(ConsoleEndpoint::downloadedAt, + Comparator.nullsLast(Comparator.reverseOrder()))) + .collectList() + .map(ConsoleEndpoint::toCsv) + .flatMap(csv -> ServerResponse.ok() + .contentType(CSV_MEDIA_TYPE) + .header(HttpHeaders.CONTENT_DISPOSITION, CSV_DISPOSITION) + .bodyValue(csv.getBytes(StandardCharsets.UTF_8))); + } + + // ---------- references ---------- + + private Mono getReferences(ServerRequest request) { + return referenceService.references() + .flatMap(refs -> ServerResponse.ok().bodyValue(refs)); + } + + private Mono refreshReferences(ServerRequest request) { + return referenceService.refresh() + .flatMap(refs -> ServerResponse.ok().bodyValue(refs)); + } + + // ---------- helpers ---------- + + private Mono toVo(DownloadResource resource) { + return referenceService.references() + .onErrorResume(e -> Mono.just(Map.of())) + .flatMap(refMap -> toVo(resource, refMap)); + } + + private Mono toVo(DownloadResource resource, + Map> refMap) { + var spec = resource.getSpec(); + var slug = spec == null ? null : spec.getSlug(); + var records = StringUtils.isBlank(slug) + ? Mono.just(List.of()) + : recordsOfSlug(slug).collectList(); + return records.map(recordList -> { + var identities = new LinkedHashSet(); + for (var record : recordList) { + var recordSpec = record.getSpec(); + if (recordSpec == null) { + continue; + } + if (StringUtils.isNotBlank(recordSpec.getEmail())) { + identities.add("e:" + recordSpec.getEmail()); + } else if (StringUtils.isNotBlank(recordSpec.getIp())) { + identities.add("i:" + recordSpec.getIp()); + } + } + var referenceCount = slug == null ? 0 + : refMap.getOrDefault(slug, List.of()).size(); + var stats = new ResourceStats(recordList.size(), identities.size(), + referenceCount); + return ResourceVo.from(resource, stats); + }); + } + + private Flux recordsOfSlug(String slug) { + var options = ListOptions.builder() + .fieldQuery(QueryFactory.equal("spec.resourceSlug", slug)) + .build(); + return client.listAll(DownloadRecord.class, options, Sort.unsorted()); + } + + private Mono findBySlug(String slug) { + var options = ListOptions.builder() + .fieldQuery(QueryFactory.equal("spec.slug", slug)) + .build(); + return client.listAll(DownloadResource.class, options, Sort.unsorted()).next(); + } + + private static int parsePositiveInt(ServerRequest request, String name, int fallback) { + return request.queryParam(name) + .map(value -> { + try { + return Math.max(1, Integer.parseInt(value.trim())); + } catch (NumberFormatException e) { + return fallback; + } + }) + .orElse(fallback); + } + + private static Instant creationTimestamp(DownloadResource resource) { + return resource.getMetadata() == null ? null + : resource.getMetadata().getCreationTimestamp(); + } + + private static Instant downloadedAt(DownloadRecord record) { + return record.getSpec() == null ? null : record.getSpec().getDownloadedAt(); + } + + private static String toCsv(List records) { + var csv = new StringBuilder(CSV_BOM); + csv.append("下载时间,资源Slug,邮箱,IP,User-Agent"); + for (var record : records) { + var spec = record.getSpec(); + csv.append('\n') + .append(csvField(instantText(downloadedAt(record)))).append(',') + .append(csvField(spec == null ? null : spec.getResourceSlug())).append(',') + .append(csvField(spec == null ? null : spec.getEmail())).append(',') + .append(csvField(spec == null ? null : spec.getIp())).append(',') + .append(csvField(spec == null ? null : spec.getUserAgent())); + } + return csv.toString(); + } + + private static String instantText(Instant instant) { + return instant == null ? "" : instant.toString(); + } + + private static String csvField(String value) { + if (value == null) { + return ""; + } + if (value.contains(",") || value.contains("\"") || value.contains("\n") + || value.contains("\r")) { + return '"' + value.replace("\"", "\"\"") + '"'; + } + return value; + } + + @Override + public GroupVersion groupVersion() { + return GroupVersion.parseAPIVersion("console.api.sharelink.halo.run/v1alpha1"); + } + + public record ResourceRequest(String slug, String displayName, String description, + String attachmentName, boolean requireEmailVerify, + boolean enabled) { + } + + public record ResourceStats(long downloadCount, long downloaderCount, + long referenceCount) { + } + + public record ResourceVo(String name, String slug, String displayName, + String description, String attachmentName, + boolean requireEmailVerify, boolean enabled, + String downloadUrl, Instant creationTimestamp, + ResourceStats stats) { + + static ResourceVo from(DownloadResource resource, ResourceStats stats) { + var spec = resource.getSpec(); + var name = resource.getMetadata() == null ? null + : resource.getMetadata().getName(); + var creationTimestamp = ConsoleEndpoint.creationTimestamp(resource); + if (spec == null) { + return new ResourceVo(name, null, null, null, null, false, false, null, + creationTimestamp, stats); + } + return new ResourceVo(name, spec.getSlug(), spec.getDisplayName(), + spec.getDescription(), spec.getAttachmentName(), + spec.isRequireEmailVerify(), spec.isEnabled(), + "/download/" + spec.getSlug(), creationTimestamp, stats); + } + } +} diff --git a/src/main/java/run/halo/sharelink/download/DownloadPageRenderer.java b/src/main/java/run/halo/sharelink/download/DownloadPageRenderer.java new file mode 100644 index 0000000..75fac99 --- /dev/null +++ b/src/main/java/run/halo/sharelink/download/DownloadPageRenderer.java @@ -0,0 +1,317 @@ +package run.halo.sharelink.download; + +/** + * Renders the self-contained HTML pages for the public download flow (no external CSS/JS + * dependencies). All dynamic values are HTML-escaped before being embedded. + */ +public final class DownloadPageRenderer { + + private DownloadPageRenderer() { + } + + public static String renderDownloadPage(String slug, String displayName, + String description, boolean requireEmailVerify) { + return DOWNLOAD_PAGE + .replace("__TITLE__", escapeHtml(displayName)) + .replace("__SLUG__", escapeHtml(slug)) + .replace("__DISPLAY_NAME__", escapeHtml(displayName)) + .replace("__DESCRIPTION__", description == null ? "" : escapeHtml(description)) + .replace("__REQUIRE_VERIFY__", String.valueOf(requireEmailVerify)); + } + + public static String renderNotFoundPage(String slug) { + return renderErrorPage(404, "下载资源不存在或已停用"); + } + + public static String renderErrorPage(int status, String message) { + return ERROR_PAGE + .replace("__STATUS__", String.valueOf(status)) + .replace("__MESSAGE__", escapeHtml(message)); + } + + static String escapeHtml(String value) { + if (value == null) { + return ""; + } + return value.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } + + private static final String SHARED_STYLE = """ + * { margin: 0; padding: 0; box-sizing: border-box; } + [hidden] { display: none !important; } + body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", + "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + min-height: 100vh; display: flex; align-items: center; justify-content: center; + padding: 24px 16px; color: #1f2937; + background: linear-gradient(160deg, #eef2ff 0%, #f5f7fb 45%, #eef7f4 100%); + } + #app { width: 100%; max-width: 440px; } + .card { + background: #fff; border-radius: 20px; width: 100%; max-width: 440px; + min-height: 520px; padding: 40px 36px 32px; text-align: center; + display: flex; flex-direction: column; justify-content: center; + box-shadow: 0 1px 2px rgba(16,24,40,.04), + 0 12px 32px -8px rgba(16,24,40,.12); + border: 1px solid rgba(226,232,240,.8); + } + """; + + private static final String ERROR_PAGE = """ + + + + + + __STATUS__ - 无法下载 + + + +
+
__STATUS__
+

__MESSAGE__

+
+ + + """.replace("__SHARED_STYLE__", SHARED_STYLE); + + private static final String DOWNLOAD_PAGE = """ + + + + + + __TITLE__ - 资源下载 + + + +
+
+
+ + + + + +
+

__DISPLAY_NAME__

+

__DESCRIPTION__

+ + +

+ +
+
+ + + + """.replace("__SHARED_STYLE__", SHARED_STYLE); +} diff --git a/src/main/java/run/halo/sharelink/download/DownloadResourceService.java b/src/main/java/run/halo/sharelink/download/DownloadResourceService.java new file mode 100644 index 0000000..e227b9d --- /dev/null +++ b/src/main/java/run/halo/sharelink/download/DownloadResourceService.java @@ -0,0 +1,31 @@ +package run.halo.sharelink.download; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; +import run.halo.app.extension.ListOptions; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.extension.index.query.QueryFactory; +import run.halo.sharelink.model.DownloadResource; + +/** + * Shared lookup helpers for {@link DownloadResource}. + */ +@Component +@RequiredArgsConstructor +public class DownloadResourceService { + + private final ReactiveExtensionClient client; + + /** + * Finds a resource by its (unique) slug. + */ + public Mono findBySlug(String slug) { + var options = ListOptions.builder() + .fieldQuery(QueryFactory.equal("spec.slug", slug)) + .build(); + return client.listAll(DownloadResource.class, options, Sort.unsorted()) + .next(); + } +} diff --git a/src/main/java/run/halo/sharelink/download/DownloadTokenEndpoint.java b/src/main/java/run/halo/sharelink/download/DownloadTokenEndpoint.java new file mode 100644 index 0000000..fb72395 --- /dev/null +++ b/src/main/java/run/halo/sharelink/download/DownloadTokenEndpoint.java @@ -0,0 +1,140 @@ +package run.halo.sharelink.download; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.reactive.function.server.ServerResponse; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.server.ServerWebInputException; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.endpoint.CustomEndpoint; +import run.halo.app.extension.GroupVersion; +import run.halo.app.extension.ListOptions; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.extension.index.query.QueryFactory; +import run.halo.sharelink.SharelinkSettingConfigGetter; +import run.halo.sharelink.emailcode.EmailCodeManager; +import run.halo.sharelink.model.DownloadResource; +import run.halo.sharelink.util.EmailUtils; +import run.halo.sharelink.util.IpUtils; +import run.halo.sharelink.verify.VerifiedEmailService; + +/** + * Public endpoint exchanging a (possibly verified) identity for a one-time download + * token. Mounted under {@code /apis/api.sharelink.halo.run/v1alpha1} and opened to + * anonymous visitors via the aggregate-to-anonymous role template. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DownloadTokenEndpoint implements CustomEndpoint { + + private final ReactiveExtensionClient client; + private final SharelinkSettingConfigGetter settingConfigGetter; + private final VerifiedEmailService verifiedEmailService; + private final EmailCodeManager emailCodeManager; + private final DownloadTokenManager tokenManager; + + @Override + public RouterFunction endpoint() { + return RouterFunctions.route() + .POST("downloads/-/token", this::issueToken) + .build(); + } + + private Mono issueToken(ServerRequest request) { + return request.bodyToMono(TokenRequest.class) + .switchIfEmpty(Mono.error(new ServerWebInputException("请求体不能为空"))) + .flatMap(body -> { + var slug = StringUtils.trimToNull(body.slug()); + if (slug == null) { + return Mono.error(new ServerWebInputException("slug 不能为空")); + } + return findBySlug(slug) + .switchIfEmpty(Mono.error(new ResponseStatusException( + HttpStatus.NOT_FOUND, "下载资源不存在"))) + .flatMap(resource -> doIssueToken(request, resource, body)); + }); + } + + private Mono doIssueToken(ServerRequest request, + DownloadResource resource, + TokenRequest body) { + var spec = resource.getSpec(); + if (!spec.isEnabled()) { + return Mono.error(new ResponseStatusException(HttpStatus.FORBIDDEN, "该资源已停用")); + } + var clientIp = IpUtils.clientIp(request.exchange().getRequest()); + return settingConfigGetter.getBasicConfig() + .flatMap(basic -> { + if (!spec.isRequireEmailVerify()) { + return respondWithToken(spec, null, clientIp, basic); + } + var email = EmailUtils.normalizeEmail(body.email()); + if (email == null || !EmailUtils.isValidEmail(email)) { + return Mono.error(new ServerWebInputException("邮箱格式不正确")); + } + return verifiedEmailService.isVerified(email) + .onErrorResume(e -> { + log.warn("查询邮箱验证状态失败,按未验证处理: {}", email, e); + return Mono.just(false); + }) + .flatMap(verified -> { + if (verified) { + return respondWithToken(spec, email, clientIp, basic); + } + var code = StringUtils.trimToNull(body.code()); + if (code == null) { + return Mono.error(new ResponseStatusException( + HttpStatus.FORBIDDEN, "请先完成邮箱验证")); + } + return settingConfigGetter.getEmailVerifyConfig() + .flatMap(config -> emailCodeManager.verify(email, code, config) + .flatMap(valid -> { + if (!valid) { + return Mono.error(new ResponseStatusException( + HttpStatus.FORBIDDEN, "验证码错误或已过期,请重新获取")); + } + return verifiedEmailService.recordVerified(email, clientIp) + .onErrorResume(e -> { + log.warn("登记已验证邮箱失败: {}", email, e); + return Mono.empty(); + }) + .then(respondWithToken(spec, email, clientIp, basic)); + })); + }); + }); + } + + private Mono respondWithToken(DownloadResource.Spec spec, String email, + String clientIp, + SharelinkSettingConfigGetter.BasicConfig basic) { + var token = tokenManager.issue(spec.getSlug(), email, clientIp); + var fileUrl = "/download/" + spec.getSlug() + "/file?token=" + token; + return ServerResponse.ok().bodyValue(new TokenResponse(token, fileUrl)); + } + + private Mono findBySlug(String slug) { + var options = ListOptions.builder() + .fieldQuery(QueryFactory.equal("spec.slug", slug)) + .build(); + return client.listAll(DownloadResource.class, options, Sort.unsorted()).next(); + } + + @Override + public GroupVersion groupVersion() { + return GroupVersion.parseAPIVersion("api.sharelink.halo.run/v1alpha1"); + } + + public record TokenRequest(String slug, String email, String code) { + } + + public record TokenResponse(String token, String fileUrl) { + } +} diff --git a/src/main/java/run/halo/sharelink/download/DownloadTokenManager.java b/src/main/java/run/halo/sharelink/download/DownloadTokenManager.java new file mode 100644 index 0000000..0973270 --- /dev/null +++ b/src/main/java/run/halo/sharelink/download/DownloadTokenManager.java @@ -0,0 +1,61 @@ +package run.halo.sharelink.download; + +import java.time.Duration; +import java.time.Instant; +import java.util.UUID; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Component; + +/** + * Issues and consumes one-time download tokens. A token is bound to a resource slug (and + * the verified email / client ip for auditing) and becomes invalid as soon as it is + * consumed or its TTL (settings {@code basic.tokenTtlSeconds}) elapses. + */ +@Component +public class DownloadTokenManager { + + /** + * Fallback eviction for never-consumed tokens; the effective expiration is checked + * against the configured TTL on consumption. + */ + private static final Duration CACHE_TTL = Duration.ofMinutes(30); + + private final Cache cache = + CacheBuilder.newBuilder() + .expireAfterWrite(CACHE_TTL) + .maximumSize(10_000) + .build(); + + public String issue(String slug, String email, String ip) { + var token = UUID.randomUUID().toString(); + cache.put(token, new TokenPayload(slug, email, ip, Instant.now())); + return token; + } + + /** + * Consumes the token: it is removed from the cache no matter the outcome, so a token + * can never be used twice. + * + * @return the payload when the token exists and has not expired, otherwise + * {@code null} + */ + public TokenPayload consume(String token, long ttlSeconds) { + if (StringUtils.isBlank(token)) { + return null; + } + var payload = cache.getIfPresent(token); + if (payload == null) { + return null; + } + cache.invalidate(token); + if (Instant.now().isAfter(payload.issuedAt().plusSeconds(ttlSeconds))) { + return null; + } + return payload; + } + + public record TokenPayload(String slug, String email, String ip, Instant issuedAt) { + } +} diff --git a/src/main/java/run/halo/sharelink/download/DownloadWebRouter.java b/src/main/java/run/halo/sharelink/download/DownloadWebRouter.java new file mode 100644 index 0000000..91a9b1c --- /dev/null +++ b/src/main/java/run/halo/sharelink/download/DownloadWebRouter.java @@ -0,0 +1,219 @@ +package run.halo.sharelink.download; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.UUID; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.context.annotation.Bean; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.data.domain.Sort; +import org.springframework.http.CacheControl; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.reactive.function.server.ServerResponse; +import reactor.core.publisher.Mono; +import reactor.util.retry.Retry; +import run.halo.app.extension.ListOptions; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.extension.index.query.QueryFactory; +import run.halo.sharelink.SharelinkSettingConfigGetter; +import run.halo.sharelink.model.DownloadRecord; +import run.halo.sharelink.model.DownloadResource; +import run.halo.sharelink.util.IpUtils; + +/** + * Public download routes, registered as {@link RouterFunction} beans which Halo collects + * from the plugin context: + *
    + *
  • {@code GET /download/{slug}} — the self-contained HTML download page
  • + *
  • {@code GET /download/{slug}/file?token=...} — consumes a one-time token, records + * the download, then streams the file
  • + *
+ * All responses are {@code Cache-Control: no-store}. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DownloadWebRouter { + + private static final MediaType HTML_UTF8 = + new MediaType(MediaType.TEXT_HTML, StandardCharsets.UTF_8); + + /** + * Fallback eviction for dedupe entries; the effective window is checked against the + * configured value on each hit. + */ + private static final Duration DEDUPE_CACHE_TTL = Duration.ofDays(1); + + private final ReactiveExtensionClient client; + private final SharelinkSettingConfigGetter settingConfigGetter; + private final DownloadTokenManager tokenManager; + private final FileStreamer fileStreamer; + + private final Cache dedupeCache = + CacheBuilder.newBuilder() + .expireAfterWrite(DEDUPE_CACHE_TTL) + .maximumSize(50_000) + .build(); + + @Bean + RouterFunction downloadPageRoute() { + return RouterFunctions.route() + .GET("/download/{slug}", this::renderPage) + .build(); + } + + @Bean + RouterFunction downloadFileRoute() { + return RouterFunctions.route() + .GET("/download/{slug}/file", this::streamFile) + .build(); + } + + private Mono renderPage(ServerRequest request) { + var slug = request.pathVariable("slug"); + return findBySlug(slug) + .filter(resource -> resource.getSpec().isEnabled()) + .flatMap(resource -> { + var spec = resource.getSpec(); + var displayName = StringUtils.firstNonBlank(spec.getDisplayName(), + spec.getSlug()); + var html = DownloadPageRenderer.renderDownloadPage(spec.getSlug(), + displayName, spec.getDescription(), spec.isRequireEmailVerify()); + return ServerResponse.ok() + .contentType(HTML_UTF8) + .cacheControl(CacheControl.noStore()) + .bodyValue(html); + }) + .switchIfEmpty(notFoundPage(slug)); + } + + private Mono streamFile(ServerRequest request) { + var slug = request.pathVariable("slug"); + var token = request.queryParam("token").orElse(null); + return settingConfigGetter.getBasicConfig() + .flatMap(basic -> { + var payload = tokenManager.consume(token, basic.getTokenTtlSeconds()); + if (payload == null || !slug.equals(payload.slug())) { + // Token missing, reused or expired: send the visitor back to the + // download page to obtain a fresh one. + return redirectToPage(slug); + } + return findBySlug(slug) + .filter(resource -> resource.getSpec().isEnabled()) + .flatMap(resource -> fileStreamer.resolveAvailable(resource) + .flatMap(attachment -> recordDownload(request, resource, payload, + basic) + .then(fileStreamer.stream(request, resource.getSpec(), + attachment))) + .switchIfEmpty(FileStreamer.errorPage(HttpStatus.NOT_FOUND, + "附件暂不可用"))) + .switchIfEmpty(notFoundPage(slug)); + }); + } + + /** + * Records the download (DownloadRecord + redundant counter). Best effort: failures + * are logged and never block the download itself. Resources without email + * verification dedupe repeated downloads from the same IP inside the configured + * window (no record, no count, but the download still proceeds). + */ + private Mono recordDownload(ServerRequest request, DownloadResource resource, + DownloadTokenManager.TokenPayload payload, + SharelinkSettingConfigGetter.BasicConfig basic) { + var spec = resource.getSpec(); + if (!spec.isRequireEmailVerify() + && isDuplicate(spec.getSlug(), payload.ip(), basic.getDedupeWindowMinutes())) { + return Mono.empty(); + } + var record = new DownloadRecord(); + var metadata = new Metadata(); + metadata.setName(UUID.randomUUID().toString()); + record.setMetadata(metadata); + var recordSpec = new DownloadRecord.Spec(); + recordSpec.setResourceSlug(spec.getSlug()); + recordSpec.setEmail(payload.email()); + recordSpec.setIp(payload.ip()); + recordSpec.setUserAgent( + StringUtils.truncate(request.headers().firstHeader("User-Agent"), 500)); + recordSpec.setDownloadedAt(Instant.now()); + record.setSpec(recordSpec); + return client.create(record) + .then(incrementDownloadCount(resource.getMetadata().getName())) + .onErrorResume(e -> { + log.warn("记录下载失败: slug={}", spec.getSlug(), e); + return Mono.empty(); + }); + } + + /** + * @return {@code true} when this slug+ip pair was already seen inside the dedupe + * window + */ + private boolean isDuplicate(String slug, String ip, int windowMinutes) { + if (windowMinutes <= 0) { + return false; + } + var key = slug + "|" + (ip == null ? "-" : ip); + var now = Instant.now(); + var last = dedupeCache.getIfPresent(key); + if (last != null && now.isBefore(last.plusSeconds(windowMinutes * 60L))) { + return true; + } + dedupeCache.put(key, now); + return false; + } + + private Mono incrementDownloadCount(String resourceName) { + return Mono.defer(() -> client.fetch(DownloadResource.class, resourceName) + .flatMap(resource -> { + var status = resource.getStatus(); + if (status == null) { + status = new DownloadResource.Status(); + resource.setStatus(status); + } + status.setDownloadCount(status.getDownloadCount() + 1); + return client.update(resource); + })) + // Optimistic-lock retry for concurrent downloads of the same resource. + .retryWhen(Retry.max(3) + .filter(OptimisticLockingFailureException.class::isInstance)) + .onErrorResume(e -> { + log.warn("更新下载计数失败: {}", resourceName, e); + return Mono.empty(); + }) + .then(); + } + + private Mono findBySlug(String slug) { + var options = ListOptions.builder() + .fieldQuery(QueryFactory.equal("spec.slug", slug)) + .build(); + return client.listAll(DownloadResource.class, options, Sort.unsorted()).next(); + } + + private static Mono redirectToPage(String slug) { + return ServerResponse.status(HttpStatus.FOUND) + .location(URI.create("/download/" + slug)) + .cacheControl(CacheControl.noStore()) + .build(); + } + + private static Mono notFoundPage(String slug) { + return ServerResponse.status(HttpStatus.NOT_FOUND) + .contentType(HTML_UTF8) + .cacheControl(CacheControl.noStore()) + .bodyValue(DownloadPageRenderer.renderNotFoundPage(slug)); + } +} diff --git a/src/main/java/run/halo/sharelink/download/FileStreamer.java b/src/main/java/run/halo/sharelink/download/FileStreamer.java new file mode 100644 index 0000000..1ed7158 --- /dev/null +++ b/src/main/java/run/halo/sharelink/download/FileStreamer.java @@ -0,0 +1,231 @@ +package run.halo.sharelink.download; + +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.core.env.Environment; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.CacheControl; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.reactive.function.server.ServerResponse; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; +import run.halo.app.core.extension.attachment.Attachment; +import run.halo.app.core.extension.attachment.Constant; +import run.halo.app.extension.MetadataUtil; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.infra.ExternalUrlSupplier; +import run.halo.sharelink.model.DownloadResource; + +/** + * Streams the attachment bytes of a download resource to the visitor. + * + *

For the local storage policy the file is read directly from disk + * ({work-dir}/attachments/{local-relative-path}); for other policies the file is fetched + * through a loopback HTTP request to its permalink (carrying the internal secret header + * so {@code UploadProtectFilter} lets it pass). The body is forwarded as a + * {@link DataBuffer} stream without buffering the whole file in memory. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class FileStreamer { + + private static final MediaType HTML_UTF8 = + new MediaType(MediaType.TEXT_HTML, StandardCharsets.UTF_8); + + private static final int BUFFER_SIZE = 64 * 1024; + + private final ReactiveExtensionClient client; + private final ExternalUrlSupplier externalUrlSupplier; + private final InternalRequestSecret internalRequestSecret; + private final Environment environment; + + private final WebClient webClient = WebClient.builder().build(); + + /** + * Resolves the attachment for the resource and emits it only when its bytes are + * actually available (local file exists, or a permalink is present for the loopback + * fallback). Emits empty otherwise, so callers can avoid counting failed downloads. + */ + public Mono resolveAvailable(DownloadResource resource) { + return client.fetch(Attachment.class, resource.getSpec().getAttachmentName()) + .filter(attachment -> { + if (resolveLocalFile(attachment) != null) { + return true; + } + var permalink = attachment.getStatus() == null + ? null : attachment.getStatus().getPermalink(); + return StringUtils.isNotBlank(permalink); + }); + } + + /** + * Streams an attachment previously resolved via {@link #resolveAvailable}. + */ + public Mono stream(ServerRequest request, DownloadResource.Spec spec, + Attachment attachment) { + var localFile = resolveLocalFile(attachment); + if (localFile != null) { + return streamLocalFile(localFile, spec, attachment); + } + return streamViaLoopback(request, spec, attachment); + } + + /** + * @deprecated kept for compatibility; prefer {@link #resolveAvailable} + + * {@link #stream(ServerRequest, DownloadResource.Spec, Attachment)}. + */ + @Deprecated + public Mono stream(ServerRequest request, DownloadResource resource) { + var spec = resource.getSpec(); + return client.fetch(Attachment.class, spec.getAttachmentName()) + .flatMap(attachment -> { + var localFile = resolveLocalFile(attachment); + if (localFile != null) { + return streamLocalFile(localFile, spec, attachment); + } + return streamViaLoopback(request, spec, attachment); + }) + .switchIfEmpty(errorPage(HttpStatus.NOT_FOUND, "附件不存在")); + } + + /** + * Resolves the on-disk path for attachments stored by the local storage policy, + * or {@code null} when the attachment is not a local file. + */ + private Path resolveLocalFile(Attachment attachment) { + var annotations = MetadataUtil.nullSafeAnnotations(attachment); + var relPath = annotations.get(Constant.LOCAL_REL_PATH_ANNO_KEY); + if (StringUtils.isBlank(relPath)) { + return null; + } + var workDir = environment.getProperty("halo.work-dir", + System.getProperty("user.home") + "/.halo2"); + var attachmentsRoot = Paths.get(workDir).resolve("attachments").normalize(); + var file = attachmentsRoot.resolve(relPath).normalize(); + if (!file.startsWith(attachmentsRoot)) { + log.warn("附件路径越界,已拒绝: {}", relPath); + return null; + } + if (!Files.isRegularFile(file)) { + log.warn("附件文件不存在: {}", file); + return null; + } + return file; + } + + private Mono streamLocalFile(Path file, DownloadResource.Spec spec, + Attachment attachment) { + return Mono.fromCallable(() -> Files.size(file)) + .subscribeOn(Schedulers.boundedElastic()) + .flatMap(size -> { + var filename = resolveFilename(spec, attachment); + var builder = ServerResponse.ok() + .cacheControl(CacheControl.noStore()) + .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition(filename)) + .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(size)); + var mediaType = mediaTypeOf(attachment); + if (mediaType != null) { + builder.contentType(mediaType); + } + var body = DataBufferUtils.read(file, + DefaultDataBufferFactory.sharedInstance, BUFFER_SIZE); + return builder.body(body, DataBuffer.class); + }); + } + + private Mono streamViaLoopback(ServerRequest request, + DownloadResource.Spec spec, Attachment attachment) { + var permalink = attachment.getStatus() == null + ? null : attachment.getStatus().getPermalink(); + if (StringUtils.isBlank(permalink)) { + return errorPage(HttpStatus.NOT_FOUND, "附件暂不可用"); + } + final URI fileUri; + try { + fileUri = externalUrlSupplier.getURL(request.exchange().getRequest()) + .toURI().resolve(permalink); + } catch (URISyntaxException e) { + log.warn("拼接附件下载地址失败: {}", permalink, e); + return errorPage(HttpStatus.INTERNAL_SERVER_ERROR, "附件地址解析失败"); + } + return webClient.get() + .uri(fileUri) + .header(InternalRequestSecret.HEADER, internalRequestSecret.value()) + .exchangeToMono(response -> { + if (!response.statusCode().is2xxSuccessful()) { + log.warn("回环请求附件失败: {} -> {}", fileUri, response.statusCode()); + return response.releaseBody() + .then(errorPage(HttpStatus.NOT_FOUND, "附件暂不可用")); + } + var filename = resolveFilename(spec, attachment); + var builder = ServerResponse.ok() + .cacheControl(CacheControl.noStore()) + .header(HttpHeaders.CONTENT_DISPOSITION, + contentDisposition(filename)); + response.headers().contentType().ifPresent(builder::contentType); + var contentLength = response.headers().contentLength(); + if (contentLength.isPresent()) { + builder.header(HttpHeaders.CONTENT_LENGTH, + String.valueOf(contentLength.getAsLong())); + } + return builder.body(response.bodyToFlux(DataBuffer.class), + DataBuffer.class); + }); + } + + private static MediaType mediaTypeOf(Attachment attachment) { + var mediaType = attachment.getSpec() == null + ? null : attachment.getSpec().getMediaType(); + if (StringUtils.isBlank(mediaType)) { + return null; + } + try { + return MediaType.parseMediaType(mediaType); + } catch (Exception e) { + return null; + } + } + + private static String resolveFilename(DownloadResource.Spec spec, Attachment attachment) { + var attachmentName = attachment.getSpec() == null + ? null : attachment.getSpec().getDisplayName(); + var filename = StringUtils.firstNonBlank(spec.getDisplayName(), attachmentName, + "download"); + // Append the original extension when the display name carries none. + if (StringUtils.isNotBlank(attachmentName) + && attachmentName.contains(".") + && !filename.contains(".")) { + filename += attachmentName.substring(attachmentName.lastIndexOf('.')); + } + return filename; + } + + private static String contentDisposition(String filename) { + var encoded = URLEncoder.encode(filename, StandardCharsets.UTF_8) + .replace("+", "%20"); + return "attachment; filename*=UTF-8''" + encoded; + } + + public static Mono errorPage(HttpStatus status, String message) { + return ServerResponse.status(status) + .contentType(HTML_UTF8) + .cacheControl(CacheControl.noStore()) + .bodyValue(DownloadPageRenderer.renderErrorPage(status.value(), message)); + } +} diff --git a/src/main/java/run/halo/sharelink/download/InternalRequestSecret.java b/src/main/java/run/halo/sharelink/download/InternalRequestSecret.java new file mode 100644 index 0000000..e58b974 --- /dev/null +++ b/src/main/java/run/halo/sharelink/download/InternalRequestSecret.java @@ -0,0 +1,30 @@ +package run.halo.sharelink.download; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.UUID; +import org.springframework.stereotype.Component; + +/** + * A random secret generated at plugin startup. {@code FileStreamer} attaches it as the + * {@value #HEADER} header on its loopback requests so that + * {@code UploadProtectFilter} lets them through, while external requests to the same + * {@code /upload/**} permalinks are rejected. + */ +@Component +public class InternalRequestSecret { + + public static final String HEADER = "X-Sharelink-Internal"; + + private final String value = UUID.randomUUID().toString(); + + public String value() { + return value; + } + + public boolean matches(String headerValue) { + return headerValue != null + && MessageDigest.isEqual(value.getBytes(StandardCharsets.UTF_8), + headerValue.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/src/main/java/run/halo/sharelink/emailcode/EmailCodeManager.java b/src/main/java/run/halo/sharelink/emailcode/EmailCodeManager.java new file mode 100644 index 0000000..9019208 --- /dev/null +++ b/src/main/java/run/halo/sharelink/emailcode/EmailCodeManager.java @@ -0,0 +1,52 @@ +package run.halo.sharelink.emailcode; + +import java.net.URI; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import reactor.core.publisher.Mono; +import run.halo.sharelink.SharelinkSettingConfigGetter; + +public interface EmailCodeManager { + + /** + * Sends a verification code to the given email. Fails with + * {@link SendRateLimitedException} when any rate limit is exceeded. + * + * @param email normalized email (lowercased and trimmed) + * @param clientIp client ip for auxiliary rate limiting, may be null + * @param config email verify config + */ + Mono sendCode(String email, String clientIp, + SharelinkSettingConfigGetter.EmailVerifyConfig config); + + /** + * Verifies the code for the given email. Each invocation accumulates attempts and the + * code is invalidated once the max attempts is exceeded or the verification succeeds. + */ + Mono verify(String email, String code, + SharelinkSettingConfigGetter.EmailVerifyConfig config); + + Mono invalidate(String email); + + class SendRateLimitedException extends ResponseStatusException { + public static final String TYPE = + "https://www.halo.run/probs/email-code-send-rate-limited"; + + public SendRateLimitedException(Type type) { + super(HttpStatus.TOO_MANY_REQUESTS, type.detail); + setType(URI.create(TYPE)); + } + + public enum Type { + RESEND_TOO_FREQUENT("验证码发送过于频繁,请稍后再试"), + DAILY_LIMIT_EXCEEDED("今日验证码发送次数已达上限,请明天再试"), + IP_HOURLY_LIMIT_EXCEEDED("当前网络环境验证码发送过于频繁,请稍后再试"); + + private final String detail; + + Type(String detail) { + this.detail = detail; + } + } + } +} diff --git a/src/main/java/run/halo/sharelink/emailcode/EmailCodeManagerImpl.java b/src/main/java/run/halo/sharelink/emailcode/EmailCodeManagerImpl.java new file mode 100644 index 0000000..79c6534 --- /dev/null +++ b/src/main/java/run/halo/sharelink/emailcode/EmailCodeManagerImpl.java @@ -0,0 +1,156 @@ +package run.halo.sharelink.emailcode; + +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.Duration; +import java.time.Instant; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicInteger; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; +import run.halo.sharelink.SharelinkSettingConfigGetter; + +/** + * In-memory verification code store: one active code per email, one-time use, resend / + * daily / ip-hourly rate limits, and a constant-time comparison. + */ +@Component +@RequiredArgsConstructor +public class EmailCodeManagerImpl implements EmailCodeManager { + private static final SecureRandom RANDOM = new SecureRandom(); + private static final int CODE_BOUND = 1_000_000; + /** + * The real expiration is checked against the config on verification, this TTL is only + * a fallback to evict stale cache entries. + */ + private static final Duration CODE_CACHE_TTL = Duration.ofHours(1); + private static final Duration RESEND_CACHE_TTL = Duration.ofMinutes(10); + private static final Duration DAILY_LIMIT_TTL = Duration.ofDays(1); + private static final Duration IP_HOURLY_LIMIT_TTL = Duration.ofHours(1); + + private final Cache codeCache = + CacheBuilder.newBuilder() + .expireAfterWrite(CODE_CACHE_TTL) + .maximumSize(10_000) + .build(); + + private final Cache resendCache = + CacheBuilder.newBuilder() + .expireAfterWrite(RESEND_CACHE_TTL) + .maximumSize(10_000) + .build(); + + private final Cache dailySendCache = + CacheBuilder.newBuilder() + .expireAfterWrite(DAILY_LIMIT_TTL) + .maximumSize(10_000) + .build(); + + private final Cache ipHourlySendCache = + CacheBuilder.newBuilder() + .expireAfterWrite(IP_HOURLY_LIMIT_TTL) + .maximumSize(10_000) + .build(); + + private final EmailCodeNotificationSender notificationSender; + + @Override + public Mono sendCode(String email, String clientIp, + SharelinkSettingConfigGetter.EmailVerifyConfig config) { + return Mono.defer(() -> { + checkRateLimits(email, clientIp, config); + var code = generateCode(); + return notificationSender.sendVerificationCode(email, email, code, + config.getCodeExpireMinutes()) + .subscribeOn(Schedulers.boundedElastic()) + // Record the code and rate limits only after the notification is emitted, + // so failures neither count against the quota nor overwrite the old code. + .doOnSuccess(sent -> recordSent(email, clientIp, code)); + }); + } + + private void checkRateLimits(String email, String clientIp, + SharelinkSettingConfigGetter.EmailVerifyConfig config) { + var lastSentAt = resendCache.getIfPresent(email); + if (lastSentAt != null + && Instant.now().isBefore(lastSentAt.plusSeconds(config.getResendIntervalSeconds()))) { + throw new SendRateLimitedException(SendRateLimitedException.Type.RESEND_TOO_FREQUENT); + } + var dailyCounter = dailySendCache.getIfPresent(email); + if (dailyCounter != null && dailyCounter.get() >= config.getDailySendLimitPerEmail()) { + throw new SendRateLimitedException( + SendRateLimitedException.Type.DAILY_LIMIT_EXCEEDED); + } + if (StringUtils.isNotBlank(clientIp)) { + var ipCounter = ipHourlySendCache.getIfPresent(clientIp); + if (ipCounter != null && ipCounter.get() >= config.getIpHourlySendLimit()) { + throw new SendRateLimitedException( + SendRateLimitedException.Type.IP_HOURLY_LIMIT_EXCEEDED); + } + } + } + + private void recordSent(String email, String clientIp, String code) { + // Resending overwrites the previous code. + codeCache.put(email, new EmailCodeEntry(code, Instant.now(), new AtomicInteger())); + resendCache.put(email, Instant.now()); + incrementCounter(dailySendCache, email); + if (StringUtils.isNotBlank(clientIp)) { + incrementCounter(ipHourlySendCache, clientIp); + } + } + + private static void incrementCounter(Cache cache, String key) { + var counter = cache.getIfPresent(key); + if (counter == null) { + counter = new AtomicInteger(); + cache.put(key, counter); + } + counter.incrementAndGet(); + } + + @Override + public Mono verify(String email, String code, + SharelinkSettingConfigGetter.EmailVerifyConfig config) { + return Mono.fromSupplier(() -> { + var entry = codeCache.getIfPresent(email); + if (entry == null) { + return false; + } + if (entry.attempts().incrementAndGet() > config.getMaxVerifyAttempts()) { + codeCache.invalidate(email); + return false; + } + if (Instant.now() + .isAfter(entry.createdAt().plusSeconds(config.getCodeExpireMinutes() * 60L))) { + codeCache.invalidate(email); + return false; + } + var matched = MessageDigest.isEqual(entry.code().getBytes(), + code.getBytes()); + if (matched) { + // One-time use. + codeCache.invalidate(email); + } + return matched; + }); + } + + @Override + public Mono invalidate(String email) { + codeCache.invalidate(email); + return Mono.empty(); + } + + private static String generateCode() { + return String.format(Locale.ROOT, "%06d", RANDOM.nextInt(CODE_BOUND)); + } + + record EmailCodeEntry(String code, Instant createdAt, AtomicInteger attempts) { + } +} diff --git a/src/main/java/run/halo/sharelink/emailcode/EmailCodeNotificationSender.java b/src/main/java/run/halo/sharelink/emailcode/EmailCodeNotificationSender.java new file mode 100644 index 0000000..ccbeead --- /dev/null +++ b/src/main/java/run/halo/sharelink/emailcode/EmailCodeNotificationSender.java @@ -0,0 +1,70 @@ +package run.halo.sharelink.emailcode; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.User; +import run.halo.app.core.extension.notification.Reason; +import run.halo.app.core.extension.notification.Subscription; +import run.halo.app.extension.GroupVersion; +import run.halo.app.notification.NotificationCenter; +import run.halo.app.notification.NotificationReasonEmitter; +import run.halo.app.notification.UserIdentity; + +/** + * Sends email verification codes through the Halo notification chain. The email address + * is resolved by the core subscriber email resolver from the anonymous user identity + * ({@code anonymousUser#}), so an email notifier (SMTP) must be configured in the + * console first. + */ +@Component +@RequiredArgsConstructor +public class EmailCodeNotificationSender { + public static final String REASON_TYPE = "sharelink-download-verification"; + + private final NotificationReasonEmitter reasonEmitter; + private final NotificationCenter notificationCenter; + + public Mono sendVerificationCode(String email, String displayName, String code, + long expirationMinutes) { + var identity = UserIdentity.anonymousWithEmail(email); + var interestReason = createInterestReason(identity); + var subscribe = notificationCenter.subscribe( + createSubscriber(identity), interestReason); + var emitReason = reasonEmitter.emit(REASON_TYPE, builder -> builder + .attribute("code", code) + .attribute("expirationAtMinutes", String.valueOf(expirationMinutes)) + .attribute("username", displayName) + .author(identity) + .subject(Reason.Subject.builder() + .apiVersion(interestReason.getSubject().getApiVersion()) + .kind(User.KIND) + .name(identity.name()) + .title("资源下载邮箱验证:" + email) + .build())); + return Mono.when(subscribe).then(emitReason); + } + + public Mono unsubscribe(String email) { + var identity = UserIdentity.anonymousWithEmail(email); + return notificationCenter.unsubscribe(createSubscriber(identity), + createInterestReason(identity)); + } + + private static Subscription.Subscriber createSubscriber(UserIdentity identity) { + var subscriber = new Subscription.Subscriber(); + subscriber.setName(identity.name()); + return subscriber; + } + + private static Subscription.InterestReason createInterestReason(UserIdentity identity) { + var interestReason = new Subscription.InterestReason(); + interestReason.setReasonType(REASON_TYPE); + interestReason.setSubject(Subscription.ReasonSubject.builder() + .apiVersion(new GroupVersion(User.GROUP, User.KIND).toString()) + .kind(User.KIND) + .name(identity.name()) + .build()); + return interestReason; + } +} diff --git a/src/main/java/run/halo/sharelink/model/DownloadRecord.java b/src/main/java/run/halo/sharelink/model/DownloadRecord.java new file mode 100644 index 0000000..c805633 --- /dev/null +++ b/src/main/java/run/halo/sharelink/model/DownloadRecord.java @@ -0,0 +1,38 @@ +package run.halo.sharelink.model; + +import java.time.Instant; +import lombok.Data; +import lombok.EqualsAndHashCode; +import run.halo.app.extension.AbstractExtension; +import run.halo.app.extension.GVK; + +/** + * A single download event. The metadata name is a random UUID; {@code spec.resourceSlug} + * is indexed for per-resource queries. + */ +@GVK(group = "sharelink.halo.run", version = "v1alpha1", kind = "DownloadRecord", + plural = "downloadrecords", singular = "downloadrecord") +@Data +@EqualsAndHashCode(callSuper = true) +public class DownloadRecord extends AbstractExtension { + + private Spec spec; + + @Data + public static class Spec { + + private String resourceSlug; + + /** + * Verified email of the downloader; blank for resources without email + * verification. + */ + private String email; + + private String ip; + + private String userAgent; + + private Instant downloadedAt; + } +} diff --git a/src/main/java/run/halo/sharelink/model/DownloadResource.java b/src/main/java/run/halo/sharelink/model/DownloadResource.java new file mode 100644 index 0000000..d8a18eb --- /dev/null +++ b/src/main/java/run/halo/sharelink/model/DownloadResource.java @@ -0,0 +1,53 @@ +package run.halo.sharelink.model; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import run.halo.app.extension.AbstractExtension; +import run.halo.app.extension.GVK; + +/** + * A downloadable resource registered from an attachment. The {@code spec.slug} is the + * public URL identifier ({@code /download/{slug}}) and carries a unique index. + */ +@GVK(group = "sharelink.halo.run", version = "v1alpha1", kind = "DownloadResource", + plural = "downloadresources", singular = "downloadresource") +@Data +@EqualsAndHashCode(callSuper = true) +public class DownloadResource extends AbstractExtension { + + private Spec spec; + + private Status status; + + @Data + public static class Spec { + + /** + * URL identifier of the resource, unique across all resources. Matches + * {@code [a-z0-9][a-z0-9-]{0,63}}. + */ + private String slug; + + private String displayName; + + private String description; + + /** + * Metadata name of the associated {@code Attachment} extension. + */ + private String attachmentName; + + private boolean requireEmailVerify; + + private boolean enabled; + } + + @Data + public static class Status { + + /** + * Redundant download counter; the authoritative source is DownloadRecord. + */ + private long downloadCount; + } +} diff --git a/src/main/java/run/halo/sharelink/model/VerifiedDownloader.java b/src/main/java/run/halo/sharelink/model/VerifiedDownloader.java new file mode 100644 index 0000000..f5615ef --- /dev/null +++ b/src/main/java/run/halo/sharelink/model/VerifiedDownloader.java @@ -0,0 +1,34 @@ +package run.halo.sharelink.model; + +import java.time.Instant; +import lombok.Data; +import lombok.EqualsAndHashCode; +import run.halo.app.extension.AbstractExtension; +import run.halo.app.extension.GVK; + +/** + * Registry entry for an email address that has passed download email verification. The + * metadata name is the SHA-256 hex of the normalized email, which keeps it unique and + * stable across verifications. + */ +@GVK(group = "sharelink.halo.run", version = "v1alpha1", kind = "VerifiedDownloader", + plural = "verifieddownloaders", singular = "verifieddownloader") +@Data +@EqualsAndHashCode(callSuper = true) +public class VerifiedDownloader extends AbstractExtension { + + private Spec spec; + + @Data + public static class Spec { + + /** + * The normalized (trimmed, lower-cased) email address. + */ + private String email; + + private Instant firstVerifiedAt; + + private Instant lastVerifiedAt; + } +} diff --git a/src/main/java/run/halo/sharelink/protect/UploadProtectFilter.java b/src/main/java/run/halo/sharelink/protect/UploadProtectFilter.java new file mode 100644 index 0000000..b20fd95 --- /dev/null +++ b/src/main/java/run/halo/sharelink/protect/UploadProtectFilter.java @@ -0,0 +1,139 @@ +package run.halo.sharelink.protect; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Collections; +import java.util.stream.Collectors; +import java.util.Set; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.lang.NonNull; +import org.springframework.security.config.web.server.SecurityWebFiltersOrder; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.attachment.Attachment; +import run.halo.app.extension.ListOptions; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.security.AdditionalWebFilter; +import run.halo.sharelink.download.InternalRequestSecret; +import run.halo.sharelink.model.DownloadResource; + +/** + * Blocks direct access to {@code /upload/**} permalinks of attachments that are + * registered as (enabled) download resources, so visitors must go through the download + * page. Loopback requests from {@code FileStreamer} carrying the internal secret header + * are always allowed. The protected permalink set is cached for 60 seconds and can be + * invalidated explicitly after console mutations. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class UploadProtectFilter implements AdditionalWebFilter { + + private static final String UPLOAD_PREFIX = "/upload/"; + private static final String CACHE_KEY = "protected-permalinks"; + private static final Duration CACHE_TTL = Duration.ofSeconds(60); + private static final int PERMALINK_LOAD_CONCURRENCY = 8; + + private static final byte[] NOT_FOUND_BODY = ("{\"type\":\"about:blank\"," + + "\"title\":\"Not Found\",\"status\":404,\"detail\":\"资源不存在\"}") + .getBytes(StandardCharsets.UTF_8); + + private final ReactiveExtensionClient client; + private final InternalRequestSecret internalRequestSecret; + + private final Cache>> permalinkCache = + CacheBuilder.newBuilder() + .expireAfterWrite(CACHE_TTL) + .maximumSize(1) + .build(); + + /** + * Drops the cached permalink set; called by the console endpoint after resource + * create/update/delete. + */ + public void invalidate() { + permalinkCache.invalidateAll(); + } + + @Override + @NonNull + public Mono filter(@NonNull ServerWebExchange exchange, + @NonNull WebFilterChain chain) { + var request = exchange.getRequest(); + var method = request.getMethod(); + if (method != HttpMethod.GET && method != HttpMethod.HEAD) { + return chain.filter(exchange); + } + var rawPath = request.getURI().getRawPath(); + if (rawPath == null || !rawPath.startsWith(UPLOAD_PREFIX)) { + return chain.filter(exchange); + } + if (internalRequestSecret.matches( + request.getHeaders().getFirst(InternalRequestSecret.HEADER))) { + return chain.filter(exchange); + } + var decodedPath = request.getURI().getPath(); + return protectedPermalinks() + .flatMap(permalinks -> { + if (permalinks.contains(rawPath) || permalinks.contains(decodedPath)) { + return notFound(exchange); + } + return chain.filter(exchange); + }) + // Fail open: a lookup failure must not break unrelated /upload/ requests. + .onErrorResume(e -> { + log.warn("查询受保护附件列表失败,放行本次请求: {}", rawPath, e); + return chain.filter(exchange); + }); + } + + private Mono notFound(ServerWebExchange exchange) { + var response = exchange.getResponse(); + response.setStatusCode(HttpStatus.NOT_FOUND); + response.getHeaders().setContentType(MediaType.APPLICATION_PROBLEM_JSON); + return response.writeWith( + Mono.just(response.bufferFactory().wrap(NOT_FOUND_BODY))); + } + + private Mono> protectedPermalinks() { + var cached = permalinkCache.getIfPresent(CACHE_KEY); + if (cached != null) { + return cached; + } + var loading = client.listAll(DownloadResource.class, new ListOptions(), + Sort.unsorted()) + .filter(resource -> resource.getSpec() != null + && resource.getSpec().isEnabled() + && StringUtils.isNotBlank(resource.getSpec().getAttachmentName())) + .flatMap(resource -> client.fetch(Attachment.class, + resource.getSpec().getAttachmentName()) + .flatMap(attachment -> Mono.justOrEmpty( + attachment.getStatus() == null + ? null : attachment.getStatus().getPermalink())) + .onErrorResume(e -> Mono.empty()), + PERMALINK_LOAD_CONCURRENCY) + .filter(permalink -> permalink.startsWith(UPLOAD_PREFIX)) + .collect(Collectors.toSet()) + .map(Collections::unmodifiableSet) + .cache() + // Never keep a failed load in the cache. + .doOnError(e -> permalinkCache.invalidate(CACHE_KEY)); + permalinkCache.put(CACHE_KEY, loading); + return loading; + } + + @Override + public int getOrder() { + return SecurityWebFiltersOrder.AUTHORIZATION.getOrder() + 1; + } +} diff --git a/src/main/java/run/halo/sharelink/reference/PostReferenceService.java b/src/main/java/run/halo/sharelink/reference/PostReferenceService.java new file mode 100644 index 0000000..19606f0 --- /dev/null +++ b/src/main/java/run/halo/sharelink/reference/PostReferenceService.java @@ -0,0 +1,131 @@ +package run.halo.sharelink.reference; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.content.Post; +import run.halo.app.core.extension.content.Snapshot; +import run.halo.app.extension.ListOptions; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.sharelink.model.DownloadResource; + +/** + * Scans published posts for occurrences of {@code /download/{slug}} and produces a + * resource-slug → referencing-posts map. The result is cached for 5 minutes; a failed + * post never breaks the whole scan. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PostReferenceService { + + private static final String CACHE_KEY = "references"; + private static final Duration CACHE_TTL = Duration.ofMinutes(5); + private static final int SCAN_CONCURRENCY = 8; + + private final ReactiveExtensionClient client; + + private final Cache>>> referenceCache = + CacheBuilder.newBuilder() + .expireAfterWrite(CACHE_TTL) + .maximumSize(1) + .build(); + + public Mono>> references() { + var cached = referenceCache.getIfPresent(CACHE_KEY); + if (cached != null) { + return cached; + } + var loading = scan() + .cache() + // Never keep a failed scan in the cache. + .doOnError(e -> referenceCache.invalidate(CACHE_KEY)); + referenceCache.put(CACHE_KEY, loading); + return loading; + } + + /** + * Forces a rescan, discarding the cached result. + */ + public Mono>> refresh() { + referenceCache.invalidateAll(); + return references(); + } + + private Mono>> scan() { + return client.listAll(DownloadResource.class, new ListOptions(), Sort.unsorted()) + .map(resource -> resource.getSpec() == null ? null : resource.getSpec().getSlug()) + .filter(StringUtils::isNotBlank) + .distinct() + .collectList() + .flatMap(slugs -> { + if (slugs.isEmpty()) { + return Mono.just(Map.>of()); + } + return client.listAll(Post.class, new ListOptions(), Sort.unsorted()) + .filter(post -> !post.isDeleted() + && post.getSpec() != null + && StringUtils.isNotBlank(post.getSpec().getReleaseSnapshot())) + .flatMap(post -> referencesOfPost(post, slugs) + .onErrorResume(e -> { + log.warn("扫描文章引用失败,跳过: {}", + post.getMetadata().getName(), e); + return Flux.empty(); + }), + SCAN_CONCURRENCY) + .collectList() + .map(PostReferenceService::groupBySlug); + }); + } + + private Flux> referencesOfPost(Post post, List slugs) { + return client.fetch(Snapshot.class, post.getSpec().getReleaseSnapshot()) + .flatMapMany(snapshot -> { + var snapshotSpec = snapshot.getSpec(); + var content = snapshotSpec == null ? null + : StringUtils.firstNonBlank(snapshotSpec.getRawPatch(), + snapshotSpec.getContentPatch()); + if (content == null || !content.contains("/download/")) { + return Flux.empty(); + } + var ref = PostRef.from(post); + return Flux.fromIterable(slugs) + .filter(slug -> content.contains("/download/" + slug)) + .map(slug -> Map.entry(slug, ref)); + }); + } + + private static Map> groupBySlug( + List> entries) { + Map> result = new LinkedHashMap<>(); + for (var entry : entries) { + result.computeIfAbsent(entry.getKey(), key -> new ArrayList<>()) + .add(entry.getValue()); + } + return result; + } + + public record PostRef(String postName, String title, String permalink, + String editorUrl) { + + static PostRef from(Post post) { + var name = post.getMetadata().getName(); + var title = post.getSpec() == null || StringUtils.isBlank(post.getSpec().getTitle()) + ? name : post.getSpec().getTitle(); + var permalink = post.getStatus() == null ? null : post.getStatus().getPermalink(); + return new PostRef(name, title, permalink, + "/console/posts/editor?name=" + name); + } + } +} diff --git a/src/main/java/run/halo/sharelink/util/EmailUtils.java b/src/main/java/run/halo/sharelink/util/EmailUtils.java new file mode 100644 index 0000000..d767fce --- /dev/null +++ b/src/main/java/run/halo/sharelink/util/EmailUtils.java @@ -0,0 +1,52 @@ +package run.halo.sharelink.util; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Locale; +import java.util.regex.Pattern; +import org.apache.commons.lang3.StringUtils; + +/** + * Email normalization and hashing helpers shared by the verification flow. + */ +public final class EmailUtils { + + private static final Pattern EMAIL_PATTERN = + Pattern.compile("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$"); + + private EmailUtils() { + } + + /** + * Normalizes an email address (trimmed, lower-cased). + * + * @return the normalized email, or {@code null} when blank + */ + public static String normalizeEmail(String email) { + if (email == null) { + return null; + } + var normalized = email.trim().toLowerCase(Locale.ROOT); + return StringUtils.isBlank(normalized) ? null : normalized; + } + + public static boolean isValidEmail(String email) { + return email != null && EMAIL_PATTERN.matcher(email).matches(); + } + + /** + * SHA-256 hex of the given value; used as the deterministic metadata name of + * {@code VerifiedDownloader} (and the comment plugin's {@code VerifiedCommenter}). + */ + public static String sha256Hex(String value) { + try { + var digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of() + .formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 algorithm is unavailable", e); + } + } +} diff --git a/src/main/java/run/halo/sharelink/util/IpUtils.java b/src/main/java/run/halo/sharelink/util/IpUtils.java new file mode 100644 index 0000000..3e139aa --- /dev/null +++ b/src/main/java/run/halo/sharelink/util/IpUtils.java @@ -0,0 +1,36 @@ +package run.halo.sharelink.util; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.server.reactive.ServerHttpRequest; + +/** + * Resolves the client IP address of a request: the first {@code X-Forwarded-For} segment + * wins, falling back to the remote address of the connection. + */ +public final class IpUtils { + + private static final String X_FORWARDED_FOR = "X-Forwarded-For"; + + private IpUtils() { + } + + /** + * Resolves the client IP address. + * + * @param request the current request + * @return the client IP, or {@code null} when neither a forwarded header nor a remote + * address is available + */ + public static String clientIp(ServerHttpRequest request) { + var forwardedFor = request.getHeaders().getFirst(X_FORWARDED_FOR); + if (StringUtils.isNotBlank(forwardedFor)) { + return forwardedFor.split(",")[0].trim(); + } + var remoteAddress = request.getRemoteAddress(); + if (remoteAddress == null) { + return null; + } + var address = remoteAddress.getAddress(); + return address != null ? address.getHostAddress() : remoteAddress.getHostString(); + } +} diff --git a/src/main/java/run/halo/sharelink/verify/EmailVerifyEndpoint.java b/src/main/java/run/halo/sharelink/verify/EmailVerifyEndpoint.java new file mode 100644 index 0000000..1389351 --- /dev/null +++ b/src/main/java/run/halo/sharelink/verify/EmailVerifyEndpoint.java @@ -0,0 +1,93 @@ +package run.halo.sharelink.verify; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.reactive.function.server.ServerResponse; +import org.springframework.web.server.ServerWebInputException; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.endpoint.CustomEndpoint; +import run.halo.app.extension.GroupVersion; +import run.halo.sharelink.SharelinkSettingConfigGetter; +import run.halo.sharelink.emailcode.EmailCodeManager; +import run.halo.sharelink.util.EmailUtils; +import run.halo.sharelink.util.IpUtils; + +/** + * Public endpoints for the download email verification flow, mounted under + * {@code /apis/api.sharelink.halo.run/v1alpha1} and opened to anonymous visitors via the + * aggregate-to-anonymous role template. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class EmailVerifyEndpoint implements CustomEndpoint { + + private final EmailCodeManager emailCodeManager; + private final SharelinkSettingConfigGetter settingConfigGetter; + private final VerifiedEmailService verifiedEmailService; + + @Override + public RouterFunction endpoint() { + return RouterFunctions.route() + .POST("email-verify/-/send", this::sendCode) + .POST("email-verify/-/check", this::checkVerified) + .build(); + } + + private Mono sendCode(ServerRequest request) { + return settingConfigGetter.getEmailVerifyConfig() + .flatMap(config -> request.bodyToMono(SendCodeRequest.class) + .switchIfEmpty(Mono.error(new ServerWebInputException("请求体不能为空"))) + .flatMap(body -> { + var email = EmailUtils.normalizeEmail(body.email()); + if (email == null || !EmailUtils.isValidEmail(email)) { + return Mono.error(new ServerWebInputException("邮箱格式不正确")); + } + var clientIp = IpUtils.clientIp(request.exchange().getRequest()); + return emailCodeManager.sendCode(email, clientIp, config) + .then(ServerResponse.ok().bodyValue(new SendCodeResponse(true, + config.getCodeExpireMinutes(), + config.getResendIntervalSeconds()))); + })); + } + + private Mono checkVerified(ServerRequest request) { + return request.bodyToMono(CheckRequest.class) + .switchIfEmpty(Mono.error(new ServerWebInputException("请求体不能为空"))) + .flatMap(body -> { + var email = EmailUtils.normalizeEmail(body.email()); + if (email == null || !EmailUtils.isValidEmail(email)) { + return Mono.error(new ServerWebInputException("邮箱格式不正确")); + } + return verifiedEmailService.isVerified(email) + // 登记查询失败按未验证处理,前端会回退到发送验证码流程。 + .onErrorResume(e -> { + log.warn("查询邮箱验证状态失败,按未验证处理: {}", email, e); + return Mono.just(false); + }) + .flatMap(verified -> ServerResponse.ok() + .bodyValue(new CheckResponse(verified))); + }); + } + + @Override + public GroupVersion groupVersion() { + return GroupVersion.parseAPIVersion("api.sharelink.halo.run/v1alpha1"); + } + + public record SendCodeRequest(String email) { + } + + public record SendCodeResponse(boolean sent, long expireMinutes, long resendAfterSeconds) { + } + + public record CheckRequest(String email) { + } + + public record CheckResponse(boolean verified) { + } +} diff --git a/src/main/java/run/halo/sharelink/verify/VerifiedEmailService.java b/src/main/java/run/halo/sharelink/verify/VerifiedEmailService.java new file mode 100644 index 0000000..9c7a95a --- /dev/null +++ b/src/main/java/run/halo/sharelink/verify/VerifiedEmailService.java @@ -0,0 +1,105 @@ +package run.halo.sharelink.verify; + +import java.time.Instant; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; +import run.halo.app.extension.GroupVersionKind; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.sharelink.SharelinkSettingConfigGetter; +import run.halo.sharelink.model.VerifiedDownloader; +import run.halo.sharelink.util.EmailUtils; + +/** + * Unified "is this email verified" check for the download flow. Looks up this plugin's + * own {@link VerifiedDownloader} registry first; when {@code trustCommentVerified} is + * enabled it also honors the comment plugin's {@code VerifiedCommenter} registry + * (resolved at runtime by GVK + SHA-256 name, degrading gracefully when the comment + * plugin is absent). + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class VerifiedEmailService { + + private static final GroupVersionKind COMMENT_VERIFIED_COMMENTER_GVK = + GroupVersionKind.fromAPIVersionAndKind("commentwidget.halo.run/v1alpha1", + "VerifiedCommenter"); + + private final ReactiveExtensionClient client; + private final SharelinkSettingConfigGetter settingConfigGetter; + + public Mono isVerified(String email) { + var normalizedEmail = EmailUtils.normalizeEmail(email); + if (normalizedEmail == null) { + return Mono.just(false); + } + // Look up by the deterministic metadata name (SHA-256 of the normalized email). + return client.fetch(VerifiedDownloader.class, EmailUtils.sha256Hex(normalizedEmail)) + .map(downloader -> downloader.getSpec() != null + && normalizedEmail.equals(downloader.getSpec().getEmail())) + .defaultIfEmpty(false) + .flatMap(verified -> verified ? Mono.just(true) + : isCommentVerified(normalizedEmail)); + } + + /** + * Checks the comment plugin's verified registry. Any failure (plugin absent, scheme + * unregistered, store error) degrades to "not verified" without affecting the main + * flow. + */ + private Mono isCommentVerified(String normalizedEmail) { + return settingConfigGetter.getEmailVerifyConfig() + .flatMap(config -> { + if (!config.isTrustCommentVerified()) { + return Mono.just(false); + } + return client.fetch(COMMENT_VERIFIED_COMMENTER_GVK, + EmailUtils.sha256Hex(normalizedEmail)) + .map(unstructured -> true) + .defaultIfEmpty(false) + .onErrorResume(e -> { + log.debug("查询评论插件已验证邮箱失败,按未验证处理: {}", normalizedEmail, e); + return Mono.just(false); + }); + }); + } + + /** + * Registers (or refreshes) the email in this plugin's own verified registry. + */ + public Mono recordVerified(String email, String ip) { + var normalizedEmail = EmailUtils.normalizeEmail(email); + if (normalizedEmail == null) { + return Mono.empty(); + } + var name = EmailUtils.sha256Hex(normalizedEmail); + var now = Instant.now(); + // Retried once to tolerate a concurrent registration of the same email. + return Mono.defer(() -> upsertVerified(name, normalizedEmail, now)) + .retry(1) + .then(); + } + + private Mono upsertVerified(String name, String email, Instant now) { + return client.fetch(VerifiedDownloader.class, name) + .flatMap(existing -> { + existing.getSpec().setLastVerifiedAt(now); + return client.update(existing); + }) + .switchIfEmpty(Mono.defer(() -> { + var downloader = new VerifiedDownloader(); + var metadata = new Metadata(); + metadata.setName(name); + downloader.setMetadata(metadata); + var spec = new VerifiedDownloader.Spec(); + spec.setEmail(email); + spec.setFirstVerifiedAt(now); + spec.setLastVerifiedAt(now); + downloader.setSpec(spec); + return client.create(downloader); + })); + } +} diff --git a/src/main/resources/extensions/notification.yaml b/src/main/resources/extensions/notification.yaml new file mode 100644 index 0000000..7426a3d --- /dev/null +++ b/src/main/resources/extensions/notification.yaml @@ -0,0 +1,40 @@ +apiVersion: notification.halo.run/v1alpha1 +kind: ReasonType +metadata: + name: sharelink-download-verification +spec: + displayName: "资源下载邮箱验证" + description: "访客下载需要邮箱验证的资源时,向其邮箱发送数字验证码。" + properties: + - name: username + type: string + - name: code + type: string + - name: expirationAtMinutes + type: string +--- +apiVersion: notification.halo.run/v1alpha1 +kind: NotificationTemplate +metadata: + name: template-sharelink-download-verification +spec: + reasonSelector: + reasonType: sharelink-download-verification + language: default + template: + title: "资源下载验证码-[(${site.title})]" + rawBody: | + 【[(${site.title})]】你的资源下载验证码是:[(${code})],请在 [(${expirationAtMinutes})] 分钟内完成验证。若不是你本人操作,请忽略。 + htmlBody: | +

+
+

+
+
+

你正在 [(${site.title})] 下载资源,使用下面的验证码验证邮箱:

+
+ +
+

+
+
diff --git a/src/main/resources/extensions/role-templates.yaml b/src/main/resources/extensions/role-templates.yaml new file mode 100644 index 0000000..4fce8e5 --- /dev/null +++ b/src/main/resources/extensions/role-templates.yaml @@ -0,0 +1,67 @@ +apiVersion: v1alpha1 +kind: Role +metadata: + name: sharelink-public-apis + labels: + halo.run/role-template: "true" + halo.run/hidden: "true" + rbac.authorization.halo.run/aggregate-to-anonymous: "true" + annotations: + rbac.authorization.halo.run/module: "资源下载管理" + rbac.authorization.halo.run/display-name: "Sharelink Public APIs" +rules: + - apiGroups: [ "api.sharelink.halo.run" ] + resources: [ "email-verify/send" ] + verbs: [ "create" ] + - apiGroups: [ "api.sharelink.halo.run" ] + resources: [ "email-verify/check" ] + verbs: [ "create" ] + - apiGroups: [ "api.sharelink.halo.run" ] + resources: [ "downloads/token" ] + verbs: [ "create" ] +--- +apiVersion: v1alpha1 +kind: Role +metadata: + name: role-template-sharelink-view + labels: + halo.run/role-template: "true" + annotations: + rbac.authorization.halo.run/module: "资源下载管理" + rbac.authorization.halo.run/display-name: "下载资源查看" + rbac.authorization.halo.run/ui-permissions: '["plugin:sharelink:view"]' +rules: + - apiGroups: [ "console.api.sharelink.halo.run" ] + resources: [ "download-resources", "download-records", "references" ] + verbs: [ "get", "list" ] + - apiGroups: [ "console.api.sharelink.halo.run" ] + resources: [ "download-records/export" ] + resourceNames: [ "-" ] + verbs: [ "get" ] + # 引用扫描需要读取已发布文章及其快照内容 + - apiGroups: [ "content.halo.run" ] + resources: [ "posts", "snapshots" ] + verbs: [ "get", "list" ] +--- +apiVersion: v1alpha1 +kind: Role +metadata: + name: role-template-sharelink-manage + labels: + halo.run/role-template: "true" + annotations: + rbac.authorization.halo.run/module: "资源下载管理" + rbac.authorization.halo.run/display-name: "下载资源管理" + rbac.authorization.halo.run/ui-permissions: '["plugin:sharelink:manage"]' + rbac.authorization.halo.run/dependencies: '["role-template-sharelink-view"]' +rules: + - apiGroups: [ "console.api.sharelink.halo.run" ] + resources: [ "download-resources" ] + verbs: [ "create", "update", "delete" ] + - apiGroups: [ "console.api.sharelink.halo.run" ] + resources: [ "download-records" ] + verbs: [ "delete" ] + - apiGroups: [ "console.api.sharelink.halo.run" ] + resources: [ "references/refresh" ] + resourceNames: [ "-" ] + verbs: [ "create" ] diff --git a/src/main/resources/extensions/settings.yaml b/src/main/resources/extensions/settings.yaml new file mode 100644 index 0000000..faf7c90 --- /dev/null +++ b/src/main/resources/extensions/settings.yaml @@ -0,0 +1,75 @@ +apiVersion: v1alpha1 +kind: Setting +metadata: + name: sharelink-settings +spec: + forms: + - group: basic + label: 基本设置 + formSchema: + - $formkit: number + name: tokenTtlSeconds + key: tokenTtlSeconds + label: 下载令牌有效期(秒) + help: 访客在下载页获取下载令牌后,需在该时间内开始下载。 + min: 10 + max: 600 + value: 60 + validation: required + - $formkit: number + name: dedupeWindowMinutes + key: dedupeWindowMinutes + label: 免验证资源下载去重窗口(分钟) + help: 不需要邮箱验证的资源,同一 IP 在该时间窗口内重复下载只计 1 次。 + min: 0 + max: 1440 + value: 10 + validation: required + - group: emailVerify + label: 邮箱验证 + formSchema: + - $formkit: number + name: codeExpireMinutes + key: codeExpireMinutes + label: 验证码有效期(分钟) + min: 1 + max: 30 + value: 10 + validation: required + - $formkit: number + name: resendIntervalSeconds + key: resendIntervalSeconds + label: 重发间隔(秒) + min: 30 + value: 60 + validation: required + - $formkit: number + name: dailySendLimitPerEmail + key: dailySendLimitPerEmail + label: 同一邮箱每日发送上限 + min: 1 + max: 50 + value: 5 + validation: required + - $formkit: number + name: maxVerifyAttempts + key: maxVerifyAttempts + label: 验证码最大错误尝试次数 + min: 1 + max: 10 + value: 5 + validation: required + - $formkit: number + name: ipHourlySendLimit + key: ipHourlySendLimit + label: 同一 IP 每小时发送上限 + min: 1 + max: 200 + value: 20 + validation: required + - $formkit: checkbox + name: trustCommentVerified + key: trustCommentVerified + label: 信任评论插件已验证的邮箱 + help: 开启后,已在评论组件中通过邮箱验证的访客,下载需要验证的资源时无需再次验证(需安装并启用评论组件)。 + value: true diff --git a/src/main/resources/logo.svg b/src/main/resources/logo.svg new file mode 100644 index 0000000..a5f1aee --- /dev/null +++ b/src/main/resources/logo.svg @@ -0,0 +1 @@ + diff --git a/src/main/resources/plugin.yaml b/src/main/resources/plugin.yaml new file mode 100644 index 0000000..7945931 --- /dev/null +++ b/src/main/resources/plugin.yaml @@ -0,0 +1,20 @@ +apiVersion: plugin.halo.run/v1alpha1 +kind: Plugin +metadata: + name: sharelink +spec: + enabled: true + requires: ">=2.22.0" + author: + name: ssy + website: https://git.ali.songshiyu.cn/ssy + logo: logo.svg + homepage: https://git.ali.songshiyu.cn/ssy/sharelink + repo: https://git.ali.songshiyu.cn/ssy/sharelink + issues: https://git.ali.songshiyu.cn/ssy/sharelink/issues + displayName: "资源下载管理" + description: "管理文章中的资源下载链接:下载统计、按资源的邮箱验证、防止附件直链下载、文章引用扫描。" + configMapName: sharelink-configmap + settingName: sharelink-settings + license: + - name: "GPL-3.0" diff --git a/ui/.npmrc b/ui/.npmrc new file mode 100644 index 0000000..38f11c6 --- /dev/null +++ b/ui/.npmrc @@ -0,0 +1 @@ +registry=https://registry.npmjs.org diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 0000000..f87b8d0 --- /dev/null +++ b/ui/README.md @@ -0,0 +1,59 @@ +# sharelink console UI + +Halo 2.x 插件「资源下载管理」的 console 后台前端(Vue 3 + rsbuild + +@halo-dev/ui-plugin-bundler-kit)。工程结构照搬 +`blog-comment/packages/ui`。 + +## 命令 + +```bash +pnpm install +pnpm build # 产物输出到 ../src/main/resources/console(打进插件 jar) +pnpm dev # watch 模式,输出到 ../build/resources/main/console +pnpm type-check # vue-tsc 类型检查(构建流程不依赖) +``` + +## 路由与权限 + +- 路由:`/download-manager`,挂在 `Root` 下,菜单组 `content`,priority 52 +- 权限 meta:`plugin:sharelink:view`(见 + `src/main/resources/extensions/role-templates.yaml` 的 + `role-template-sharelink-view`) +- 注意:角色的 `ui-permissions` 里还定义了 `plugin:sharelink:manage`,但 + console 菜单只挂了 view 权限;前端未对 manage 操作做按钮级权限隐藏, + 写操作是否放行完全由后端 RBAC 决定。 + +## API 契约(后端并行开发中) + +Base:`/apis/console.api.sharelink.halo.run/v1alpha1` + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/download-resources` | `{ items: [...], total }`(非标准 ListResult) | +| POST | `/download-resources` | 创建,**前端只提交 `{ metadata: { name: '' }, spec }`,name 由后端生成** | +| GET/PUT/DELETE | `/download-resources/{name}` | 单资源读写删,PUT 提交完整对象 | +| GET | `/download-records?resourceSlug=&page=&size=` | Halo 标准 ListResult | +| DELETE | `/download-records/{name}` | 删除单条记录 | +| GET | `/download-records/-/export?resourceSlug=` | CSV,前端用 `window.open` 直接导航下载 | +| GET | `/references` | `{ slug: [{ postName, title, permalink, editorUrl }] }` | +| POST | `/references/-/refresh` | 强制重扫,返回同上 | + +## 前端字段假设(联调时若与后端不一致,以此为准核对) + +1. `DownloadResource.spec`:`slug`、`displayName`、`description`、 + `attachmentName`、`requireEmailVerify`、`enabled`;`status.downloadCount`; + `stats.downloaderCount`、`stats.referenceCount`。其中 `stats` 是 Halo + 自定义资源里不常见的顶层字段(与 `status` 平级),若后端改为放进 + `status`,需同步修改 `src/types.ts` 和 `DownloadManager.vue` 的两处取值。 +2. 列表 GET `/download-resources` 返回 `{ items, total }`,不带分页参数, + 前端一次性拉全量。 +3. `references` 的 key 是 **slug**(不是 metadata.name)。 +4. 引用项 `editorUrl` / `permalink` 均为可直接打开的相对或绝对路径, + 前端 `target="_blank"` 打开。 +5. 下载记录 `spec.downloadedAt` 为 ISO 时间字符串;`spec.email` 可能为空 + (匿名下载)。 +6. CSV 导出端点接受 session cookie 直接 GET 导航,无需 axios blob 下载。 +7. 附件选择:`AttachmentSelectorModal` 是 Halo Console **全局注册**的业务 + 组件(不从 `@halo-dev/components` 导出),`v-model:visible` 控制显隐, + `@select` 回调附件数组,前端存 `attachment.metadata.name` 到 + `spec.attachmentName`。 diff --git a/ui/env.d.ts b/ui/env.d.ts new file mode 100644 index 0000000..e03aa3d --- /dev/null +++ b/ui/env.d.ts @@ -0,0 +1,23 @@ +/// + +// AttachmentSelectorModal 是 Halo Console 全局注册的业务组件(非 @halo-dev/components 导出), +// 见 https://docs.halo.run/developer-guide/plugin/api-reference/ui/components/attachment-selector-modal/ +declare module 'vue' { + interface GlobalComponents { + AttachmentSelectorModal: import('vue').DefineComponent< + { visible?: boolean }, + {}, + {}, + {}, + {}, + import('vue').ComponentOptionsMixin, + import('vue').ComponentOptionsMixin, + { + 'update:visible': (value: boolean) => void; + select: (attachments: unknown[]) => void; + } + >; + } +} + +export {}; diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..c54c00e --- /dev/null +++ b/ui/package.json @@ -0,0 +1,26 @@ +{ + "name": "sharelink-console-ui", + "type": "module", + "private": true, + "scripts": { + "build": "rsbuild build", + "dev": "rsbuild build --watch --env-mode=development", + "type-check": "vue-tsc --build" + }, + "dependencies": { + "@halo-dev/api-client": "2.23.0", + "@halo-dev/components": "^2.21.0", + "@halo-dev/ui-shared": "^2.22.0", + "pinia": "^3.0.4", + "vue": "^3.5.24" + }, + "devDependencies": { + "@halo-dev/ui-plugin-bundler-kit": "^2.21.2", + "@rsbuild/core": "^2.0.3", + "@rsbuild/plugin-vue": "^1.2.7", + "@types/node": "^20.19.24", + "@vue/tsconfig": "^0.7.0", + "typescript": "~5.8.3", + "vue-tsc": "^2.2.12" + } +} diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml new file mode 100644 index 0000000..1a1336b --- /dev/null +++ b/ui/pnpm-lock.yaml @@ -0,0 +1,2190 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@halo-dev/api-client': + specifier: 2.23.0 + version: 2.23.0(axios@1.19.0) + '@halo-dev/components': + specifier: ^2.21.0 + version: 2.25.2(vue-router@5.2.0(@rspack/core@2.1.7(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.40)(pinia@3.0.4(typescript@5.8.3)(vue@3.5.40(typescript@5.8.3)))(rolldown@1.2.1)(vite@8.2.0(@types/node@20.19.43)(yaml@2.9.0))(vue@3.5.40(typescript@5.8.3)))(vue@3.5.40(typescript@5.8.3)) + '@halo-dev/ui-shared': + specifier: ^2.22.0 + version: 2.25.2(@tiptap/pm@3.29.2)(axios@1.19.0)(vue-router@5.2.0(@rspack/core@2.1.7(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.40)(pinia@3.0.4(typescript@5.8.3)(vue@3.5.40(typescript@5.8.3)))(rolldown@1.2.1)(vite@8.2.0(@types/node@20.19.43)(yaml@2.9.0))(vue@3.5.40(typescript@5.8.3)))(vue@3.5.40(typescript@5.8.3)) + pinia: + specifier: ^3.0.4 + version: 3.0.4(typescript@5.8.3)(vue@3.5.40(typescript@5.8.3)) + vue: + specifier: ^3.5.24 + version: 3.5.40(typescript@5.8.3) + devDependencies: + '@halo-dev/ui-plugin-bundler-kit': + specifier: ^2.21.2 + version: 2.25.2(@rsbuild/core@2.1.9)(@rsbuild/plugin-vue@1.2.9(@rsbuild/core@2.1.9)(@rspack/core@2.1.7(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.40)(vue@3.5.40(typescript@5.8.3)))(@vitejs/plugin-vue@6.0.8(vite@8.2.0(@types/node@20.19.43)(yaml@2.9.0))(vue@3.5.40(typescript@5.8.3)))(axios@1.19.0)(vite@8.2.0(@types/node@20.19.43)(yaml@2.9.0)) + '@rsbuild/core': + specifier: ^2.0.3 + version: 2.1.9 + '@rsbuild/plugin-vue': + specifier: ^1.2.7 + version: 1.2.9(@rsbuild/core@2.1.9)(@rspack/core@2.1.7(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.40)(vue@3.5.40(typescript@5.8.3)) + '@types/node': + specifier: ^20.19.24 + version: 20.19.43 + '@vue/tsconfig': + specifier: ^0.7.0 + version: 0.7.0(typescript@5.8.3)(vue@3.5.40(typescript@5.8.3)) + typescript: + specifier: ~5.8.3 + version: 5.8.3 + vue-tsc: + specifier: ^2.2.12 + version: 2.2.12(typescript@5.8.3) + +packages: + + '@babel/generator@8.0.0': + resolution: {integrity: sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@8.0.0': + resolution: {integrity: sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@8.0.4': + resolution: {integrity: sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/parser@8.0.4': + resolution: {integrity: sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==} + engines: {node: ^22.18.0 || >=24.11.0} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@babel/types@8.0.4': + resolution: {integrity: sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@emnapi/core@1.11.2': + resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + + '@emnapi/core@2.0.0-alpha.3': + resolution: {integrity: sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==} + + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + + '@emnapi/runtime@2.0.0-alpha.3': + resolution: {integrity: sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@emnapi/wasi-threads@2.0.1': + resolution: {integrity: sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==} + + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.1.1': + resolution: {integrity: sha512-TpIO93+DIujg3g7SykEAGZMDtbJRrmnYRCNYSjJlvIbGhBjRSNTLVbNeDQBrzy9qDgUbiWdc7KA0uZHZ2tJmiw==} + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + + '@halo-dev/api-client@2.23.0': + resolution: {integrity: sha512-uws5E1RkiSSj23XwyTLdmpDWt1QCPfeiAPVetrOlGt/bkSWhPDSZucvZQ3/EW4r1PzXy5SV/lynULyBDc2Dc1w==} + peerDependencies: + axios: ^1.12.* + + '@halo-dev/api-client@2.25.2': + resolution: {integrity: sha512-9FkxGu1N4Ct5TuSXyR8ktY6HzpDbgpFqqC67OqpSCGsX45/8iOm4+oXBqbHAhCWrRbMh1zHPxIE4zadWh7H1Qw==} + peerDependencies: + axios: ^1.16.0 + + '@halo-dev/components@2.25.2': + resolution: {integrity: sha512-54DqfDQE+6yaqObUKNvIiVvxaa33WorNe/RH/Zke4I2+0GGG1NKt8sfp4asQlnMjSTXs95yCLgT1Hju3QRiaTg==} + peerDependencies: + vue: ^3.5.x + vue-router: ^5.0.x + + '@halo-dev/ui-plugin-bundler-kit@2.25.2': + resolution: {integrity: sha512-IfauzRYtfghF53p1qYwTWXuxWTEQW+k5RWjWENou68uLj0R6nuAkxWo577EWNanPxmh4+P3bUFu+a4JGwKjXOg==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + '@rsbuild/core': ^1.0.0 || ^2.0.0 + '@rsbuild/plugin-vue': ^1.0.0 || ^2.0.0 + '@vitejs/plugin-vue': ^5.0.0 || ^6.0.0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + + '@halo-dev/ui-shared@2.25.2': + resolution: {integrity: sha512-5VlIzlhrENXkc3s6FujTjN3jDIXCISLm4YtXc5/WBecSQaKlavE5N06ixDccuTVMs3sztRST6TdgvNRcMLEN6g==} + peerDependencies: + vue: ^3.5.x + vue-router: ^5.0.x + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@napi-rs/wasm-runtime@1.2.2': + resolution: {integrity: sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 + + '@oxc-project/types@0.142.0': + resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} + + '@rolldown/binding-android-arm64@1.2.1': + resolution: {integrity: sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.1': + resolution: {integrity: sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.1': + resolution: {integrity: sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.1': + resolution: {integrity: sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.1': + resolution: {integrity: sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.1': + resolution: {integrity: sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.2.1': + resolution: {integrity: sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.2.1': + resolution: {integrity: sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.2.1': + resolution: {integrity: sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.2.1': + resolution: {integrity: sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.2.1': + resolution: {integrity: sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.2.1': + resolution: {integrity: sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.2.1': + resolution: {integrity: sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + + '@rolldown/binding-win32-arm64-msvc@1.2.1': + resolution: {integrity: sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.1': + resolution: {integrity: sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@rsbuild/core@2.1.9': + resolution: {integrity: sha512-yqf1hFZ3wbMYI431LqsxLH3r0VZkfyarVKTf7kMeIiGe0YLwsrgsfp+sKpIyVkQkq60J0qyp6l/CoqqsQZqEwQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + core-js: '>= 3.0.0' + peerDependenciesMeta: + core-js: + optional: true + + '@rsbuild/plugin-vue@1.2.9': + resolution: {integrity: sha512-ufD4eufkZZlNK3iY5NWg57AMK9qrTFmHycDkSTfRTua4Qczf7q9s7rCLYVfVv2w36cOXJIewM+376jD0zaQShQ==} + peerDependencies: + '@rsbuild/core': ^1.0.0 || ^2.0.0-0 + peerDependenciesMeta: + '@rsbuild/core': + optional: true + + '@rspack/binding-darwin-arm64@2.1.7': + resolution: {integrity: sha512-DwxzrXRctueP/3Pyom9JHcIsRShuEAlHb+mrE5OPT+4cdHI1UnJpbzEvEDLTo4IKJhDb3vjXdHLtjqtL0SYbeA==} + cpu: [arm64] + os: [darwin] + + '@rspack/binding-darwin-x64@2.1.7': + resolution: {integrity: sha512-kPbrYvR/XUHfAMgRVq3QnC71DW/qjwsPj+3hEUuEnRmlploPNy9u8Szf1IHKSVUSrVZBTgDyMoZQdxYLfhResw==} + cpu: [x64] + os: [darwin] + + '@rspack/binding-linux-arm64-gnu@2.1.7': + resolution: {integrity: sha512-VFB+YXM3kZ6IIuLV64H3vgnwqvQIIaqfR/aeGwuxYvwcZsrgblSBmXMeDULdgDjqP8Yr0VaFMBBiD9OtG5KdFw==} + cpu: [arm64] + os: [linux] + + '@rspack/binding-linux-arm64-musl@2.1.7': + resolution: {integrity: sha512-Mzbxyg0aJ+ITj526Iuz0enEDYY6WxhFIwEKXqwjQh+Vpd5v/+aPzPo83sSQVX/3puBV1sbmviTURbh6N9e1fvA==} + cpu: [arm64] + os: [linux] + + '@rspack/binding-linux-riscv64-gnu@2.1.7': + resolution: {integrity: sha512-mpazwgT/Pse1720mvEJsoXfPkJ+enj0xUqpbe/wL6aedwjGT+9jJNB8HTJXE4XBX0UO7umGqcJMeKA6YsD2CDA==} + cpu: [riscv64] + os: [linux] + + '@rspack/binding-linux-riscv64-musl@2.1.7': + resolution: {integrity: sha512-oU/l3soPRsDEWn7KZic+npyTMM2N1kRdHjoJ+L5IUBXs8bjdTXPLoyTbTdIOza5ZSoT4+UeEiEryj4BB0tQE5w==} + cpu: [riscv64] + os: [linux] + + '@rspack/binding-linux-x64-gnu@2.1.7': + resolution: {integrity: sha512-7Gtpl3h3jtnOpk1mYQE8mRndXAO2ibI8mnAbs7klevdKey+ZHneWMoMi2yOMQhhI/ifWEFxDzyGJ8bdxo0XTsA==} + cpu: [x64] + os: [linux] + + '@rspack/binding-linux-x64-musl@2.1.7': + resolution: {integrity: sha512-w+whI2Uy+DYkGN+MVkzMFWweL7B/s1gMqX+nvTE1vhOy3hGV0VyA9H6lqWjSD3I+eGkpYhN9Pr244cYnLpZOUQ==} + cpu: [x64] + os: [linux] + + '@rspack/binding-wasm32-wasi@2.1.7': + resolution: {integrity: sha512-cDVgvzRdTgxaeM+a5Lx0+7/VAvunvwO0wNtQ3ATQGOtFCW5b7cUzhNPcytH5ZSJTnFWuxinlGwtar5yfcnkdZQ==} + cpu: [wasm32] + + '@rspack/binding-win32-arm64-msvc@2.1.7': + resolution: {integrity: sha512-JDd85+iYwUvaG9Zrt5X7oIxRZRiTW+76FwkRakoXNy/5VAWQW32Jq4ESjSVz6l6mh0KnZxPq3TLMugacCPnLjw==} + cpu: [arm64] + os: [win32] + + '@rspack/binding-win32-ia32-msvc@2.1.7': + resolution: {integrity: sha512-y9PKEs6v9BLHV0i/4eaIRtxpATvSgcf/VYQkMT8mp+qWlPjUwDQNwU2ueWVGpff6INO+YAa7zobzziNFRgO7Lg==} + cpu: [ia32] + os: [win32] + + '@rspack/binding-win32-x64-msvc@2.1.7': + resolution: {integrity: sha512-BjkOzcPY/K8YlRRvyywz0mDWk89MMxqAMhDmgBXCWorh1IjgKTsWDJ2lCGIM8M9CZXUG3khom8AfrOGwRT2I+g==} + cpu: [x64] + os: [win32] + + '@rspack/binding@2.1.7': + resolution: {integrity: sha512-wYqi8TY30hsIzLry503o/Uqu7y9Ec7pEwN5TVmB7Pb3xHrR2eHsQPzdpF/GkCLUjQSgD2Es3CDVV1mr6zO/78g==} + + '@rspack/core@2.1.7': + resolution: {integrity: sha512-d5Ju3zXzGgbqQWvlMlLUtek2eFPIzsFe2QOF4nwTAknxo/4OZ64t+kPT9nM6fr3aZX93VK0R3v02/kZYIRrV9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@module-federation/runtime-tools': ^0.24.1 || ^2.0.0 + '@swc/helpers': ^0.5.23 + peerDependenciesMeta: + '@module-federation/runtime-tools': + optional: true + '@swc/helpers': + optional: true + + '@rspack/lite-tapable@1.1.4': + resolution: {integrity: sha512-ZXl+NUVMkLCmi5aLXgM9I8EQ4/UH4tfhQHuZhwKSR8MUOVT1q/LuFs8No4B0UZ512WGruEUhq4FjW3yheKvIhg==} + + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + + '@tiptap/core@3.29.2': + resolution: {integrity: sha512-oKUkiPUB7noilVYxI9lNzUD4rX17sHub+PYjMfHMWHG9A3nvIy+FdePIVIIhThKWF7ijhr3eIqHY51Bn+GAFtw==} + peerDependencies: + '@tiptap/pm': 3.29.2 + + '@tiptap/pm@3.29.2': + resolution: {integrity: sha512-GCOme7xHaS+DSoaA4CDcAD3l6JyBlvZhvCyfsy2Vp6j8tEoBkZWio7soYVosmlyn7zq8/64VeFZP5s47yfG7fQ==} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/jsesc@2.5.1': + resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} + + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + + '@vitejs/plugin-vue@6.0.8': + resolution: {integrity: sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vue: ^3.2.25 + + '@volar/language-core@2.4.15': + resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==} + + '@volar/source-map@2.4.15': + resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==} + + '@volar/typescript@2.4.15': + resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==} + + '@vue-macros/common@3.1.4': + resolution: {integrity: sha512-/5Fv+6DgIcM9ajY05ZmKBv+LMX1M9A0X+IUwDRVdt67ciw8OV9bvG2r34p3RiEadlsQybjhKPRKNXDC8Bp23cw==} + engines: {node: '>=20.19.0'} + peerDependencies: + vue: ^2.7.0 || ^3.2.25 + peerDependenciesMeta: + vue: + optional: true + + '@vue/compiler-core@3.5.40': + resolution: {integrity: sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==} + + '@vue/compiler-dom@3.5.40': + resolution: {integrity: sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==} + + '@vue/compiler-sfc@3.5.40': + resolution: {integrity: sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==} + + '@vue/compiler-ssr@3.5.40': + resolution: {integrity: sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==} + + '@vue/compiler-vue2@2.7.16': + resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} + + '@vue/devtools-api@7.7.10': + resolution: {integrity: sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==} + + '@vue/devtools-api@8.2.1': + resolution: {integrity: sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A==} + + '@vue/devtools-kit@7.7.10': + resolution: {integrity: sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==} + + '@vue/devtools-kit@8.2.1': + resolution: {integrity: sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ==} + + '@vue/devtools-shared@7.7.10': + resolution: {integrity: sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==} + + '@vue/devtools-shared@8.2.1': + resolution: {integrity: sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==} + + '@vue/language-core@2.2.12': + resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/reactivity@3.5.40': + resolution: {integrity: sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==} + + '@vue/runtime-core@3.5.40': + resolution: {integrity: sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==} + + '@vue/runtime-dom@3.5.40': + resolution: {integrity: sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==} + + '@vue/server-renderer@3.5.40': + resolution: {integrity: sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==} + + '@vue/shared@3.5.40': + resolution: {integrity: sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==} + + '@vue/tsconfig@0.7.0': + resolution: {integrity: sha512-ku2uNz5MaZ9IerPPUyOHzyjhXoX2kVJaVf7hL315DC17vS6IiZRmmCPfggNbU16QTvM80+uYYy3eYJB59WCtvg==} + peerDependencies: + typescript: 5.x + vue: ^3.4.0 + peerDependenciesMeta: + typescript: + optional: true + vue: + optional: true + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + alien-signals@1.0.13: + resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + ast-kit@2.2.0: + resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==} + engines: {node: '>=20.19.0'} + + ast-walker-scope@0.9.0: + resolution: {integrity: sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A==} + engines: {node: '>=20.19.0'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.19.0: + resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + copy-anything@4.0.5: + resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + exsolve@1.1.1: + resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + floating-vue@5.2.2: + resolution: {integrity: sha512-afW+h2CFafo+7Y9Lvw/xsqjaQlKLdJV7h1fCHfcYQ1C4SVMlu7OAekqWgu5d4SgvkBVU0pVpLlVsrSTBURFRkg==} + peerDependencies: + '@nuxt/kit': ^3.2.0 + vue: ^3.2.0 + peerDependenciesMeta: + '@nuxt/kit': + optional: true + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + is-what@5.5.0: + resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} + engines: {node: '>=18'} + + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + local-pkg@1.2.1: + resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==} + engines: {node: '>=14'} + + magic-string-ast@1.0.3: + resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==} + engines: {node: '>=20.19.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nostics@1.2.0: + resolution: {integrity: sha512-FGqEfhQjrvo1lL8KFifdTQiNwwQHJxC1jtYE1Rc54qF/jxONUNL+kC9gS1krX8Q65PgrQ5fCqH/I4NhWBvdSqg==} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + orderedmap@2.1.1: + resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pinia@3.0.4: + resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==} + peerDependencies: + typescript: '>=4.5.0' + vue: ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + + postcss@8.5.25: + resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} + engines: {node: ^10 || ^12 || >=14} + + prosemirror-changeset@2.4.1: + resolution: {integrity: sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==} + + prosemirror-commands@1.7.1: + resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==} + + prosemirror-dropcursor@1.8.3: + resolution: {integrity: sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==} + + prosemirror-gapcursor@1.4.1: + resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==} + + prosemirror-history@1.5.0: + resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==} + + prosemirror-inputrules@1.5.1: + resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==} + + prosemirror-keymap@1.2.3: + resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} + + prosemirror-model@1.25.11: + resolution: {integrity: sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==} + + prosemirror-schema-list@1.5.1: + resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} + + prosemirror-state@1.4.4: + resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==} + + prosemirror-tables@1.8.5: + resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==} + + prosemirror-transform@1.12.0: + resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==} + + prosemirror-view@1.42.2: + resolution: {integrity: sha512-Pdg0l5kXm8aLDquFAnQFTCITg0q44sLqBlHlpsVLD9segdOao8TOfQdAhCrCXyVgPSRr6UDDROOIWA3bIrN9YQ==} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rolldown@1.2.1: + resolution: {integrity: sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rope-sequence@1.3.4: + resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + + rspack-vue-loader@17.6.1: + resolution: {integrity: sha512-jeeqh2wLcJLTd6AwYJjiEv62Bo6A7+OH4c7kGi4S2BF3E996cLtdY6ybAu8lG82EyUcr2lgV3kHoRnfD5QPUgA==} + peerDependencies: + '@rspack/core': ^1.0.0 || ^2.0.0 + '@vue/compiler-sfc': '*' + vue: '*' + peerDependenciesMeta: + '@rspack/core': + optional: true + '@vue/compiler-sfc': + optional: true + vue: + optional: true + + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} + engines: {node: '>=0.10.0'} + + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unplugin-utils@0.3.2: + resolution: {integrity: sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==} + engines: {node: '>=20.19.0'} + + unplugin@3.3.0: + resolution: {integrity: sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@farmfe/core': '*' + '@rspack/core': '*' + bun-types-no-globals: '*' + esbuild: '*' + rolldown: '*' + rollup: '*' + unloader: '*' + vite: '*' + webpack: '*' + peerDependenciesMeta: + '@farmfe/core': + optional: true + '@rspack/core': + optional: true + bun-types-no-globals: + optional: true + esbuild: + optional: true + rolldown: + optional: true + rollup: + optional: true + unloader: + optional: true + vite: + optional: true + webpack: + optional: true + + vite@8.2.0: + resolution: {integrity: sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + vue-resize@2.0.0-alpha.1: + resolution: {integrity: sha512-7+iqOueLU7uc9NrMfrzbG8hwMqchfVfSzpVlCMeJQe4pyibqyoifDNbKTZvwxZKDvGkB+PdFeKvnGZMoEb8esg==} + peerDependencies: + vue: ^3.0.0 + + vue-router@5.2.0: + resolution: {integrity: sha512-QAC5i0LEb1GLG0LXDQmHu8L7FX12j0KwU/JTKmLQUJMrn04gQdKP6Du+p0QwpHb3iy71vBlqnHQ8WAfOSAWhqw==} + peerDependencies: + '@pinia/colada': '>=0.21.2' + '@vue/compiler-sfc': ^3.5.34 || ^4.0.0 + pinia: ^3.0.4 || ^4.0.2 + vite: ^7.3.0 || ^8.0.0 + vue: ^3.5.34 || ^4.0.0 + peerDependenciesMeta: + '@pinia/colada': + optional: true + '@vue/compiler-sfc': + optional: true + pinia: + optional: true + vite: + optional: true + + vue-tsc@2.2.12: + resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue@3.5.40: + resolution: {integrity: sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + +snapshots: + + '@babel/generator@8.0.0': + dependencies: + '@babel/parser': 8.0.4 + '@babel/types': 8.0.4 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@types/jsesc': 2.5.1 + jsesc: 3.1.0 + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-string-parser@8.0.0': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-identifier@8.0.4': {} + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/parser@8.0.4': + dependencies: + '@babel/types': 8.0.4 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@babel/types@8.0.4': + dependencies: + '@babel/helper-string-parser': 8.0.0 + '@babel/helper-validator-identifier': 8.0.4 + + '@emnapi/core@1.11.2': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/core@2.0.0-alpha.3': + dependencies: + '@emnapi/wasi-threads': 2.0.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@2.0.0-alpha.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@2.0.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.1.1': + dependencies: + '@floating-ui/core': 1.8.0 + + '@floating-ui/utils@0.2.12': {} + + '@halo-dev/api-client@2.23.0(axios@1.19.0)': + dependencies: + axios: 1.19.0 + qs: 6.15.3 + + '@halo-dev/api-client@2.25.2(axios@1.19.0)': + dependencies: + axios: 1.19.0 + qs: 6.15.3 + + '@halo-dev/components@2.25.2(vue-router@5.2.0(@rspack/core@2.1.7(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.40)(pinia@3.0.4(typescript@5.8.3)(vue@3.5.40(typescript@5.8.3)))(rolldown@1.2.1)(vite@8.2.0(@types/node@20.19.43)(yaml@2.9.0))(vue@3.5.40(typescript@5.8.3)))(vue@3.5.40(typescript@5.8.3))': + dependencies: + floating-vue: 5.2.2(vue@3.5.40(typescript@5.8.3)) + vue: 3.5.40(typescript@5.8.3) + vue-router: 5.2.0(@rspack/core@2.1.7(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.40)(pinia@3.0.4(typescript@5.8.3)(vue@3.5.40(typescript@5.8.3)))(rolldown@1.2.1)(vite@8.2.0(@types/node@20.19.43)(yaml@2.9.0))(vue@3.5.40(typescript@5.8.3)) + transitivePeerDependencies: + - '@nuxt/kit' + + '@halo-dev/ui-plugin-bundler-kit@2.25.2(@rsbuild/core@2.1.9)(@rsbuild/plugin-vue@1.2.9(@rsbuild/core@2.1.9)(@rspack/core@2.1.7(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.40)(vue@3.5.40(typescript@5.8.3)))(@vitejs/plugin-vue@6.0.8(vite@8.2.0(@types/node@20.19.43)(yaml@2.9.0))(vue@3.5.40(typescript@5.8.3)))(axios@1.19.0)(vite@8.2.0(@types/node@20.19.43)(yaml@2.9.0))': + dependencies: + '@halo-dev/api-client': 2.25.2(axios@1.19.0) + '@rsbuild/core': 2.1.9 + '@rsbuild/plugin-vue': 1.2.9(@rsbuild/core@2.1.9)(@rspack/core@2.1.7(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.40)(vue@3.5.40(typescript@5.8.3)) + '@vitejs/plugin-vue': 6.0.8(vite@8.2.0(@types/node@20.19.43)(yaml@2.9.0))(vue@3.5.40(typescript@5.8.3)) + js-yaml: 4.3.0 + semver: 7.8.5 + vite: 8.2.0(@types/node@20.19.43)(yaml@2.9.0) + transitivePeerDependencies: + - axios + + '@halo-dev/ui-shared@2.25.2(@tiptap/pm@3.29.2)(axios@1.19.0)(vue-router@5.2.0(@rspack/core@2.1.7(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.40)(pinia@3.0.4(typescript@5.8.3)(vue@3.5.40(typescript@5.8.3)))(rolldown@1.2.1)(vite@8.2.0(@types/node@20.19.43)(yaml@2.9.0))(vue@3.5.40(typescript@5.8.3)))(vue@3.5.40(typescript@5.8.3))': + dependencies: + '@halo-dev/api-client': 2.25.2(axios@1.19.0) + '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) + mitt: 3.0.1 + vue: 3.5.40(typescript@5.8.3) + vue-router: 5.2.0(@rspack/core@2.1.7(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.40)(pinia@3.0.4(typescript@5.8.3)(vue@3.5.40(typescript@5.8.3)))(rolldown@1.2.1)(vite@8.2.0(@types/node@20.19.43)(yaml@2.9.0))(vue@3.5.40(typescript@5.8.3)) + transitivePeerDependencies: + - '@tiptap/pm' + - axios + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)': + dependencies: + '@emnapi/core': 2.0.0-alpha.3 + '@emnapi/runtime': 2.0.0-alpha.3 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/types@0.142.0': {} + + '@rolldown/binding-android-arm64@1.2.1': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.1': + optional: true + + '@rolldown/binding-darwin-x64@1.2.1': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.1': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.1': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.1': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.1': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.1': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.1': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.1': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.1': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.1': + optional: true + + '@rolldown/binding-wasm32-wasi@1.2.1': + dependencies: + '@emnapi/core': 2.0.0-alpha.3 + '@emnapi/runtime': 2.0.0-alpha.3 + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.1': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.1': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@rsbuild/core@2.1.9': + dependencies: + '@rspack/core': 2.1.7(@swc/helpers@0.5.23) + '@swc/helpers': 0.5.23 + transitivePeerDependencies: + - '@module-federation/runtime-tools' + + '@rsbuild/plugin-vue@1.2.9(@rsbuild/core@2.1.9)(@rspack/core@2.1.7(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.40)(vue@3.5.40(typescript@5.8.3))': + dependencies: + rspack-vue-loader: 17.6.1(@rspack/core@2.1.7(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.40)(vue@3.5.40(typescript@5.8.3)) + optionalDependencies: + '@rsbuild/core': 2.1.9 + transitivePeerDependencies: + - '@rspack/core' + - '@vue/compiler-sfc' + - vue + + '@rspack/binding-darwin-arm64@2.1.7': + optional: true + + '@rspack/binding-darwin-x64@2.1.7': + optional: true + + '@rspack/binding-linux-arm64-gnu@2.1.7': + optional: true + + '@rspack/binding-linux-arm64-musl@2.1.7': + optional: true + + '@rspack/binding-linux-riscv64-gnu@2.1.7': + optional: true + + '@rspack/binding-linux-riscv64-musl@2.1.7': + optional: true + + '@rspack/binding-linux-x64-gnu@2.1.7': + optional: true + + '@rspack/binding-linux-x64-musl@2.1.7': + optional: true + + '@rspack/binding-wasm32-wasi@2.1.7': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + optional: true + + '@rspack/binding-win32-arm64-msvc@2.1.7': + optional: true + + '@rspack/binding-win32-ia32-msvc@2.1.7': + optional: true + + '@rspack/binding-win32-x64-msvc@2.1.7': + optional: true + + '@rspack/binding@2.1.7': + optionalDependencies: + '@rspack/binding-darwin-arm64': 2.1.7 + '@rspack/binding-darwin-x64': 2.1.7 + '@rspack/binding-linux-arm64-gnu': 2.1.7 + '@rspack/binding-linux-arm64-musl': 2.1.7 + '@rspack/binding-linux-riscv64-gnu': 2.1.7 + '@rspack/binding-linux-riscv64-musl': 2.1.7 + '@rspack/binding-linux-x64-gnu': 2.1.7 + '@rspack/binding-linux-x64-musl': 2.1.7 + '@rspack/binding-wasm32-wasi': 2.1.7 + '@rspack/binding-win32-arm64-msvc': 2.1.7 + '@rspack/binding-win32-ia32-msvc': 2.1.7 + '@rspack/binding-win32-x64-msvc': 2.1.7 + + '@rspack/core@2.1.7(@swc/helpers@0.5.23)': + dependencies: + '@rspack/binding': 2.1.7 + optionalDependencies: + '@swc/helpers': 0.5.23 + + '@rspack/lite-tapable@1.1.4': {} + + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + + '@tiptap/core@3.29.2(@tiptap/pm@3.29.2)': + dependencies: + '@tiptap/pm': 3.29.2 + + '@tiptap/pm@3.29.2': + dependencies: + prosemirror-changeset: 2.4.1 + prosemirror-commands: 1.7.1 + prosemirror-dropcursor: 1.8.3 + prosemirror-gapcursor: 1.4.1 + prosemirror-history: 1.5.0 + prosemirror-inputrules: 1.5.1 + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.11 + prosemirror-schema-list: 1.5.1 + prosemirror-state: 1.4.4 + prosemirror-tables: 1.8.5 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.42.2 + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/jsesc@2.5.1': {} + + '@types/node@20.19.43': + dependencies: + undici-types: 6.21.0 + + '@vitejs/plugin-vue@6.0.8(vite@8.2.0(@types/node@20.19.43)(yaml@2.9.0))(vue@3.5.40(typescript@5.8.3))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.2.0(@types/node@20.19.43)(yaml@2.9.0) + vue: 3.5.40(typescript@5.8.3) + + '@volar/language-core@2.4.15': + dependencies: + '@volar/source-map': 2.4.15 + + '@volar/source-map@2.4.15': {} + + '@volar/typescript@2.4.15': + dependencies: + '@volar/language-core': 2.4.15 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vue-macros/common@3.1.4(vue@3.5.40(typescript@5.8.3))': + dependencies: + '@vue/compiler-sfc': 3.5.40 + ast-kit: 2.2.0 + local-pkg: 1.2.1 + magic-string-ast: 1.0.3 + unplugin-utils: 0.3.2 + optionalDependencies: + vue: 3.5.40(typescript@5.8.3) + + '@vue/compiler-core@3.5.40': + dependencies: + '@babel/parser': 7.29.8 + '@vue/shared': 3.5.40 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.40': + dependencies: + '@vue/compiler-core': 3.5.40 + '@vue/shared': 3.5.40 + + '@vue/compiler-sfc@3.5.40': + dependencies: + '@babel/parser': 7.29.8 + '@vue/compiler-core': 3.5.40 + '@vue/compiler-dom': 3.5.40 + '@vue/compiler-ssr': 3.5.40 + '@vue/shared': 3.5.40 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.25 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.40': + dependencies: + '@vue/compiler-dom': 3.5.40 + '@vue/shared': 3.5.40 + + '@vue/compiler-vue2@2.7.16': + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + '@vue/devtools-api@7.7.10': + dependencies: + '@vue/devtools-kit': 7.7.10 + + '@vue/devtools-api@8.2.1': + dependencies: + '@vue/devtools-kit': 8.2.1 + + '@vue/devtools-kit@7.7.10': + dependencies: + '@vue/devtools-shared': 7.7.10 + birpc: 2.9.0 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.6 + + '@vue/devtools-kit@8.2.1': + dependencies: + '@vue/devtools-shared': 8.2.1 + birpc: 2.9.0 + hookable: 5.5.3 + perfect-debounce: 2.1.0 + + '@vue/devtools-shared@7.7.10': + dependencies: + rfdc: 1.4.1 + + '@vue/devtools-shared@8.2.1': {} + + '@vue/language-core@2.2.12(typescript@5.8.3)': + dependencies: + '@volar/language-core': 2.4.15 + '@vue/compiler-dom': 3.5.40 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.40 + alien-signals: 1.0.13 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.8.3 + + '@vue/reactivity@3.5.40': + dependencies: + '@vue/shared': 3.5.40 + + '@vue/runtime-core@3.5.40': + dependencies: + '@vue/reactivity': 3.5.40 + '@vue/shared': 3.5.40 + + '@vue/runtime-dom@3.5.40': + dependencies: + '@vue/reactivity': 3.5.40 + '@vue/runtime-core': 3.5.40 + '@vue/shared': 3.5.40 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.40': + dependencies: + '@vue/compiler-ssr': 3.5.40 + '@vue/runtime-dom': 3.5.40 + '@vue/shared': 3.5.40 + + '@vue/shared@3.5.40': {} + + '@vue/tsconfig@0.7.0(typescript@5.8.3)(vue@3.5.40(typescript@5.8.3))': + optionalDependencies: + typescript: 5.8.3 + vue: 3.5.40(typescript@5.8.3) + + acorn@8.18.0: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + alien-signals@1.0.13: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + ast-kit@2.2.0: + dependencies: + '@babel/parser': 7.29.8 + pathe: 2.0.3 + + ast-walker-scope@0.9.0: + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + ast-kit: 2.2.0 + + asynckit@0.4.0: {} + + axios@1.19.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + balanced-match@1.0.2: {} + + birpc@2.9.0: {} + + brace-expansion@2.1.4: + dependencies: + balanced-match: 1.0.2 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + confbox@0.1.8: {} + + confbox@0.2.4: {} + + copy-anything@4.0.5: + dependencies: + is-what: 5.5.0 + + csstype@3.2.3: {} + + de-indent@1.0.2: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + delayed-stream@1.0.0: {} + + detect-libc@2.1.2: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + entities@7.0.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + estree-walker@2.0.2: {} + + exsolve@1.1.1: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + floating-vue@5.2.2(vue@3.5.40(typescript@5.8.3)): + dependencies: + '@floating-ui/dom': 1.1.1 + vue: 3.5.40(typescript@5.8.3) + vue-resize: 2.0.0-alpha.1(vue@3.5.40(typescript@5.8.3)) + + follow-redirects@1.16.0: {} + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + he@1.2.0: {} + + hookable@5.5.3: {} + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + is-what@5.5.0: {} + + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + local-pkg@1.2.1: + dependencies: + mlly: 1.8.2 + pkg-types: 2.3.1 + quansync: 0.2.11 + + magic-string-ast@1.0.3: + dependencies: + magic-string: 0.30.21 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.4 + + mitt@3.0.1: {} + + mlly@1.8.2: + dependencies: + acorn: 8.18.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + nanoid@3.3.16: {} + + nostics@1.2.0: {} + + object-inspect@1.13.4: {} + + orderedmap@2.1.1: {} + + path-browserify@1.0.1: {} + + pathe@2.0.3: {} + + perfect-debounce@1.0.0: {} + + perfect-debounce@2.1.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + pinia@3.0.4(typescript@5.8.3)(vue@3.5.40(typescript@5.8.3)): + dependencies: + '@vue/devtools-api': 7.7.10 + vue: 3.5.40(typescript@5.8.3) + optionalDependencies: + typescript: 5.8.3 + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.1.1 + pathe: 2.0.3 + + postcss@8.5.25: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prosemirror-changeset@2.4.1: + dependencies: + prosemirror-transform: 1.12.0 + + prosemirror-commands@1.7.1: + dependencies: + prosemirror-model: 1.25.11 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + + prosemirror-dropcursor@1.8.3: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.42.2 + + prosemirror-gapcursor@1.4.1: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.11 + prosemirror-state: 1.4.4 + prosemirror-view: 1.42.2 + + prosemirror-history@1.5.0: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.42.2 + rope-sequence: 1.3.4 + + prosemirror-inputrules@1.5.1: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + + prosemirror-keymap@1.2.3: + dependencies: + prosemirror-state: 1.4.4 + w3c-keyname: 2.2.8 + + prosemirror-model@1.25.11: + dependencies: + orderedmap: 2.1.1 + + prosemirror-schema-list@1.5.1: + dependencies: + prosemirror-model: 1.25.11 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + + prosemirror-state@1.4.4: + dependencies: + prosemirror-model: 1.25.11 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.42.2 + + prosemirror-tables@1.8.5: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.11 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.42.2 + + prosemirror-transform@1.12.0: + dependencies: + prosemirror-model: 1.25.11 + + prosemirror-view@1.42.2: + dependencies: + prosemirror-model: 1.25.11 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + + proxy-from-env@2.1.0: {} + + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + quansync@0.2.11: {} + + readdirp@5.0.0: {} + + rfdc@1.4.1: {} + + rolldown@1.2.1: + dependencies: + '@oxc-project/types': 0.142.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.2.1 + '@rolldown/binding-darwin-arm64': 1.2.1 + '@rolldown/binding-darwin-x64': 1.2.1 + '@rolldown/binding-freebsd-x64': 1.2.1 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.1 + '@rolldown/binding-linux-arm64-gnu': 1.2.1 + '@rolldown/binding-linux-arm64-musl': 1.2.1 + '@rolldown/binding-linux-ppc64-gnu': 1.2.1 + '@rolldown/binding-linux-s390x-gnu': 1.2.1 + '@rolldown/binding-linux-x64-gnu': 1.2.1 + '@rolldown/binding-linux-x64-musl': 1.2.1 + '@rolldown/binding-openharmony-arm64': 1.2.1 + '@rolldown/binding-wasm32-wasi': 1.2.1 + '@rolldown/binding-win32-arm64-msvc': 1.2.1 + '@rolldown/binding-win32-x64-msvc': 1.2.1 + + rope-sequence@1.3.4: {} + + rspack-vue-loader@17.6.1(@rspack/core@2.1.7(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.40)(vue@3.5.40(typescript@5.8.3)): + dependencies: + '@rspack/lite-tapable': 1.1.4 + chalk: 4.1.2 + optionalDependencies: + '@rspack/core': 2.1.7(@swc/helpers@0.5.23) + '@vue/compiler-sfc': 3.5.40 + vue: 3.5.40(typescript@5.8.3) + + scule@1.3.0: {} + + semver@7.8.5: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + source-map-js@1.2.1: {} + + speakingurl@14.0.1: {} + + superjson@2.2.6: + dependencies: + copy-anything: 4.0.5 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tslib@2.8.1: {} + + typescript@5.8.3: {} + + ufo@1.6.4: {} + + undici-types@6.21.0: {} + + unplugin-utils@0.3.2: + dependencies: + pathe: 2.0.3 + picomatch: 4.0.5 + + unplugin@3.3.0(@rspack/core@2.1.7(@swc/helpers@0.5.23))(rolldown@1.2.1)(vite@8.2.0(@types/node@20.19.43)(yaml@2.9.0)): + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.5 + webpack-virtual-modules: 0.6.2 + optionalDependencies: + '@rspack/core': 2.1.7(@swc/helpers@0.5.23) + rolldown: 1.2.1 + vite: 8.2.0(@types/node@20.19.43)(yaml@2.9.0) + + vite@8.2.0(@types/node@20.19.43)(yaml@2.9.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.25 + rolldown: 1.2.1 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 20.19.43 + fsevents: 2.3.3 + yaml: 2.9.0 + + vscode-uri@3.1.0: {} + + vue-resize@2.0.0-alpha.1(vue@3.5.40(typescript@5.8.3)): + dependencies: + vue: 3.5.40(typescript@5.8.3) + + vue-router@5.2.0(@rspack/core@2.1.7(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.40)(pinia@3.0.4(typescript@5.8.3)(vue@3.5.40(typescript@5.8.3)))(rolldown@1.2.1)(vite@8.2.0(@types/node@20.19.43)(yaml@2.9.0))(vue@3.5.40(typescript@5.8.3)): + dependencies: + '@babel/generator': 8.0.0 + '@vue-macros/common': 3.1.4(vue@3.5.40(typescript@5.8.3)) + '@vue/devtools-api': 8.2.1 + ast-walker-scope: 0.9.0 + chokidar: 5.0.0 + json5: 2.2.3 + local-pkg: 1.2.1 + magic-string: 0.30.21 + mlly: 1.8.2 + muggle-string: 0.4.1 + nostics: 1.2.0 + pathe: 2.0.3 + picomatch: 4.0.5 + scule: 1.3.0 + tinyglobby: 0.2.17 + unplugin: 3.3.0(@rspack/core@2.1.7(@swc/helpers@0.5.23))(rolldown@1.2.1)(vite@8.2.0(@types/node@20.19.43)(yaml@2.9.0)) + unplugin-utils: 0.3.2 + vue: 3.5.40(typescript@5.8.3) + yaml: 2.9.0 + optionalDependencies: + '@vue/compiler-sfc': 3.5.40 + pinia: 3.0.4(typescript@5.8.3)(vue@3.5.40(typescript@5.8.3)) + vite: 8.2.0(@types/node@20.19.43)(yaml@2.9.0) + transitivePeerDependencies: + - '@farmfe/core' + - '@rspack/core' + - bun-types-no-globals + - esbuild + - rolldown + - rollup + - unloader + - webpack + + vue-tsc@2.2.12(typescript@5.8.3): + dependencies: + '@volar/typescript': 2.4.15 + '@vue/language-core': 2.2.12(typescript@5.8.3) + typescript: 5.8.3 + + vue@3.5.40(typescript@5.8.3): + dependencies: + '@vue/compiler-dom': 3.5.40 + '@vue/compiler-sfc': 3.5.40 + '@vue/runtime-dom': 3.5.40 + '@vue/server-renderer': 3.5.40 + '@vue/shared': 3.5.40 + optionalDependencies: + typescript: 5.8.3 + + w3c-keyname@2.2.8: {} + + webpack-virtual-modules@0.6.2: {} + + yaml@2.9.0: {} diff --git a/ui/rsbuild.config.mjs b/ui/rsbuild.config.mjs new file mode 100644 index 0000000..c368621 --- /dev/null +++ b/ui/rsbuild.config.mjs @@ -0,0 +1,28 @@ +import { rsbuildConfig } from '@halo-dev/ui-plugin-bundler-kit'; +import { pluginVue } from '@rsbuild/plugin-vue'; + +const MANIFEST_PATH = '../src/main/resources/plugin.yaml'; +const OUT_DIR_PROD = '../src/main/resources/console'; +const OUT_DIR_DEV = '../build/resources/main/console'; + +export default rsbuildConfig({ + manifestPath: MANIFEST_PATH, + rsbuild: ({ envMode }) => { + const isProduction = envMode === 'production'; + const outDir = isProduction ? OUT_DIR_PROD : OUT_DIR_DEV; + + return { + resolve: { + alias: { + '@': './src', + }, + }, + plugins: [pluginVue()], + output: { + distPath: { + root: outDir, + }, + }, + }; + }, +}); diff --git a/ui/src/api/index.ts b/ui/src/api/index.ts new file mode 100644 index 0000000..adbdf97 --- /dev/null +++ b/ui/src/api/index.ts @@ -0,0 +1,127 @@ +import { axiosInstance } from '@halo-dev/api-client'; +import type { + DownloadResource, + DownloadResourceList, + DownloadResourceSpec, + DownloadRecord, + ListResult, + ReferencesMap, +} from '@/types'; + +export const API_BASE = '/apis/console.api.sharelink.halo.run/v1alpha1'; + +// 后端资源 VO 为扁平结构,这里统一归一化为 Halo 风格结构供组件使用 +interface ResourceVo { + name: string; + slug: string; + displayName: string; + description?: string; + attachmentName?: string; + requireEmailVerify?: boolean; + enabled?: boolean; + downloadUrl?: string; + creationTimestamp?: string; + stats?: { + downloadCount?: number; + downloaderCount?: number; + referenceCount?: number; + }; +} + +function normalizeResource(vo: ResourceVo): DownloadResource { + return { + metadata: { name: vo.name, creationTimestamp: vo.creationTimestamp }, + spec: { + slug: vo.slug, + displayName: vo.displayName, + description: vo.description, + attachmentName: vo.attachmentName, + requireEmailVerify: vo.requireEmailVerify, + enabled: vo.enabled, + }, + status: { downloadCount: vo.stats?.downloadCount ?? 0 }, + stats: { + downloaderCount: vo.stats?.downloaderCount ?? 0, + referenceCount: vo.stats?.referenceCount ?? 0, + }, + }; +} + +export async function listResources(): Promise { + const { data } = await axiosInstance.get( + `${API_BASE}/download-resources` + ); + const items = (data ?? []).map(normalizeResource); + return { items, total: items.length }; +} + +// 后端创建/更新均接收扁平的 spec 字段(ResourceRequest) +export async function createResource( + spec: DownloadResourceSpec +): Promise { + const { data } = await axiosInstance.post( + `${API_BASE}/download-resources`, + spec + ); + return normalizeResource(data); +} + +export async function getResource(name: string): Promise { + const { data } = await axiosInstance.get( + `${API_BASE}/download-resources/${name}` + ); + return normalizeResource(data); +} + +export async function updateResource( + name: string, + spec: DownloadResourceSpec +): Promise { + const { data } = await axiosInstance.put( + `${API_BASE}/download-resources/${name}`, + spec + ); + return normalizeResource(data); +} + +export async function deleteResource(name: string): Promise { + await axiosInstance.delete(`${API_BASE}/download-resources/${name}`); +} + +export async function listRecords(params: { + resourceSlug?: string; + page?: number; + size?: number; +}): Promise> { + const { data } = await axiosInstance.get>( + `${API_BASE}/download-records`, + { params } + ); + return data; +} + +export async function deleteRecord(name: string): Promise { + await axiosInstance.delete(`${API_BASE}/download-records/${name}`); +} + +// CSV 导出走浏览器直接导航(console 端点接受 session cookie) +export function recordsExportUrl(resourceSlug?: string): string { + const query = resourceSlug + ? `?resourceSlug=${encodeURIComponent(resourceSlug)}` + : ''; + return `${API_BASE}/download-records/-/export${query}`; +} + +export async function getReferences(): Promise { + const { data } = await axiosInstance.get( + `${API_BASE}/references` + ); + return data; +} + +export async function refreshReferences(): Promise { + const { data } = await axiosInstance.post( + `${API_BASE}/references/-/refresh` + ); + return data; +} diff --git a/ui/src/components/RecordsDrawer.vue b/ui/src/components/RecordsDrawer.vue new file mode 100644 index 0000000..c1e4294 --- /dev/null +++ b/ui/src/components/RecordsDrawer.vue @@ -0,0 +1,262 @@ + + + + + diff --git a/ui/src/components/ResourceFormModal.vue b/ui/src/components/ResourceFormModal.vue new file mode 100644 index 0000000..c9b9633 --- /dev/null +++ b/ui/src/components/ResourceFormModal.vue @@ -0,0 +1,354 @@ + + +