From d8c1303c32430f6ecf2d0d41d3146c41fc1aaf37 Mon Sep 17 00:00:00 2001 From: O96u Date: Thu, 27 Aug 2026 12:13:58 +0800 Subject: [PATCH] =?UTF-8?q?release:=20v1.1.3=20=E2=80=94=20dual-domain=20s?= =?UTF-8?q?eparation=20and=20URL=20options?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add admin/image domain separation with host isolation middleware, optional hide-folder public URLs, GitHub release update check, and Lucky/Nginx deployment docs. Switch license to GPL-3.0-only. --- .env.example | 8 +- .gitignore | 4 +- CHANGELOG.md | 21 + LICENSE | 703 ++++++++++++++++++++++++- README.en.md | 46 +- README.md | 46 +- app/composables/useAuth.ts | 8 +- app/pages/api.vue | 7 +- app/pages/logs.vue | 38 +- app/pages/settings/index.vue | 382 +++++++++++--- app/pages/setup.vue | 81 ++- docs/README.md | 9 + docs/domain-separation.en.md | 83 +++ docs/domain-separation.md | 125 +++++ docs/screenshots/lucky-image.png | Bin 0 -> 32323 bytes docs/screenshots/lucky-site.png | Bin 0 -> 39030 bytes i18n/locales/en.json | 34 +- i18n/locales/zh-CN.json | 34 +- nuxt.config.ts | 12 + package-lock.json | 6 +- package.json | 4 +- scripts/assign-image-owner.ps1 | 146 ----- server/api/settings/index.patch.ts | 44 +- server/api/setup.post.ts | 22 +- server/api/user/api-token.get.ts | 4 +- server/api/version/latest.get.ts | 29 + server/middleware/00-host-isolation.ts | 7 + server/middleware/01-serve-image.ts | 4 +- server/utils/app-version.test.ts | 20 + server/utils/app-version.ts | 77 +++ server/utils/db.ts | 2 + server/utils/env.test.ts | 58 +- server/utils/env.ts | 139 ++++- server/utils/host-isolation.test.ts | 69 +++ server/utils/host-isolation.ts | 51 ++ server/utils/image-index.ts | 26 +- server/utils/image-public-path.test.ts | 23 + server/utils/image-public-path.ts | 13 + server/utils/image-response.ts | 35 +- server/utils/serve-image-path.test.ts | 19 + server/utils/serve-image.ts | 30 +- 41 files changed, 2170 insertions(+), 299 deletions(-) create mode 100644 docs/domain-separation.en.md create mode 100644 docs/domain-separation.md create mode 100644 docs/screenshots/lucky-image.png create mode 100644 docs/screenshots/lucky-site.png delete mode 100644 scripts/assign-image-owner.ps1 create mode 100644 server/api/version/latest.get.ts create mode 100644 server/middleware/00-host-isolation.ts create mode 100644 server/utils/app-version.test.ts create mode 100644 server/utils/app-version.ts create mode 100644 server/utils/host-isolation.test.ts create mode 100644 server/utils/host-isolation.ts create mode 100644 server/utils/image-public-path.test.ts create mode 100644 server/utils/image-public-path.ts create mode 100644 server/utils/serve-image-path.test.ts diff --git a/.env.example b/.env.example index 0c60a31..f1eb495 100644 --- a/.env.example +++ b/.env.example @@ -11,7 +11,13 @@ # 可选;不填可在设置页配置。若在此填写则覆盖后台设置 # ALLOWED_REFERER_HOSTS=example.com,www.example.com -# 图片直链域名(可选;不填则使用请求域名,也可在设置页配置) +# 管理后台域名(可选;不填则使用当前请求域名,也可在设置页配置) +# SITE_BASE_URL=https://admin.example.com + +# 隐藏图片直链中的目录前缀(如 images/2026/08/a.webp → 2026/08/a.webp) +# HIDE_FOLDER_IN_URL=false + +# 图片直链域名(可选;不填则使用当前请求域名,也可在设置页配置) # IMAGE_BASE_URL=https://pic.example.com # 图片存储目录(默认 ./data,容器内为 /data) diff --git a/.gitignore b/.gitignore index 56125a0..5a87dc4 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,6 @@ data # 本地 Git hooks 模板(仅安装在 .git/hooks/,不进仓库) git-hooks/ -# 飞牛 fpk 工程与产物(仅本地维护,不进仓库) -scripts/fpk/ +# 本地维护脚本(assign-image-owner、fpk 打包等,不进仓库) +scripts/ dist-fpk/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 795162a..73218dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ 本仓库自 **v1.0.0** 起作为正式版维护;此前历史提交已归档重置。 +## [1.1.3] - 2026-08-27 + +### 新增 + +- 后台与图片域名分离(`SITE_BASE_URL` / `IMAGE_BASE_URL`):单实例双域名,Host 中间件隔离图片域非图片路径 +- `/setup` 与系统设置支持启用/关闭域名分离,设置页可后续关闭并恢复单域 +- 可选隐藏外链中的 `images/` 前缀(多文件夹时确认提示) +- 设置页加载时检测 GitHub 最新版本并提示更新 +- 双域名部署文档(Nginx / Lucky / 本地 hosts 测试):`docs/domain-separation.md` + +### 改进 + +- 双域名下防盗链自动放行网站域与图片域主机名 +- 开发服务器默认绑定 `127.0.0.1:3000`,便于 hosts 双域本地测试 +- API 文档补充自定义文件夹的 cURL 示例 + +### 其他 + +- 许可证变更为 GPL-3.0-only +- `scripts/` 目录改为本地维护,不再纳入仓库 + ## [1.1.2] - 2026-08-26 ### 新增 diff --git a/LICENSE b/LICENSE index e924e4d..b912b2c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,690 @@ -MIT License +PicHost - Self-hosted image hosting +Copyright (C) 2026 O96u -Copyright (c) 2026 O96u +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License version 3 as +published by the Free Software Foundation. -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +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. -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +You should have received a copy of the GNU General Public License +along with this program. If not, see . -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +================================================================================ + + 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. + + PicHost - Self-hosted image hosting + Copyright (C) 2026 O96u + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License version 3 as + published by the Free Software Foundation. + + 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.en.md b/README.en.md index 92931ce..cfad7bd 100644 --- a/README.en.md +++ b/README.en.md @@ -13,7 +13,7 @@ CI Nuxt 4 Docker - MIT + GPL-3.0

@@ -32,7 +32,7 @@ | Branch | Notes | | ------ | ----- | -| [**main**](https://github.com/O96u/PicHost/tree/main) (default) | **v1.1.2** — local disk + multi-backend object storage (R2 / COS / OSS / AWS), [`/storage`](docs/README.md) UI, hybrid URLs | +| [**main**](https://github.com/O96u/PicHost/tree/main) (default) | **v1.1.3** — local disk + multi-backend storage, dual-domain separation, [`/storage`](docs/README.md) UI, hybrid URLs | | [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | **Cloudflare R2–only line** — streamlined for deployments that use R2 exclusively | Use **main** for the full product and multiple cloud backends. @@ -176,7 +176,42 @@ Full docs and copyable cURL snippets are on the **API** page after deployment. - Proxy to port **6892**; HTTPS recommended - Nginx: `client_max_body_size` ≥ 12m -- Set `IMAGE_BASE_URL` for public access +- Set `IMAGE_BASE_URL` (single domain) or `SITE_BASE_URL` + `IMAGE_BASE_URL` (dual-domain separation) + +### Recommended architecture (dual domains) + +**One Docker instance + two domains**, both fully proxied to `6892` (isolation via PicHost middleware): + +```nginx +# admin.example.com — admin + API + images +server { + server_name admin.example.com; + location / { + proxy_pass http://127.0.0.1:6892; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + client_max_body_size 12m; + } +} + +# pic.example.com — same full proxy; PicHost middleware blocks non-image paths +server { + server_name pic.example.com; + location / { + proxy_pass http://127.0.0.1:6892; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +FN NAS [Lucky](https://github.com/gdy666/lucky) reverse proxy (public hostname → backend `http://:6892`): + +| Site domain (admin) | Image domain | +| :-----------------: | :----------: | +| ![Lucky site](docs/screenshots/lucky-site.png) | ![Lucky image](docs/screenshots/lucky-image.png) | + +See **[docs/domain-separation.en.md](docs/domain-separation.en.md)** for Caddy/NPM notes, env vars, and setup fields. ## Development @@ -193,7 +228,8 @@ npm test # Unit tests | Version | Plan | | ---------- | -------------------------------------------------------------- | -| **v1.1.2** | Activity logs, gallery storage filter, upload rate limits (current `main`) | +| **v1.1.3** | Dual-domain separation, optional `images/` URL prefix, update check (current `main`) | +| **v1.1.2** | Activity logs, gallery storage filter, upload rate limits | | **v1.1.1** | Session check fixes, storage placeholder cleanup, Docker logging | | **v1.1.0** | Multi-backend object storage, hybrid URLs, storage UI | | **v1.0.4** | Password visibility toggle, legacy ADMIN_SECRET migration | @@ -204,7 +240,7 @@ npm test # Unit tests ## License -[MIT](LICENSE) +[GPL-3.0](LICENSE) ## Friend links diff --git a/README.md b/README.md index 833dbc3..458017b 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ CI Nuxt 4 Docker - MIT + GPL-3.0

@@ -32,7 +32,7 @@ | 分支 | 说明 | | ---- | ---- | -| [**main**](https://github.com/O96u/PicHost/tree/main)(默认) | **v1.1.2** 主线:本地磁盘 + 多对象存储后端(R2 / COS / OSS / AWS)、[`/storage`](docs/README.md) 存储管理、混合直链 | +| [**main**](https://github.com/O96u/PicHost/tree/main)(默认) | **v1.1.3** 主线:本地磁盘 + 多对象存储、双域名分离、[`/storage`](docs/README.md) 存储管理、混合直链 | | [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | **Cloudflare R2 专用线**:面向「只用 R2」的部署,预设与配置更聚焦 R2 | 日常使用、需要管理多种存储后端,请直接用 **main**。 @@ -181,7 +181,42 @@ curl -X POST "https://pic.example.com/api/images/upload" \ - 反代到容器 **6892** 端口,建议 HTTPS - Nginx:`client_max_body_size` ≥ 12m -- 公网访问请配置 `IMAGE_BASE_URL` +- 公网访问请配置 `IMAGE_BASE_URL`(单域名)或 `SITE_BASE_URL` + `IMAGE_BASE_URL`(双域名分离) + +### 推荐部署架构(双域名分离) + +**单 Docker 实例 + 两个域名** 均可全量反代到 `6892`(隔离由 PicHost 中间件负责): + +```nginx +# admin.example.com — 后台 + API + 出图 +server { + server_name admin.example.com; + location / { + proxy_pass http://127.0.0.1:6892; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + client_max_body_size 12m; + } +} + +# pic.example.com — 同样全量反代;PicHost 中间件会拦截非图片路径 +server { + server_name pic.example.com; + location / { + proxy_pass http://127.0.0.1:6892; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +飞牛 NAS [Lucky](https://github.com/gdy666/lucky) 反向代理示例(前端域名 → 后端 `http://<内网IP>:6892`): + +| 网站域名(管理后台) | 图片域名 | +| :------------------: | :------: | +| ![Lucky 网站域名](docs/screenshots/lucky-site.png) | ![Lucky 图片域名](docs/screenshots/lucky-image.png) | + +完整说明(Caddy / NPM、环境变量、初始化填写项)见 **[docs/domain-separation.md](docs/domain-separation.md)**。 ## 开发 @@ -198,7 +233,8 @@ npm test # 单元测试 | 版本 | 说明 | | ---------- | ------------------------------------------------- | -| **v1.1.2** | 操作日志、图库存储筛选、上传限流(当前 main) | +| **v1.1.3** | 双域名分离、隐藏 `images/` 前缀、版本更新提示(当前 main) | +| **v1.1.2** | 操作日志、图库存储筛选、上传限流 | | **v1.1.1** | 会话校验与存储占位修复、Docker 日志增强 | | **v1.1.0** | S3 / R2 多后端对象存储、混合直链、存储管理页 | | **v1.0.4** | 密码显示切换、遗留 ADMIN_SECRET 迁移对齐 | @@ -209,7 +245,7 @@ npm test # 单元测试 ## License -[MIT](LICENSE) +[GPL-3.0](LICENSE) ## 友链 diff --git a/app/composables/useAuth.ts b/app/composables/useAuth.ts index edafde5..04e60a7 100644 --- a/app/composables/useAuth.ts +++ b/app/composables/useAuth.ts @@ -182,6 +182,9 @@ export function useAuth() { username: string password: string allowRegistration: boolean + domainSeparation?: boolean + siteBaseUrl?: string + imageBaseUrl?: string }): Promise { const validationError = validateAuthInput(input.username, input.password) if (validationError) { @@ -197,7 +200,10 @@ export function useAuth() { body: { username: input.username.trim(), password: input.password, - allowRegistration: input.allowRegistration + allowRegistration: input.allowRegistration, + domainSeparation: input.domainSeparation, + siteBaseUrl: input.siteBaseUrl, + imageBaseUrl: input.imageBaseUrl } } ) diff --git a/app/pages/api.vue b/app/pages/api.vue index 6caed3b..5c21f75 100644 --- a/app/pages/api.vue +++ b/app/pages/api.vue @@ -6,6 +6,7 @@ interface SettingsResponse { env: { webpQuality: number refererConfigured: boolean + siteBaseUrl: string imageBaseUrl: string appVersion: string } @@ -32,6 +33,8 @@ const regenerateResultOpen = ref(false) const newToken = ref('') const baseUrl = computed(() => { + const configured = settings.value?.env.siteBaseUrl + if (configured) return configured if (import.meta.client) { return window.location.origin } @@ -57,7 +60,7 @@ const apiDocs = computed(() => { ${authHeaderFlag.value} \\ -F "image=@./demo.png" -# 上传到自定义目录 blog/ +${t('api.docs.upload.curlCustomFolder')} curl -X POST "${baseUrl.value}/api/images/upload" \\ ${authHeaderFlag.value} \\ -F "folder=blog" \\ @@ -73,7 +76,7 @@ curl -X POST "${baseUrl.value}/api/images/upload" \\ ? `curl "${baseUrl.value}/api/images?limit=20&page=1" \\ ${authHeaderFlag.value} -# 按目录筛选 +${t('api.docs.list.curlFolderFilter')} curl "${baseUrl.value}/api/images?folder=blog&limit=20&page=1" \\ ${authHeaderFlag.value}` : `curl "${baseUrl.value}/api/images?limit=20&page=1" \\ diff --git a/app/pages/logs.vue b/app/pages/logs.vue index db9eee3..a909a96 100644 --- a/app/pages/logs.vue +++ b/app/pages/logs.vue @@ -14,6 +14,8 @@ const userFilter = ref<'all' | number>('all') const searchQuery = ref('') const activeSearch = ref('') const listData = ref(null) +const imageLinkBase = ref('') +const hideFolderInUrl = ref(false) const pageSize = 10 @@ -100,9 +102,39 @@ function storageIcon(storage: ActivityLogStorage | null) { return storage?.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud' } +function publicPathFromKey(key: string) { + if (!hideFolderInUrl.value) return key + const slash = key.indexOf('/') + return slash > 0 ? key.slice(slash + 1) : key +} + function imageUrl(key: string) { - if (!import.meta.client) return '' - return `${window.location.origin}/${key}` + const base = imageLinkBase.value.replace(/\/$/, '') + const path = publicPathFromKey(key) + if (!base) return `/${path}` + return `${base}/${path}` +} + +async function loadImageLinkBase() { + try { + if (isAdmin.value) { + const data = await $fetch<{ imageBaseUrl: string, hideFolderInUrl: boolean }>('/api/settings', { + credentials: 'include' + }) + imageLinkBase.value = data.imageBaseUrl + hideFolderInUrl.value = data.hideFolderInUrl + return + } + const data = await $fetch<{ env: { imageBaseUrl: string, hideFolderInUrl: boolean } }>('/api/user/api-token', { + credentials: 'include' + }) + imageLinkBase.value = data.env.imageBaseUrl + hideFolderInUrl.value = data.env.hideFolderInUrl + } catch { + if (import.meta.client) { + imageLinkBase.value = window.location.origin + } + } } async function loadLogs() { @@ -172,11 +204,13 @@ onMounted(async () => { await navigateTo('/') return } + await loadImageLinkBase() await loadLogs() }) watch(isAuthenticated, async (authed, prev) => { if (authed && prev === false) { + await loadImageLinkBase() await loadLogs() } }) diff --git a/app/pages/settings/index.vue b/app/pages/settings/index.vue index b355a2b..e35886d 100644 --- a/app/pages/settings/index.vue +++ b/app/pages/settings/index.vue @@ -14,6 +14,12 @@ interface SettingsResponse { imageBaseUrl: string imageBaseUrlConfigured: string imageBaseUrlSource: SettingSource + siteBaseUrl: string + siteBaseUrlConfigured: string + siteBaseUrlSource: SettingSource + domainSeparation: boolean + hideFolderInUrl: boolean + hideFolderInUrlSource: SettingSource allowRegistration: boolean appVersion: string } @@ -28,8 +34,21 @@ const folderOptions = ref(['images']) const savingServer = ref(false) const refererDraft = ref('') +const siteBaseUrlDraft = ref('') const imageBaseUrlDraft = ref('') +const hideFolderInUrlDraft = ref(false) const allowRegistrationDraft = ref(false) +const domainSeparationDraft = ref(false) +const hideFolderConfirmOpen = ref(false) + +interface ReleaseCheckResponse { + currentVersion: string + latestVersion: string | null + updateAvailable: boolean + releaseUrl: string | null +} + +const releaseCheck = ref(null) const folderSelectItems = computed(() => folderOptions.value.map(folder => ({ label: folder, value: folder })) @@ -38,7 +57,10 @@ const folderSelectItems = computed(() => const hasServerChanges = computed(() => { if (!settings.value) return false return refererDraft.value !== settings.value.allowedRefererHosts + || domainSeparationDraft.value !== settings.value.domainSeparation + || siteBaseUrlDraft.value !== (settings.value.domainSeparation ? settings.value.siteBaseUrlConfigured : '') || imageBaseUrlDraft.value !== settings.value.imageBaseUrlConfigured + || hideFolderInUrlDraft.value !== settings.value.hideFolderInUrl || allowRegistrationDraft.value !== settings.value.allowRegistration }) @@ -76,8 +98,11 @@ function errorStatus(error: unknown): number { function applySettings(data: SettingsResponse) { settings.value = data refererDraft.value = data.allowedRefererHosts + siteBaseUrlDraft.value = data.domainSeparation ? data.siteBaseUrlConfigured : '' imageBaseUrlDraft.value = data.imageBaseUrlConfigured + hideFolderInUrlDraft.value = data.hideFolderInUrl allowRegistrationDraft.value = data.allowRegistration + domainSeparationDraft.value = data.domainSeparation } async function patchSettings(body: Record) { @@ -101,6 +126,40 @@ function handlePatchError(error: unknown, fallback: string) { toast.add({ title: fallback, color: 'error' }) } +function onHideFolderDraftChange(next: boolean | 'indeterminate') { + if (next !== true) { + hideFolderInUrlDraft.value = false + return + } + if (folderOptions.value.length > 1) { + hideFolderConfirmOpen.value = true + return + } + hideFolderInUrlDraft.value = true +} + +function confirmHideFolderEnable() { + hideFolderInUrlDraft.value = true + hideFolderConfirmOpen.value = false +} + +function cancelHideFolderEnable() { + hideFolderInUrlDraft.value = false + hideFolderConfirmOpen.value = false +} + +function onDomainSeparationDraftChange(next: boolean | 'indeterminate') { + const enabled = next === true + domainSeparationDraft.value = enabled + if (!enabled) { + siteBaseUrlDraft.value = '' + return + } + if (!siteBaseUrlDraft.value && import.meta.client) { + siteBaseUrlDraft.value = window.location.origin + } +} + async function saveServerSettings() { if (!isAuthenticated.value || !hasServerChanges.value) return @@ -108,7 +167,9 @@ async function saveServerSettings() { try { await patchSettings({ allowedRefererHosts: refererDraft.value, + siteBaseUrl: domainSeparationDraft.value ? siteBaseUrlDraft.value : '', imageBaseUrl: imageBaseUrlDraft.value, + hideFolderInUrl: hideFolderInUrlDraft.value, allowRegistration: allowRegistrationDraft.value }) toast.add({ title: t('settings.saved'), color: 'success' }) @@ -143,13 +204,23 @@ async function loadSettings() { } } +async function checkLatestRelease() { + try { + releaseCheck.value = await $fetch('/api/version/latest', { + credentials: 'include' + }) + } catch { + releaseCheck.value = null + } +} + async function loadPage() { if (!isAdmin.value) { await navigateTo('/') return } loadPreferences() - await Promise.all([loadSettings(), loadFolders()]) + await Promise.all([loadSettings(), loadFolders(), checkLatestRelease()]) } onMounted(async () => { @@ -178,6 +249,7 @@ watch(isAuthenticated, async (authed, prev) => { await loadPage() } else { settings.value = null + releaseCheck.value = null } }) @@ -226,89 +298,214 @@ watch(isAuthenticated, async (authed, prev) => { v-if="settings" class="p-5 sm:p-6" > -

-
-

- {{ t('settings.uploadAccount') }} -

+
+
+
+

+ {{ t('settings.uploadAccount') }} +

-
- -

- {{ t('settings.defaultFolderHint') }} -

- +
+ +

+ {{ t('settings.defaultFolderHint') }} +

+ +
+ +
-
+
+ +
+

+ {{ t('settings.linkGeneration') }} +

+ + -
-
-
-

- {{ t('settings.accessControl') }} -

- - {{ sourceBadge(settings.refererSource).label }} - -
-

- {{ t('settings.refererHint') }} -

- -
- -
-
-

- {{ t('settings.linkGeneration') }} -

- - {{ sourceBadge(settings.imageBaseUrlSource).label }} - -
-

- {{ t('settings.imageBaseUrlHint') }} -

-

- {{ t('settings.imageBaseUrlActive', { url: settings.imageBaseUrl }) }} + {{ t('setup.domainSeparationProxyHint') }} + + {{ t('setup.domainSeparationProxyExample') }} +

+ +
+
+
+ + + {{ sourceBadge(settings.siteBaseUrlSource).label }} + +
+

+ {{ t('settings.siteBaseUrlHint') }} +

+ +

+ {{ t('settings.siteBaseUrlActive', { url: settings.siteBaseUrl }) }} +

+
+ +
+
+ + + {{ sourceBadge(settings.imageBaseUrlSource).label }} + +
+

+ {{ t('settings.imageBaseUrlHint') }} +

+ +

+ {{ t('settings.imageBaseUrlActive', { url: settings.imageBaseUrl }) }} +

+
+
+ +
+
+ + + {{ sourceBadge(settings.imageBaseUrlSource).label }} + +
+

+ {{ t('settings.imageBaseUrlSingleHint') }} +

+ +

+ {{ t('settings.imageBaseUrlActive', { url: settings.imageBaseUrl }) }} +

+
+ +
+ +
@@ -335,10 +532,13 @@ watch(isAuthenticated, async (authed, prev) => { {{ t('settings.apiTokenLine', { source: tokenSourceText(settings.tokenSource) }) }} v{{ settings.appVersion }} @@ -347,6 +547,17 @@ watch(isAuthenticated, async (authed, prev) => { class="size-3 opacity-70" /> + @@ -373,5 +584,28 @@ watch(isAuthenticated, async (authed, prev) => {
+ + + + diff --git a/app/pages/setup.vue b/app/pages/setup.vue index c5a40cf..d75fe0c 100644 --- a/app/pages/setup.vue +++ b/app/pages/setup.vue @@ -16,14 +16,21 @@ const username = ref('') const password = ref('') const confirmPassword = ref('') const allowRegistration = ref(false) +const domainSeparation = ref(false) +const siteBaseUrl = ref('') +const imageBaseUrl = ref('') const loading = ref(false) -const canSubmit = computed(() => - Boolean(username.value && password.value && confirmPassword.value) - && password.value === confirmPassword.value - && isPasswordValid(password.value) - && AUTH_USERNAME_PATTERN.test(username.value.trim()) -) +const canSubmit = computed(() => { + if (!username.value || !password.value || !confirmPassword.value) return false + if (password.value !== confirmPassword.value) return false + if (!isPasswordValid(password.value)) return false + if (!AUTH_USERNAME_PATTERN.test(username.value.trim())) return false + if (domainSeparation.value) { + return Boolean(siteBaseUrl.value.trim() && imageBaseUrl.value.trim()) + } + return true +}) onMounted(async () => { const status = await fetchStatus() @@ -34,6 +41,10 @@ onMounted(async () => { } if (status.initialized && !status.needsMigration) { await router.replace('/') + return + } + if (import.meta.client) { + siteBaseUrl.value = window.location.origin } }) @@ -50,7 +61,10 @@ async function submit() { const payload = { username: username.value.trim(), password: password.value, - allowRegistration: allowRegistration.value + allowRegistration: allowRegistration.value, + domainSeparation: domainSeparation.value, + siteBaseUrl: domainSeparation.value ? siteBaseUrl.value.trim() : undefined, + imageBaseUrl: domainSeparation.value ? imageBaseUrl.value.trim() : undefined } const result = isMigrate.value ? await migrate(payload) @@ -76,7 +90,7 @@ async function submit() {
-
+
+
+ + + +
+ Caddy, NPM, and similar tools work the same: proxy both hostnames to `http://:6892` and forward `Host` and `X-Forwarded-Proto`. + +## FN NAS · Lucky reverse proxy + +In [Lucky](https://github.com/gdy666/lucky), create one **reverse proxy** rule per domain. **Frontend** is the public hostname; **backend** is PicHost on your LAN (e.g. `http://192.168.8.3:6892`). + +**Site domain (admin)** + +| Field | Example | +| ----- | ------- | +| Service type | Reverse proxy | +| Frontend | `admin.pichost.com` | +| Backend | `http://192.168.8.3:6892` | + +![Lucky site domain](screenshots/lucky-site.png) + +**Image domain** + +| Field | Example | +| ----- | ------- | +| Service type | Reverse proxy | +| Frontend | `image.pichost.com` | +| Backend | `http://192.168.8.3:6892` | + +![Lucky image domain](screenshots/lucky-image.png) + +Both rules can point to the same backend; PicHost applies host-based isolation automatically. + +## Environment variables (optional) + +```env +SITE_BASE_URL=https://admin.example.com +IMAGE_BASE_URL=https://pic.example.com +``` + +## Notes + +- Hostnames must **differ** (same machine, different subdomains is fine) +- No separate static root on the image domain; unlike EasyImages, PicHost isolates in-app +- Single-domain setups can skip separation and use `IMAGE_BASE_URL` or the request host diff --git a/docs/domain-separation.md b/docs/domain-separation.md new file mode 100644 index 0000000..a8bc07d --- /dev/null +++ b/docs/domain-separation.md @@ -0,0 +1,125 @@ +# 双域名分离 · 推荐部署架构 + +启用「后台与图片域名分离」后,管理后台、API 与图片直链使用不同域名。PicHost 通过 **Host 中间件** 在应用层隔离:图片域仅放行图片路径,其余请求返回 404。 + +## 架构说明 + +**单 Docker 实例 + 两个域名**,均可 **全量反代** 到 `6892`(隔离由 PicHost 中间件负责,无需在 Nginx 层拆分路径)。 + +| 域名 | 用途 | +| ---- | ---- | +| `admin.example.com` | 管理后台、API、Twikoo 上传 | +| `pic.example.com` | 图片直链(中间件拦截非图片路径) | + +初始化时在 `/setup` 或后台 **设置** 中填写: + +- **网站域名**:`https://admin.example.com` +- **图片域名**:`https://pic.example.com` + +## Nginx 示例 + +```nginx +# admin.example.com — 后台 + API + 出图 +server { + server_name admin.example.com; + location / { + proxy_pass http://127.0.0.1:6892; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + client_max_body_size 12m; + } +} + +# pic.example.com — 同样全量反代;PicHost 中间件会拦截非图片路径 +server { + server_name pic.example.com; + location / { + proxy_pass http://127.0.0.1:6892; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +> Caddy、NPM(Nginx Proxy Manager)等工具同理:两个域名均反代到 `http://<主机>:6892`,并保留 `Host` 与 `X-Forwarded-Proto` 头。 + +## 飞牛 NAS · Lucky 反向代理 + +在 [Lucky](https://github.com/gdy666/lucky) 中为两个域名各建一条 **反向代理** 规则,**前端地址** 填公网域名,**后端地址** 填 PicHost 所在内网 IP 与端口(如 `http://192.168.8.3:6892`)。 + +**网站域名(管理后台)** + +| 项 | 示例值 | +| -- | ------ | +| 服务类型 | 反向代理 | +| 前端地址 | `admin.pichost.com` | +| 后端地址 | `http://192.168.8.3:6892` | + +![Lucky 网站域名配置](screenshots/lucky-site.png) + +**图片域名** + +| 项 | 示例值 | +| -- | ------ | +| 服务类型 | 反向代理 | +| 前端地址 | `image.pichost.com` | +| 后端地址 | `http://192.168.8.3:6892` | + +![Lucky 图片域名配置](screenshots/lucky-image.png) + +两条规则的后端地址相同即可;域名分离与路径隔离由 PicHost 根据请求 `Host` 自动处理。 + +## 环境变量(可选) + +除 Web 设置外,也可在 `.env` 或 Docker 环境中配置: + +```env +SITE_BASE_URL=https://admin.example.com +IMAGE_BASE_URL=https://pic.example.com +``` + +## 本地测试(Windows / macOS) + +### 1. 修改 hosts + +```text +127.0.0.1 admin.pichost.test +127.0.0.1 pic.pichost.test +``` + +建议使用 `.test` 后缀,避免 `.local` 在部分系统上与 mDNS 冲突。 + +### 2. 启动开发服务 + +```bash +npm run dev +``` + +项目已默认绑定 `127.0.0.1:3000`(与 hosts 的 IPv4 一致)。若曾出现 `ping` 通但浏览器连不上,多半是 dev 只监听了 IPv6 `::1`。 + +### 3. 填写双域名 + +| 项 | 示例 | +| -- | ---- | +| 网站域名 | `http://admin.pichost.test:3000` | +| 图片域名 | `http://pic.pichost.test:3000` | + +**后台请始终用网站域名打开**,不要用 `localhost`(Cookie 与 Host 不一致)。 + +### 4. 预期结果 + +| 地址 | 预期 | +| ---- | ---- | +| `http://admin.pichost.test:3000/` | 正常进入后台 | +| `http://pic.pichost.test:3000/` | **404**(正常,图片域不提供后台) | +| `http://pic.pichost.test:3000/images/...` | 能出图 | +| `http://localhost:3000/` | 能打开,但与双域名配置无关,登录态不共享 | + +上传一张图后,复制链接应指向 `pic.pichost.test` 域名;在后台图库中缩略图也应能正常显示。 + +## 相关说明 + +- 两个域名 **主机名不能相同**(可同机、不同子域) +- 图片域无需单独部署静态目录;与 EasyImages 等不同,PicHost 在应用内完成隔离 +- 单域名部署可不启用分离,仅配置 `IMAGE_BASE_URL` 或使用默认当前访问域名 +- 配置了 Referer 防盗链时,网站域与图片域会自动加入白名单,无需手写两个域名 diff --git a/docs/screenshots/lucky-image.png b/docs/screenshots/lucky-image.png new file mode 100644 index 0000000000000000000000000000000000000000..4797ab512f1f52650b13bccbec30208beec4ad3e GIT binary patch literal 32323 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 JR}1O~{2z`&0OkMy literal 0 HcmV?d00001 diff --git a/docs/screenshots/lucky-site.png b/docs/screenshots/lucky-site.png new file mode 100644 index 0000000000000000000000000000000000000000..df5e9604388a26a7b4ce60f32fdd6e2a1aeb1615 GIT binary patch literal 39030 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 -[CmdletBinding()] -param( - [string] $DataDir = (Join-Path (Get-Location) 'data'), - [int] $UserId = 0, - [switch] $Apply -) - -$ErrorActionPreference = 'Stop' - -function Resolve-AdminUserId { - param([string] $DbPath) - - if (-not (Test-Path -LiteralPath $DbPath)) { - Write-Warning "pichost.db not found at $DbPath; using UserId = 1" - return 1 - } - - $repoRoot = Split-Path -Parent $PSScriptRoot - $nodeFile = Join-Path $env:TEMP "pichost-read-admin-$PID.mjs" - @' -import { DatabaseSync } from "node:sqlite"; -const db = new DatabaseSync(process.argv[2]); -const row = db.prepare("SELECT id FROM users WHERE role = 'admin' ORDER BY id ASC LIMIT 1").get(); -if (!row) process.exit(2); -process.stdout.write(String(row.id)); -db.close(); -'@ | Set-Content -LiteralPath $nodeFile -Encoding utf8 - - Push-Location $repoRoot - try { - $output = & node $nodeFile $DbPath 2>&1 - if ($LASTEXITCODE -eq 2) { - throw "No admin user in database. Run /setup first or pass -UserId explicitly." - } - if ($LASTEXITCODE -ne 0) { - throw "Failed to read admin from database: $output" - } - return [int]$output - } - finally { - Pop-Location - Remove-Item -LiteralPath $nodeFile -ErrorAction SilentlyContinue - } -} - -function Read-MetaJson { - param([string] $Path) - $raw = [System.IO.File]::ReadAllText($Path, [System.Text.UTF8Encoding]::new($false)) - return $raw | ConvertFrom-Json -} - -function Write-MetaJson { - param( - [string] $Path, - [object] $Meta - ) - # Compact single-line JSON, same style as PicHost putImage() - $json = $Meta | ConvertTo-Json -Compress -Depth 10 - $utf8 = [System.Text.UTF8Encoding]::new($false) - [System.IO.File]::WriteAllText($Path, $json, $utf8) -} - -if (-not (Test-Path -LiteralPath $DataDir)) { - throw "DataDir not found: $DataDir" -} - -$resolved = Resolve-Path -LiteralPath $DataDir -$DataDir = if ($resolved.ProviderPath) { $resolved.ProviderPath } else { $resolved.Path } - -if ($UserId -le 0) { - $UserId = Resolve-AdminUserId -DbPath (Join-Path $DataDir 'pichost.db') -} - -Write-Host "DataDir : $DataDir" -Write-Host "UserId : $UserId" -Write-Host "Mode : $(if ($Apply) { 'APPLY (will modify files)' } else { 'DRY-RUN (pass -Apply to write)' })" -Write-Host '' - -$metaFiles = Get-ChildItem -LiteralPath $DataDir -Recurse -File -Filter '*.meta.json' | - Where-Object { $_.Name -notlike 'pichost.db*' } - -if (-not $metaFiles) { - Write-Host 'No .meta.json files found under DataDir.' - exit 0 -} - -$patched = 0 -$skipped = 0 -$errors = 0 - -foreach ($file in $metaFiles) { - try { - $meta = Read-MetaJson -Path $file.FullName - $current = $meta.PSObject.Properties['userId'] - $hasUserId = $null -ne $current -and $null -ne $current.Value - - if ($hasUserId) { - $skipped++ - continue - } - - $relative = [System.IO.Path]::GetRelativePath($DataDir, $file.FullName) - Write-Host "[patch] $relative -> userId $UserId" - - if ($Apply) { - $meta | Add-Member -NotePropertyName userId -NotePropertyValue $UserId -Force - Write-MetaJson -Path $file.FullName -Meta $meta - } - - $patched++ - } - catch { - $errors++ - Write-Warning "Failed: $($file.FullName) — $_" - } -} - -Write-Host '' -Write-Host "Done. patched=$patched skipped(already had userId)=$skipped errors=$errors" -if (-not $Apply -and $patched -gt 0) { - Write-Host 'Re-run with -Apply to write changes.' -} diff --git a/server/api/settings/index.patch.ts b/server/api/settings/index.patch.ts index a0d89cc..7c15ae4 100644 --- a/server/api/settings/index.patch.ts +++ b/server/api/settings/index.patch.ts @@ -1,9 +1,12 @@ import { requireAdminAuth } from '../../utils/access' import { createApiError } from '../../utils/api-error' import { + getSetting, setSetting, SETTINGS_ALLOWED_REFERER_HOSTS, + SETTINGS_HIDE_FOLDER_IN_URL, SETTINGS_IMAGE_BASE_URL, + SETTINGS_SITE_BASE_URL, SETTINGS_WEBP_QUALITY, setAllowRegistration } from '../../utils/db' @@ -11,18 +14,23 @@ import { getSettingsPayload, isValidImageBaseUrl, isValidRefererHost, + isValidSiteBaseUrl, MAX_AUTO_DELETE_DAYS, normalizeImageBaseUrl, normalizeRefererHosts, + normalizeSiteBaseUrl, parseAutoDeleteDays, parseWebpQuality, - setGlobalAutoDeletePolicy + setGlobalAutoDeletePolicy, + validateDomainSeparationPair } from '../../utils/env' interface SettingsPatchBody { webpQuality?: unknown allowedRefererHosts?: unknown + siteBaseUrl?: unknown imageBaseUrl?: unknown + hideFolderInUrl?: unknown autoDeleteDays?: unknown allowRegistration?: unknown } @@ -61,6 +69,21 @@ export default defineEventHandler(async (event) => { setSetting(SETTINGS_ALLOWED_REFERER_HOSTS, normalized) } + if (body.siteBaseUrl !== undefined) { + const configured = normalizeSiteBaseUrl( + typeof body.siteBaseUrl === 'string' ? body.siteBaseUrl : '' + ) + if (configured && !isValidSiteBaseUrl(configured)) { + createApiError( + event, + 'INVALID_REQUEST', + 'SITE_BASE_URL 需为 http(s) 地址,或留空使用当前请求域名', + 400 + ) + } + setSetting(SETTINGS_SITE_BASE_URL, configured) + } + if (body.imageBaseUrl !== undefined) { const configured = normalizeImageBaseUrl( typeof body.imageBaseUrl === 'string' ? body.imageBaseUrl : '' @@ -76,6 +99,21 @@ export default defineEventHandler(async (event) => { setSetting(SETTINGS_IMAGE_BASE_URL, configured) } + if (body.siteBaseUrl !== undefined || body.imageBaseUrl !== undefined) { + const siteConfigured = normalizeSiteBaseUrl( + getSetting(SETTINGS_SITE_BASE_URL) ?? '' + ) + const imageConfigured = normalizeImageBaseUrl( + getSetting(SETTINGS_IMAGE_BASE_URL) ?? '' + ) + if (siteConfigured && imageConfigured) { + const pairError = validateDomainSeparationPair(siteConfigured, imageConfigured) + if (pairError) { + createApiError(event, 'INVALID_REQUEST', pairError, 400) + } + } + } + if (body.autoDeleteDays !== undefined) { const days = parseAutoDeleteDays(String(body.autoDeleteDays)) if (days === null) { @@ -93,5 +131,9 @@ export default defineEventHandler(async (event) => { setAllowRegistration(Boolean(body.allowRegistration)) } + if (body.hideFolderInUrl !== undefined) { + setSetting(SETTINGS_HIDE_FOLDER_IN_URL, body.hideFolderInUrl ? 'true' : 'false') + } + return getSettingsPayload(event) }) diff --git a/server/api/setup.post.ts b/server/api/setup.post.ts index c3e46f6..ad53dad 100644 --- a/server/api/setup.post.ts +++ b/server/api/setup.post.ts @@ -4,13 +4,21 @@ import { createUserAccount, isInitialized } from '../utils/auth' -import { setAllowRegistration } from '../utils/db' +import { setAllowRegistration, setSetting, SETTINGS_IMAGE_BASE_URL, SETTINGS_SITE_BASE_URL } from '../utils/db' +import { + normalizeImageBaseUrl, + normalizeSiteBaseUrl, + validateDomainSeparationPair +} from '../utils/env' import { clientIp, logInfo } from '../utils/logger' interface SetupBody { username?: string password?: string allowRegistration?: boolean + domainSeparation?: boolean + siteBaseUrl?: string + imageBaseUrl?: string } export default defineEventHandler(async (event) => { @@ -30,6 +38,18 @@ export default defineEventHandler(async (event) => { }) setAllowRegistration(Boolean(body?.allowRegistration)) + + if (body?.domainSeparation) { + const siteBaseUrl = normalizeSiteBaseUrl(body.siteBaseUrl ?? '') + const imageBaseUrl = normalizeImageBaseUrl(body.imageBaseUrl ?? '') + const pairError = validateDomainSeparationPair(siteBaseUrl, imageBaseUrl) + if (pairError) { + createApiError(event, 'INVALID_REQUEST', pairError, 400) + } + setSetting(SETTINGS_SITE_BASE_URL, siteBaseUrl) + setSetting(SETTINGS_IMAGE_BASE_URL, imageBaseUrl) + } + await createSession(event, user.id) logInfo('setup complete', { ip: clientIp(event), username: user.username }) diff --git a/server/api/user/api-token.get.ts b/server/api/user/api-token.get.ts index fcd8d5c..0080403 100644 --- a/server/api/user/api-token.get.ts +++ b/server/api/user/api-token.get.ts @@ -1,6 +1,6 @@ import { requireUserAuth } from '../../utils/access' import { ensureUserApiToken } from '../../utils/db' -import { getImageBaseUrl } from '../../utils/env' +import { getImageBaseUrl, getSiteBaseUrl, isHideFolderInUrl } from '../../utils/env' export default defineEventHandler(async (event) => { const user = await requireUserAuth(event) @@ -13,7 +13,9 @@ export default defineEventHandler(async (event) => { env: { webpQuality: 80, refererConfigured: false, + siteBaseUrl: getSiteBaseUrl(event), imageBaseUrl: getImageBaseUrl(event), + hideFolderInUrl: isHideFolderInUrl(event), appVersion: config.appVersion as string } } diff --git a/server/api/version/latest.get.ts b/server/api/version/latest.get.ts new file mode 100644 index 0000000..7289d8a --- /dev/null +++ b/server/api/version/latest.get.ts @@ -0,0 +1,29 @@ +import { requireAdminAuth } from '../../utils/access' +import { + fetchLatestGitHubRelease, + isVersionNewer +} from '../../utils/app-version' + +export default defineEventHandler(async (event) => { + await requireAdminAuth(event) + + const config = useRuntimeConfig(event) + const currentVersion = String(config.appVersion ?? '') + const latest = await fetchLatestGitHubRelease() + + if (!latest) { + return { + currentVersion, + latestVersion: null, + updateAvailable: false, + releaseUrl: null + } + } + + return { + currentVersion, + latestVersion: latest.latestVersion, + updateAvailable: isVersionNewer(latest.latestVersion, currentVersion), + releaseUrl: latest.releaseUrl + } +}) diff --git a/server/middleware/00-host-isolation.ts b/server/middleware/00-host-isolation.ts new file mode 100644 index 0000000..4ac896f --- /dev/null +++ b/server/middleware/00-host-isolation.ts @@ -0,0 +1,7 @@ +import { shouldBlockImageHostRequest } from '../utils/host-isolation' + +export default defineEventHandler((event) => { + if (shouldBlockImageHostRequest(event)) { + throw createError({ statusCode: 404, statusMessage: 'Not Found' }) + } +}) diff --git a/server/middleware/01-serve-image.ts b/server/middleware/01-serve-image.ts index 1011008..2bdb2b6 100644 --- a/server/middleware/01-serve-image.ts +++ b/server/middleware/01-serve-image.ts @@ -8,7 +8,9 @@ export default defineEventHandler(async (event) => { const method = getMethod(event) if (method !== 'GET' && method !== 'HEAD') return - const key = requestPathToImageKey(getRequestURL(event).pathname) + const key = requestPathToImageKey(getRequestURL(event).pathname, { + hideFolder: isHideFolderInUrl(event) + }) if (!key) return return serveImageByKey(event, key) diff --git a/server/utils/app-version.test.ts b/server/utils/app-version.test.ts new file mode 100644 index 0000000..6973674 --- /dev/null +++ b/server/utils/app-version.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { isVersionNewer, normalizeVersionTag, parseVersionParts } from './app-version' + +describe('app-version', () => { + it('normalizes tag prefixes', () => { + expect(normalizeVersionTag('v1.1.2')).toBe('1.1.2') + expect(normalizeVersionTag('V2.0.0')).toBe('2.0.0') + }) + + it('parses dotted versions', () => { + expect(parseVersionParts('1.10.3')).toEqual([1, 10, 3]) + }) + + it('detects newer versions', () => { + expect(isVersionNewer('1.2.0', '1.1.2')).toBe(true) + expect(isVersionNewer('1.1.2', '1.1.2')).toBe(false) + expect(isVersionNewer('1.1.1', '1.1.2')).toBe(false) + expect(isVersionNewer('2.0.0', '1.9.9')).toBe(true) + }) +}) diff --git a/server/utils/app-version.ts b/server/utils/app-version.ts new file mode 100644 index 0000000..15ef327 --- /dev/null +++ b/server/utils/app-version.ts @@ -0,0 +1,77 @@ +const GITHUB_REPO = 'O96u/PicHost' +const RELEASES_LATEST_URL = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest` +const CACHE_TTL_MS = 60 * 60 * 1000 + +export interface LatestReleaseInfo { + latestVersion: string + releaseUrl: string +} + +let cachedRelease: { fetchedAt: number, value: LatestReleaseInfo | null } | null = null + +export function normalizeVersionTag(tag: string): string { + return tag.trim().replace(/^v/i, '') +} + +export function parseVersionParts(version: string): number[] { + return normalizeVersionTag(version) + .split('.') + .map(part => Number.parseInt(part, 10)) + .map(part => (Number.isFinite(part) ? part : 0)) +} + +export function isVersionNewer(candidate: string, current: string): boolean { + const a = parseVersionParts(candidate) + const b = parseVersionParts(current) + const length = Math.max(a.length, b.length) + + for (let i = 0; i < length; i++) { + const diff = (a[i] ?? 0) - (b[i] ?? 0) + if (diff !== 0) return diff > 0 + } + return false +} + +export async function fetchLatestGitHubRelease(): Promise { + const now = Date.now() + if (cachedRelease && now - cachedRelease.fetchedAt < CACHE_TTL_MS) { + return cachedRelease.value + } + + try { + const response = await fetch(RELEASES_LATEST_URL, { + headers: { + 'Accept': 'application/vnd.github+json', + 'User-Agent': 'PicHost' + }, + signal: AbortSignal.timeout(8000) + }) + + if (!response.ok) { + cachedRelease = { fetchedAt: now, value: null } + return null + } + + const data = await response.json() as { + tag_name?: string + html_url?: string + } + + const tag = typeof data.tag_name === 'string' ? data.tag_name : '' + const releaseUrl = typeof data.html_url === 'string' ? data.html_url : '' + if (!tag) { + cachedRelease = { fetchedAt: now, value: null } + return null + } + + const value: LatestReleaseInfo = { + latestVersion: normalizeVersionTag(tag), + releaseUrl: releaseUrl || `https://github.com/${GITHUB_REPO}/releases/latest` + } + cachedRelease = { fetchedAt: now, value } + return value + } catch { + cachedRelease = { fetchedAt: now, value: null } + return null + } +} diff --git a/server/utils/db.ts b/server/utils/db.ts index c5f887b..d858673 100644 --- a/server/utils/db.ts +++ b/server/utils/db.ts @@ -373,6 +373,8 @@ export function deleteSessionsForUser(userId: number): void { export const SETTINGS_API_UPLOAD_TOKEN = 'api_upload_token' export const SETTINGS_WEBP_QUALITY = 'webp_quality' export const SETTINGS_ALLOWED_REFERER_HOSTS = 'allowed_referer_hosts' +export const SETTINGS_HIDE_FOLDER_IN_URL = 'hide_folder_in_url' +export const SETTINGS_SITE_BASE_URL = 'site_base_url' export const SETTINGS_IMAGE_BASE_URL = 'image_base_url' export const SETTINGS_AUTO_DELETE_DAYS = 'auto_delete_days' export const SETTINGS_AUTO_DELETE_ENABLED_AT = 'auto_delete_enabled_at' diff --git a/server/utils/env.test.ts b/server/utils/env.test.ts index 7653ddb..27b0683 100644 --- a/server/utils/env.test.ts +++ b/server/utils/env.test.ts @@ -8,18 +8,23 @@ import { getAutoDeleteDays, getAutoDeleteDaysSource, getRefererSource, + getSiteBaseUrl, + getSiteBaseUrlConfigured, + getSiteBaseUrlSource, getWebpQuality, getWebpQualitySource, isApiUploadTokenEnvConfigured, isAutoDeleteDaysEnvConfigured, isRefererEnvConfigured, - isWebpQualityEnvConfigured + isWebpQualityEnvConfigured, + validateDomainSeparationPair } from './env' import { getSetting, SETTINGS_ALLOWED_REFERER_HOSTS, SETTINGS_API_UPLOAD_TOKEN, SETTINGS_AUTO_DELETE_DAYS, + SETTINGS_SITE_BASE_URL, SETTINGS_WEBP_QUALITY, generateApiUploadToken } from './db' @@ -34,9 +39,13 @@ vi.mock('./db', async (importOriginal) => { }) vi.stubGlobal('useRuntimeConfig', () => ({ - apiUploadToken: '' + apiUploadToken: '', + siteBaseUrl: '', + imageBaseUrl: '' })) +vi.stubGlobal('getRequestURL', () => new URL('https://fallback.example.com')) + function mockEvent(): H3Event { return { node: { req: {}, res: {} }, @@ -201,3 +210,48 @@ describe('getAutoDeleteDays priority', () => { expect(getAutoDeleteDaysSource()).toBe('default') }) }) + +describe('getSiteBaseUrl priority', () => { + afterEach(() => { + vi.clearAllMocks() + delete process.env.SITE_BASE_URL + }) + + it('prefers database over environment variable', () => { + process.env.SITE_BASE_URL = 'https://env.example.com' + vi.mocked(getSetting).mockImplementation(key => + key === SETTINGS_SITE_BASE_URL ? 'https://db.example.com' : null + ) + const event = mockEvent() + expect(getSiteBaseUrlConfigured(event)).toBe('https://db.example.com') + expect(getSiteBaseUrlSource(event)).toBe('db') + expect(getSiteBaseUrl(event)).toBe('https://db.example.com') + }) + + it('falls back to request origin when unset', () => { + vi.mocked(getSetting).mockReturnValue(null) + const event = mockEvent() + expect(getSiteBaseUrl(event)).toBe('https://fallback.example.com') + expect(getSiteBaseUrlSource(event)).toBe('none') + }) +}) + +describe('validateDomainSeparationPair', () => { + it('rejects missing values', () => { + expect(validateDomainSeparationPair('', 'https://pic.example.com')).toContain('同时填写') + }) + + it('rejects identical hostnames', () => { + expect(validateDomainSeparationPair( + 'https://same.example.com', + 'https://same.example.com' + )).toContain('相同主机名') + }) + + it('accepts different hostnames', () => { + expect(validateDomainSeparationPair( + 'https://admin.example.com', + 'https://pic.example.com' + )).toBeNull() + }) +}) diff --git a/server/utils/env.ts b/server/utils/env.ts index feb4cd5..4ec4db8 100644 --- a/server/utils/env.ts +++ b/server/utils/env.ts @@ -6,7 +6,9 @@ import { SETTINGS_API_UPLOAD_TOKEN, SETTINGS_AUTO_DELETE_DAYS, SETTINGS_AUTO_DELETE_ENABLED_AT, + SETTINGS_HIDE_FOLDER_IN_URL, SETTINGS_IMAGE_BASE_URL, + SETTINGS_SITE_BASE_URL, SETTINGS_WEBP_QUALITY, isAllowRegistration } from './db' @@ -47,10 +49,27 @@ export function getAdminSecret(event: H3Event): string { export type SettingSource = 'env' | 'db' | 'none' -export function normalizeImageBaseUrl(raw: string): string { +export function normalizeBaseUrl(raw: string): string { return raw.trim().replace(/\/$/, '') } +export function normalizeImageBaseUrl(raw: string): string { + return normalizeBaseUrl(raw) +} + +export function normalizeSiteBaseUrl(raw: string): string { + return normalizeBaseUrl(raw) +} + +export function hostnameFromBaseUrl(value: string): string | null { + if (!value) return null + try { + return new URL(value).hostname.toLowerCase() + } catch { + return null + } +} + export function getImageBaseUrlFromEnv(event: H3Event): string { return normalizeImageBaseUrl( getRuntimeEnv(event, 'IMAGE_BASE_URL', 'imageBaseUrl') @@ -73,6 +92,45 @@ export function getImageBaseUrlConfigured(event: H3Event): string { return getImageBaseUrlFromEnv(event) } +export function getSiteBaseUrlFromEnv(event: H3Event): string { + return normalizeSiteBaseUrl( + getRuntimeEnv(event, 'SITE_BASE_URL', 'siteBaseUrl') + ) +} + +export function isSiteBaseUrlEnvConfigured(event: H3Event): boolean { + return getSiteBaseUrlFromEnv(event).length > 0 +} + +export function getSiteBaseUrlSource(event: H3Event): SettingSource { + if (getSetting(SETTINGS_SITE_BASE_URL) !== null) return 'db' + if (isSiteBaseUrlEnvConfigured(event)) return 'env' + return 'none' +} + +export function getSiteBaseUrlConfigured(event: H3Event): string { + const dbRaw = getSetting(SETTINGS_SITE_BASE_URL) + if (dbRaw !== null) return normalizeSiteBaseUrl(dbRaw) + return getSiteBaseUrlFromEnv(event) +} + +export function getSiteBaseUrl(event: H3Event): string { + const dbRaw = getSetting(SETTINGS_SITE_BASE_URL) + if (dbRaw !== null) { + const configured = normalizeSiteBaseUrl(dbRaw) + if (configured) return configured + } else { + const fromEnv = getSiteBaseUrlFromEnv(event) + if (fromEnv) return fromEnv + } + + const requestUrl = getRequestURL(event, { + xForwardedHost: true, + xForwardedProto: true + }) + return requestUrl.origin +} + export function getImageBaseUrl(event: H3Event): string { const dbRaw = getSetting(SETTINGS_IMAGE_BASE_URL) if (dbRaw !== null) { @@ -91,6 +149,36 @@ export function getImageBaseUrl(event: H3Event): string { return requestUrl.origin } +export function isHideFolderInUrlFromEnv(): boolean { + try { + return process.env.HIDE_FOLDER_IN_URL?.trim().toLowerCase() === 'true' + } catch { + return false + } +} + +export function isHideFolderInUrlEnvConfigured(): boolean { + try { + const raw = process.env.HIDE_FOLDER_IN_URL?.trim().toLowerCase() + return raw === 'true' || raw === 'false' + } catch { + return false + } +} + +export function getHideFolderInUrlSource(): SettingSource { + if (getSetting(SETTINGS_HIDE_FOLDER_IN_URL) !== null) return 'db' + if (isHideFolderInUrlEnvConfigured()) return 'env' + return 'none' +} + +/** 优先级:SQLite settings → 环境变量 → 默认 false */ +export function isHideFolderInUrl(_event?: H3Event): boolean { + const dbRaw = getSetting(SETTINGS_HIDE_FOLDER_IN_URL) + if (dbRaw !== null) return dbRaw === 'true' + return isHideFolderInUrlFromEnv() +} + export function getApiUploadTokenFromEnv(event: H3Event): string { return ( getRuntimeEnv(event, 'API_UPLOAD_TOKEN', 'apiUploadToken') @@ -294,7 +382,7 @@ export function isValidRefererHost(host: string): boolean { return /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i.test(host) } -export function isValidImageBaseUrl(value: string): boolean { +export function isValidBaseUrl(value: string): boolean { if (!value) return true try { const url = new URL(value) @@ -304,13 +392,46 @@ export function isValidImageBaseUrl(value: string): boolean { } } +export function isValidImageBaseUrl(value: string): boolean { + return isValidBaseUrl(value) +} + +export function isValidSiteBaseUrl(value: string): boolean { + return isValidBaseUrl(value) +} + +export function validateDomainSeparationPair( + siteBaseUrl: string, + imageBaseUrl: string +): string | null { + if (!siteBaseUrl || !imageBaseUrl) { + return '启用域名分离时需同时填写网站域名与图片域名' + } + if (!isValidSiteBaseUrl(siteBaseUrl) || !isValidImageBaseUrl(imageBaseUrl)) { + return '域名需为 http(s) 地址,且末尾不加 /' + } + const siteHost = hostnameFromBaseUrl(siteBaseUrl) + const imageHost = hostnameFromBaseUrl(imageBaseUrl) + if (!siteHost || !imageHost) { + return '域名格式无效' + } + if (siteHost === imageHost) { + return '网站域名与图片域名不能使用相同主机名' + } + return null +} + export function getSettingsPayload(event: H3Event) { const config = useRuntimeConfig(event) const webpQuality = getWebpQuality(event) const allowedRefererHosts = getAllowedRefererHostsRaw(event) + const siteBaseUrl = getSiteBaseUrl(event) const imageBaseUrl = getImageBaseUrl(event) const autoDeleteDays = getAutoDeleteDays(event) const appVersion = config.appVersion as string + const domainSeparation = isDomainSeparationActive(event) + const hideFolderInUrl = isHideFolderInUrl(event) + const hideFolderInUrlSource = getHideFolderInUrlSource() return { apiUploadToken: getApiUploadToken(event), @@ -321,9 +442,15 @@ export function getSettingsPayload(event: H3Event) { allowedRefererHosts, refererSource: getRefererSource(), refererEnvFallback: getRefererHostsFromEnv(), + siteBaseUrl, + siteBaseUrlConfigured: getSiteBaseUrlConfigured(event), + siteBaseUrlSource: getSiteBaseUrlSource(event), imageBaseUrl, imageBaseUrlConfigured: getImageBaseUrlConfigured(event), imageBaseUrlSource: getImageBaseUrlSource(event), + domainSeparation, + hideFolderInUrl, + hideFolderInUrlSource, autoDeleteDays, autoDeleteDaysSource: getAutoDeleteDaysSource(), autoDeleteEnvFallback: parseAutoDeleteDays(getAutoDeleteDaysFromEnv()) ?? 0, @@ -332,8 +459,16 @@ export function getSettingsPayload(event: H3Event) { env: { webpQuality, refererConfigured: allowedRefererHosts.length > 0, + siteBaseUrl, imageBaseUrl, + hideFolderInUrl, appVersion } } } + +export function isDomainSeparationActive(event: H3Event): boolean { + const siteHost = hostnameFromBaseUrl(getSiteBaseUrlConfigured(event)) + const imageHost = hostnameFromBaseUrl(getImageBaseUrlConfigured(event)) + return Boolean(siteHost && imageHost && siteHost !== imageHost) +} diff --git a/server/utils/host-isolation.test.ts b/server/utils/host-isolation.test.ts new file mode 100644 index 0000000..7030a2f --- /dev/null +++ b/server/utils/host-isolation.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import type { H3Event } from 'h3' +import { shouldBlockImageHostRequest } from './host-isolation' + +function mockEvent(): H3Event { + return { + node: { req: {}, res: {} }, + context: {} + } as H3Event +} + +describe('shouldBlockImageHostRequest', () => { + const imageHost = 'pic.example.com' + const siteHost = 'admin.example.com' + + it('does not block when separation is inactive', () => { + const event = mockEvent() + expect(shouldBlockImageHostRequest(event, { + separationActive: false, + requestHost: imageHost, + method: 'GET', + pathname: '/settings' + })).toBe(false) + }) + + it('blocks non-image paths on image host', () => { + const event = mockEvent() + expect(shouldBlockImageHostRequest(event, { + separationActive: true, + imageHost, + requestHost: imageHost, + method: 'GET', + pathname: '/settings' + })).toBe(true) + }) + + it('allows image paths on image host', () => { + const event = mockEvent() + expect(shouldBlockImageHostRequest(event, { + separationActive: true, + imageHost, + requestHost: imageHost, + method: 'GET', + pathname: '/images/2026/08/demo.webp' + })).toBe(false) + }) + + it('blocks POST on image host even for image paths', () => { + const event = mockEvent() + expect(shouldBlockImageHostRequest(event, { + separationActive: true, + imageHost, + requestHost: imageHost, + method: 'POST', + pathname: '/images/2026/08/demo.webp' + })).toBe(true) + }) + + it('does not block site host admin paths', () => { + const event = mockEvent() + expect(shouldBlockImageHostRequest(event, { + separationActive: true, + imageHost, + requestHost: siteHost, + method: 'GET', + pathname: '/api/images' + })).toBe(false) + }) +}) diff --git a/server/utils/host-isolation.ts b/server/utils/host-isolation.ts new file mode 100644 index 0000000..690e42f --- /dev/null +++ b/server/utils/host-isolation.ts @@ -0,0 +1,51 @@ +import type { H3Event } from 'h3' +import { + getImageBaseUrlConfigured, + getSiteBaseUrlConfigured, + hostnameFromBaseUrl, + isDomainSeparationActive, + isHideFolderInUrl +} from './env' +import { requestPathToImageKey } from './serve-image' + +export function getConfiguredSiteHost(event: H3Event): string | null { + return hostnameFromBaseUrl(getSiteBaseUrlConfigured(event)) +} + +export function getConfiguredImageHost(event: H3Event): string | null { + return hostnameFromBaseUrl(getImageBaseUrlConfigured(event)) +} + +export function shouldBlockImageHostRequest( + event: H3Event, + options?: { + method?: string + pathname?: string + requestHost?: string + separationActive?: boolean + imageHost?: string | null + } +): boolean { + const separationActive = options?.separationActive ?? isDomainSeparationActive(event) + if (!separationActive) return false + + const imageHost = options?.imageHost ?? getConfiguredImageHost(event) + if (!imageHost) return false + + const requestUrl = options?.requestHost + ? null + : getRequestURL(event, { + xForwardedHost: true, + xForwardedProto: true + }) + const requestHost = (options?.requestHost ?? requestUrl?.hostname ?? '').toLowerCase() + if (requestHost !== imageHost) return false + + const method = (options?.method ?? getMethod(event)).toUpperCase() + if (method !== 'GET' && method !== 'HEAD') return true + + const pathname = options?.pathname ?? requestUrl?.pathname ?? '' + return requestPathToImageKey(pathname, { + hideFolder: isHideFolderInUrl(event) + }) === null +} diff --git a/server/utils/image-index.ts b/server/utils/image-index.ts index 123f738..462f605 100644 --- a/server/utils/image-index.ts +++ b/server/utils/image-index.ts @@ -2,7 +2,8 @@ import { promises as fs } from 'node:fs' import { join } from 'node:path' import { getDb } from './db' import { getDataDir } from './data-dir' -import { isValidFolderName, validateImageKey, DEFAULT_FOLDER } from './image-key' +import { DEFAULT_FOLDER, isValidFolderName, validateImageKey } from './image-key' +import { isHiddenImageDatePath } from './image-public-path' import { ensureDefaultBackends, ensureStorageSchema, @@ -114,6 +115,29 @@ export function getImageIndexAsStored(key: string): StoredImage | null { return row ? rowToStoredImage(row) : null } +/** 隐藏目录 URL(YYYY/MM/file)反查完整 key;多目录同名时优先 images */ +export function findImageKeyByDatePath(datePath: string): string | null { + if (!isHiddenImageDatePath(datePath)) return null + + ensureStorageSchema() + const suffix = `/${datePath}` + const rows = getDb().prepare(` + SELECT key FROM images WHERE key LIKE ? + `).all(`%${suffix}`) as Array<{ key: string }> + + if (rows.length === 0) { + const fallback = `${DEFAULT_FOLDER}/${datePath}` + return validateImageKey(fallback) ? fallback : null + } + if (rows.length === 1) return rows[0]!.key + + const exactDefault = rows.find(row => row.key === `${DEFAULT_FOLDER}/${datePath}`) + if (exactDefault) return exactDefault.key + + const defaultFolder = rows.find(row => row.key.startsWith(`${DEFAULT_FOLDER}/`)) + return defaultFolder?.key ?? rows[0]!.key +} + async function readDirNames(path: string): Promise { try { const entries = await fs.readdir(path, { withFileTypes: true }) diff --git a/server/utils/image-public-path.test.ts b/server/utils/image-public-path.test.ts new file mode 100644 index 0000000..7e1ab2a --- /dev/null +++ b/server/utils/image-public-path.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { isHiddenImageDatePath, toPublicImagePath } from './image-public-path' + +describe('toPublicImagePath', () => { + it('returns key unchanged when hideFolder is false', () => { + expect(toPublicImagePath('images/2026/08/a.webp', false)).toBe('images/2026/08/a.webp') + }) + + it('strips the first path segment when hideFolder is true', () => { + expect(toPublicImagePath('images/2026/08/a.webp', true)).toBe('2026/08/a.webp') + expect(toPublicImagePath('blog/2026/08/a.webp', true)).toBe('2026/08/a.webp') + }) +}) + +describe('isHiddenImageDatePath', () => { + it('accepts YYYY/MM/file paths', () => { + expect(isHiddenImageDatePath('2026/08/demo.webp')).toBe(true) + }) + + it('rejects folder-prefixed paths', () => { + expect(isHiddenImageDatePath('images/2026/08/demo.webp')).toBe(false) + }) +}) diff --git a/server/utils/image-public-path.ts b/server/utils/image-public-path.ts new file mode 100644 index 0000000..2a7f91d --- /dev/null +++ b/server/utils/image-public-path.ts @@ -0,0 +1,13 @@ +/** 对外 URL 路径:可选去掉 key 的首段目录(如 images/) */ +export function toPublicImagePath(key: string, hideFolder: boolean): string { + if (!hideFolder) return key + const slash = key.indexOf('/') + return slash > 0 ? key.slice(slash + 1) : key +} + +const HIDDEN_DATE_PATH_PATTERN + = /^\d{4}\/\d{2}\/[^/\\]+\.(jpg|jpeg|png|webp|gif|svg|ico)$/i + +export function isHiddenImageDatePath(path: string): boolean { + return HIDDEN_DATE_PATH_PATTERN.test(path) +} diff --git a/server/utils/image-response.ts b/server/utils/image-response.ts index 645295e..de92c25 100644 --- a/server/utils/image-response.ts +++ b/server/utils/image-response.ts @@ -2,7 +2,8 @@ import type { H3Event } from 'h3' import type { ImageItem } from '~/types/image' import type { AllowedMimeType } from './constants' import type { StoredImage } from './storage' -import { getImageBaseUrl } from './env' +import { getImageBaseUrl, isHideFolderInUrl } from './env' +import { toPublicImagePath } from './image-public-path' import { getActiveBackendRow } from './storage/resolver' import { getStorageBackendRow, getStorageBackendFromEnv } from './storage-backends' import type { StorageBackendType, StorageBackendRow } from './storage/types' @@ -75,19 +76,30 @@ export function mapStoredImageToItem( return item } -export function buildImageUrl(baseUrl: string, key: string): string { +export function buildImageUrl( + baseUrl: string, + key: string, + hideFolder = false +): string { const normalizedBase = baseUrl.replace(/\/$/, '') - return `${normalizedBase}/${key}` + const path = toPublicImagePath(key, hideFolder) + return `${normalizedBase}/${path}` } -export function buildPublicImageUrl(backendRow: StorageBackendRow, key: string): string { +export function buildPublicImageUrl( + backendRow: StorageBackendRow, + key: string, + hideFolder = false +): string { const base = backendRow.public_url.replace(/\/$/, '') + const path = toPublicImagePath(key, hideFolder) if (backendRow.type === 's3') { const config = parseS3Config(backendRow.config_json) const objectKey = buildObjectKey(config?.prefix, key) - return `${base}/${objectKey}` + const publicPath = hideFolder ? toPublicImagePath(key, true) : objectKey + return `${base}/${publicPath}` } - return `${base}/${key}` + return `${base}/${path}` } export function resolveImageUrl( @@ -95,14 +107,17 @@ export function resolveImageUrl( key: string, backendId?: string ): string { + const hideFolder = isHideFolderInUrl(event) const envConfig = getStorageBackendFromEnv() if (envConfig && (!backendId || backendId === 's3-primary')) { if (envConfig.servingMode === 'public' && envConfig.publicUrl) { const prefix = envConfig.config.prefix as string | undefined const objectKey = buildObjectKey(prefix, key) - return `${envConfig.publicUrl.replace(/\/$/, '')}/${objectKey}` + const base = envConfig.publicUrl.replace(/\/$/, '') + const publicPath = hideFolder ? toPublicImagePath(key, true) : objectKey + return `${base}/${publicPath}` } - return buildImageUrl(getImageBaseUrl(event), key) + return buildImageUrl(getImageBaseUrl(event), key, hideFolder) } const backendRow = backendId @@ -110,10 +125,10 @@ export function resolveImageUrl( : getActiveBackendRow() if (backendRow?.serving_mode === 'public' && backendRow.public_url) { - return buildPublicImageUrl(backendRow, key) + return buildPublicImageUrl(backendRow, key, hideFolder) } - return buildImageUrl(getImageBaseUrl(event), key) + return buildImageUrl(getImageBaseUrl(event), key, hideFolder) } export function buildMarkdown(url: string, alt: string): string { diff --git a/server/utils/serve-image-path.test.ts b/server/utils/serve-image-path.test.ts new file mode 100644 index 0000000..6cb536c --- /dev/null +++ b/server/utils/serve-image-path.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { requestPathToImageKey } from './serve-image' + +describe('requestPathToImageKey hide folder', () => { + it('resolves hidden date path to default folder key', () => { + expect(requestPathToImageKey('/2026/08/demo.webp', { hideFolder: true })) + .toBe('images/2026/08/demo.webp') + }) + + it('still accepts full key when hide folder is enabled', () => { + expect(requestPathToImageKey('/images/2026/08/demo.webp', { hideFolder: true })) + .toBe('images/2026/08/demo.webp') + }) + + it('does not resolve hidden path when hide folder is disabled', () => { + expect(requestPathToImageKey('/2026/08/demo.webp', { hideFolder: false })) + .toBeNull() + }) +}) diff --git a/server/utils/serve-image.ts b/server/utils/serve-image.ts index 4c3f041..002b368 100644 --- a/server/utils/serve-image.ts +++ b/server/utils/serve-image.ts @@ -1,6 +1,12 @@ import type { H3Event } from 'h3' import { IMAGE_CACHE_CONTROL } from './constants' -import { getAllowedRefererHostsRaw } from './env' +import { + getAllowedRefererHostsRaw, + getImageBaseUrlConfigured, + getSiteBaseUrlConfigured, + hostnameFromBaseUrl +} from './env' +import { findImageKeyByDatePath } from './image-index' import { validateImageKey } from './image-key' import { buildPublicImageUrl } from './image-response' import { createImageStream, headImage } from './storage' @@ -31,6 +37,12 @@ function getAllowedHosts(event: H3Event): Set { }) hosts.add(requestUrl.hostname.toLowerCase()) + // 双域名分离时,管理域页面会引用图片域直链,需互相放行 Referer + const siteHost = hostnameFromBaseUrl(getSiteBaseUrlConfigured(event)) + const imageHost = hostnameFromBaseUrl(getImageBaseUrlConfigured(event)) + if (siteHost) hosts.add(siteHost) + if (imageHost) hosts.add(imageHost) + return hosts } @@ -63,9 +75,19 @@ function parseRangeHeader( return { start, end: safeEnd } } -export function requestPathToImageKey(pathname: string): string | null { - const key = decodeURIComponent(pathname).replace(/^\/+/, '') - return validateImageKey(key) ? key : null +export function requestPathToImageKey( + pathname: string, + options?: { hideFolder?: boolean } +): string | null { + const raw = decodeURIComponent(pathname).replace(/^\/+/, '') + if (validateImageKey(raw)) return raw + + if (options?.hideFolder) { + const resolved = findImageKeyByDatePath(raw) + if (resolved && validateImageKey(resolved)) return resolved + } + + return null } export async function serveImageByKey(event: H3Event, key: string) {