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 0000000..4797ab5 Binary files /dev/null and b/docs/screenshots/lucky-image.png differ diff --git a/docs/screenshots/lucky-site.png b/docs/screenshots/lucky-site.png new file mode 100644 index 0000000..df5e960 Binary files /dev/null and b/docs/screenshots/lucky-site.png differ diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 45bf3ca..aa314c2 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -83,6 +83,17 @@ "passwordPlaceholder": "Set login password", "confirmPlaceholder": "Enter password again", "allowRegistration": "Allow user registration", + "domainSeparation": "Separate admin and image domains", + "domainSeparationHint": "Admin and API use the site URL; image links use a dedicated image URL", + "domainSeparationProxyHint": "Point both domains to this host and reverse-proxy to port 6892 (Nginx, Caddy, NPM, etc.)", + "domainSeparationProxyExample": "View example", + "siteBaseUrl": "Site URL", + "siteBaseUrlHint": "No trailing slash; admin UI, API docs, and Twikoo upload endpoint", + "siteBaseUrlPlaceholder": "https://admin.example.com", + "imageBaseUrl": "Image URL", + "imageBaseUrlHint": "No trailing slash; public image link prefix", + "imageBaseUrlPlaceholder": "https://pic.example.com", + "domainSeparationInvalid": "Enter valid site and image URLs with different hostnames", "migrateSubmit": "Complete migration", "initSubmit": "Create and continue", "migrateSuccess": "Migration complete", @@ -176,10 +187,22 @@ "accessControl": "Access control", "refererHint": "Referer hotlink protection; comma-separated hostnames, empty = no restriction", "refererPlaceholder": "example.com, www.example.com", - "linkGeneration": "Link generation", - "imageBaseUrlHint": "Public URL prefix for generated links; use your public domain behind a reverse proxy", + "linkGeneration": "Domains & links", + "domainSeparationActive": "Domain separation enabled: image host serves images only; site host serves admin and API", + "siteBaseUrl": "Site URL", + "siteBaseUrlHint": "No trailing slash; admin UI, API docs, and Twikoo upload endpoint", + "siteBaseUrlPlaceholder": "https://admin.example.com", + "siteBaseUrlActive": "Active: {url}", + "imageBaseUrl": "Image URL", + "imageBaseUrlHint": "No trailing slash; public image link prefix", + "imageBaseUrlSingleHint": "No trailing slash; leave empty to use the current request host; single-domain setups do not need a site URL", "imageBaseUrlPlaceholder": "https://pic.example.com", "imageBaseUrlActive": "Active: {url}", + "hideFolderInUrl": "Hide folder in image URLs", + "hideFolderInUrlHint": "Links become /2026/08/file.webp without the images/ prefix; legacy full paths still work", + "hideFolderConfirmTitle": "Hide folder prefix in URLs?", + "hideFolderConfirmDesc": "If two folders share the same year/month/filename (e.g. images/2026/08/a.webp and blog/2026/08/a.webp), the short URL /2026/08/a.webp resolves to the images folder first—you may get the wrong file. Enable only with a single upload folder or globally unique filenames.", + "hideFolderConfirmEnable": "Enable anyway", "badgeEnv": ".env", "badgeSaved": "Saved", "badgeDefault": "Default", @@ -189,6 +212,7 @@ "tokenNone": "Not configured", "apiTokenLine": "API Token · {source} ·", "viewReleases": "View releases", + "updateAvailable": "Update available: {version}", "unsavedChanges": "Unsaved changes", "manageToken": "Manage token", "envOverride": "Overridden by environment variable; cannot edit in UI", @@ -390,12 +414,14 @@ "upload": { "title": "Upload images", "descAdmin": "Upload one or more images. Use image field; folder creates directory if missing. Also accepts file, files, type.", - "descUser": "Upload one or more images via image field. Also accepts file, files. Stored under images/." + "descUser": "Upload one or more images via image field. Also accepts file, files. Stored under images/.", + "curlCustomFolder": "# Upload to custom folder blog/" }, "list": { "title": "List images", "descAdmin": "Paginated gallery; optional folder filter. limit default 20, max 100.", - "descUser": "Paginated list of your images. limit default 20, max 100." + "descUser": "Paginated list of your images. limit default 20, max 100.", + "curlFolderFilter": "# Filter by folder" }, "search": { "title": "Search images", diff --git a/i18n/locales/zh-CN.json b/i18n/locales/zh-CN.json index dd34fa4..f6d34f0 100644 --- a/i18n/locales/zh-CN.json +++ b/i18n/locales/zh-CN.json @@ -83,6 +83,17 @@ "passwordPlaceholder": "设置登录密码", "confirmPlaceholder": "再次输入密码", "allowRegistration": "允许其他用户注册", + "domainSeparation": "启用后台与图片域名分离", + "domainSeparationHint": "管理后台与 API 使用网站域名,图片直链使用独立图片域名", + "domainSeparationProxyHint": "需将两个域名解析到本机,并通过 Nginx、Caddy、NPM 等反代到端口 6892", + "domainSeparationProxyExample": "查看示例", + "siteBaseUrl": "网站域名", + "siteBaseUrlHint": "末尾不加 /;管理后台、API 文档与 Twikoo 上传地址", + "siteBaseUrlPlaceholder": "https://admin.example.com", + "imageBaseUrl": "图片域名", + "imageBaseUrlHint": "末尾不加 /;生成图片直链地址", + "imageBaseUrlPlaceholder": "https://pic.example.com", + "domainSeparationInvalid": "请填写有效的网站域名与图片域名,且两者不能相同", "migrateSubmit": "完成迁移", "initSubmit": "创建并进入", "migrateSuccess": "账号迁移完成", @@ -176,10 +187,22 @@ "accessControl": "访问控制", "refererHint": "Referer 防盗链,逗号分隔允许引用的域名,留空不限制", "refererPlaceholder": "example.com, www.example.com", - "linkGeneration": "链接生成", - "imageBaseUrlHint": "生成图片链接的 URL 前缀,内网访问建议填公网域名", + "linkGeneration": "域名与链接", + "domainSeparationActive": "已启用域名分离:图片域仅出图,管理域提供后台与 API", + "siteBaseUrl": "网站域名", + "siteBaseUrlHint": "末尾不加 /;管理后台、API 文档与 Twikoo 上传地址", + "siteBaseUrlPlaceholder": "https://admin.example.com", + "siteBaseUrlActive": "当前生效:{url}", + "imageBaseUrl": "图片域名", + "imageBaseUrlHint": "末尾不加 /;生成图片直链地址", + "imageBaseUrlSingleHint": "末尾不加 /;留空则使用当前访问域名;单域名部署无需填写网站域名", "imageBaseUrlPlaceholder": "https://pic.example.com", "imageBaseUrlActive": "当前生效:{url}", + "hideFolderInUrl": "隐藏直链目录前缀", + "hideFolderInUrlHint": "开启后链接为 /2026/08/xxx.webp,不含 images/ 等目录名;旧链接仍可用", + "hideFolderConfirmTitle": "确认隐藏目录前缀?", + "hideFolderConfirmDesc": "开启后不同目录下若存在相同年月与文件名(如 images/2026/08/a.webp 与 blog/2026/08/a.webp),短链接 /2026/08/a.webp 将优先解析到 images 目录,可能访问到非预期的图片。仅单一上传目录或文件名全局唯一时建议开启。", + "hideFolderConfirmEnable": "仍要开启", "badgeEnv": ".env", "badgeSaved": "已保存", "badgeDefault": "默认", @@ -189,6 +212,7 @@ "tokenNone": "未配置", "apiTokenLine": "API Token · {source} ·", "viewReleases": "查看发行版", + "updateAvailable": "有新版本 {version}", "unsavedChanges": "有未保存的更改", "manageToken": "管理 Token", "envOverride": "该项已由环境变量覆盖,无法在后台修改", @@ -390,12 +414,14 @@ "upload": { "title": "上传图片", "descAdmin": "上传单张或多张图片。字段 image 传图片,folder 指定目录(不存在则自动创建);仍兼容 file、files、type。", - "descUser": "上传单张或多张图片。字段 image 传图片;仍兼容 file、files。图片固定存入 images/ 目录。" + "descUser": "上传单张或多张图片。字段 image 传图片;仍兼容 file、files。图片固定存入 images/ 目录。", + "curlCustomFolder": "# 上传到自定义目录 blog/" }, "list": { "title": "获取图片列表", "descAdmin": "分页列出图库,folder 可筛选目录,limit 默认 20、最大 100。", - "descUser": "分页列出您的图库,limit 默认 20、最大 100。" + "descUser": "分页列出您的图库,limit 默认 20、最大 100。", + "curlFolderFilter": "# 按目录筛选" }, "search": { "title": "搜索图片", diff --git a/nuxt.config.ts b/nuxt.config.ts index 167e1b7..3abd3d3 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -30,10 +30,16 @@ export default defineNuxtConfig({ appVersion: pkg.version, devBypassAccess: process.env.DEV_BYPASS_ACCESS === 'true', adminSecret: '', + siteBaseUrl: '', imageBaseUrl: '', apiUploadToken: '' }, + devServer: { + host: '127.0.0.1', + port: 3000 + }, + compatibilityDate: '2026-06-30', nitro: { @@ -43,6 +49,12 @@ export default defineNuxtConfig({ } }, + vite: { + server: { + allowedHosts: true + } + }, + eslint: { config: { stylistic: { diff --git a/package-lock.json b/package-lock.json index 14ce9f0..8074c75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,14 @@ { "name": "pichost", - "version": "1.1.2", + "version": "1.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pichost", - "version": "1.1.2", + "version": "1.1.3", "hasInstallScript": true, - "license": "MIT", + "license": "GPL-3.0-only", "dependencies": { "@aws-sdk/client-s3": "^3.876.0", "@iconify-json/lucide": "^1.2.114", diff --git a/package.json b/package.json index 7a039fb..00f6040 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pichost", - "version": "1.1.2", + "version": "1.1.3", "description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk or S3-compatible storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.", "homepage": "https://github.com/O96u/PicHost#readme", "repository": { @@ -19,7 +19,7 @@ "twikoo", "easyimage" ], - "license": "MIT", + "license": "GPL-3.0-only", "private": true, "type": "module", "packageManager": "npm@11.12.1", diff --git a/scripts/assign-image-owner.ps1 b/scripts/assign-image-owner.ps1 deleted file mode 100644 index 0c3b750..0000000 --- a/scripts/assign-image-owner.ps1 +++ /dev/null @@ -1,146 +0,0 @@ -#Requires -Version 5.1 -<# -.SYNOPSIS - Assign userId to image .meta.json files that are missing it (shows as 系统/API). - -.PARAMETER DataDir - PicHost data directory (contains images/, twikoo/, pichost.db). Default: ./data - -.PARAMETER UserId - Target user id. If omitted, uses the first admin from pichost.db. - -.PARAMETER Apply - Actually write files. Without this flag, only prints what would change (dry-run). - -.EXAMPLE - cd D:\Ou\PicHost - .\scripts\assign-image-owner.ps1 - -.EXAMPLE - .\scripts\assign-image-owner.ps1 -DataDir D:\pic-host\data -Apply - -.EXAMPLE - .\scripts\assign-image-owner.ps1 -UserId 1 -Apply -#> -[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) {