From e1cd3f9ba6f4887d7cd0ed5a2414dabf2418b968 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=9F=E5=AE=81?= Date: Mon, 28 Jul 2025 16:05:40 +0800 Subject: [PATCH] porting wechat login from dev branch --- LICENSE | 708 ++++++++++++++++++ go.mod | 7 + go.sum | 14 + models/migrations/devstar_v1_0/dv1.go | 24 + models/wechat/wechat.go | 192 +++++ modules/setting/service.go | 4 +- modules/setting/setting.go | 2 + modules/setting/wechat.go | 96 +++ options/locale/locale_en-US.ini | 64 +- options/locale/locale_zh-CN.ini | 56 +- routers/api/wechat/init-wechat-routes.go | 31 + routers/init.go | 3 + routers/install/install.go | 18 +- routers/web/auth/auth.go | 83 +- routers/web/auth/wechat_qr_auth.go | 81 ++ routers/web/user/setting/account.go | 11 + routers/web/user/setting/wechat.go | 48 ++ routers/web/web.go | 14 +- services/auth/wechat_qr.go | 176 +++++ services/context/context.go | 1 + services/forms/user_form.go | 4 + services/user/user.go | 5 + services/wechat/callback.go | 186 +++++ services/wechat/qr-cache.go | 52 ++ services/wechat/qr-code.go | 283 +++++++ templates/base/head_navbar.tmpl | 5 +- templates/install.tmpl | 12 + templates/user/auth/signin.tmpl | 1 + templates/user/auth/signin_navbar.tmpl | 29 + templates/user/auth/signin_sms.tmpl | 12 + templates/user/auth/signin_sms_inner.tmpl | 37 + templates/user/auth/signin_wechat_qr.tmpl | 12 + .../user/auth/signin_wechat_qr_inner.tmpl | 207 +++++ templates/user/auth/signup.tmpl | 1 + templates/user/auth/signup_inner.tmpl | 7 + templates/user/settings/account.tmpl | 40 + 36 files changed, 2506 insertions(+), 20 deletions(-) create mode 100644 models/migrations/devstar_v1_0/dv1.go create mode 100644 models/wechat/wechat.go create mode 100644 modules/setting/wechat.go create mode 100644 routers/api/wechat/init-wechat-routes.go create mode 100644 routers/web/auth/wechat_qr_auth.go create mode 100644 routers/web/user/setting/wechat.go create mode 100644 services/auth/wechat_qr.go create mode 100644 services/wechat/callback.go create mode 100644 services/wechat/qr-cache.go create mode 100644 services/wechat/qr-code.go create mode 100644 templates/user/auth/signin_navbar.tmpl create mode 100644 templates/user/auth/signin_sms.tmpl create mode 100644 templates/user/auth/signin_sms_inner.tmpl create mode 100644 templates/user/auth/signin_wechat_qr.tmpl create mode 100644 templates/user/auth/signin_wechat_qr_inner.tmpl diff --git a/LICENSE b/LICENSE index a8d4b49dd0..eb6794a52b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,711 @@ +项目新增部分AGPL 3.0许可及版权放弃声明 + +项目名称:DevStar Studio + +发布日期:2024年11月1日 + +项目社区发起者:梦宁软件 + +一、项目背景与目的 + +本项目旨在通过社区协作,共同开发并维护一套高质量的软件产品。为了促进社区的健康发展,确保代码的开放性和透明度,项目社区决定对新增部分采用Affero General Public License, version 3.0(以下简称“AGPL 3.0”)进行发布。同时,为了保障社区发起者梦宁软件的商业利益,社区贡献者在提交代码时即放弃版权,并授权社区及梦宁软件以AGPL 3.0使用并进行商业活动。 + +二、新增部分定义 + +本声明所称“新增部分”,是指自本声明发布之日起,由社区贡献者向本项目提交的所有代码、文档、测试数据等原创性成果。 + +三、版权放弃与授权 + + 1. 版权放弃:所有社区贡献者在此声明,其向本项目提交的新增部分,在提交之时即视为已放弃对该新增部分的独占性版权。贡献者同意,在不违反法律法规的前提下,不对新增部分主张任何形式的版权保护。 + + 2. 授权使用:社区贡献者特此授权本项目社区及梦宁软件,在遵守AGPL 3.0许可证条款的前提下,对新增部分进行使用、复制、修改、合并、发布、分发及再授权等操作。 + + 3. 商业使用授权:在遵守AGPL 3.0许可证条款的前提下,社区贡献者特此授权梦宁软件对新增部分进行商业使用,包括但不限于将新增部分用于商业产品开发、销售、提供服务或进行其他形式的商业活动。 + +四、原有代码与组件许可证保持不变 + +本声明仅适用于新增部分。对于本项目在发布本声明之前已存在的代码和组件,其许可证保持不变,继续按照原有的许可证条款进行使用和分发。 + +五、合规性要求 + + 1. 所有社区贡献者在提交新增部分时,应确保该新增部分不侵犯任何第三方的知识产权,包括但不限于版权、商标、专利等。 + + 2. 梦宁软件及本项目社区在使用、修改、分发或再授权新增部分时,应遵守AGPL 3.0许可证的所有条款和条件,确保所有操作均合法合规。 + + 3. 如因新增部分引发任何知识产权纠纷或法律诉讼,相关责任应由提交该新增部分的社区贡献者承担。 + +六、免责声明 + +本项目社区及梦宁软件不对因使用新增部分而产生的任何直接、间接、附带、特殊、惩罚性或后果性的损害赔偿承担责任。同时,本项目社区及梦宁软件不保证新增部分在任何特定用途下的适用性、可靠性或准确性。 + +七、其他 + +本声明自发布之日起生效,并构成社区贡献者与本项目社区及梦宁软件之间关于新增部分版权及使用的完整协议。如有任何争议,应首先通过友好协商解决;协商不成的,任何一方均有权向中国江苏省苏州市相关法律机构提起仲裁或诉讼。 + +Copyright (c) 2024 The DevStar Authors and Mengning Software + +GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 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 Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. + Copyright (c) 2016 The Gitea Authors Copyright (c) 2015 The Gogs Authors diff --git a/go.mod b/go.mod index 8b8d61dc65..ef5dd77466 100644 --- a/go.mod +++ b/go.mod @@ -139,6 +139,9 @@ require ( dario.cat/mergo v1.0.1 // indirect filippo.io/edwards25519 v1.1.0 // indirect git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078 // indirect + github.com/ArtisanCloud/PowerLibs/v3 v3.3.2 // indirect + github.com/ArtisanCloud/PowerSocialite/v3 v3.0.8 // indirect + github.com/ArtisanCloud/PowerWeChat/v3 v3.4.21 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect github.com/DataDog/zstd v1.5.7 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect @@ -176,6 +179,7 @@ require ( github.com/caddyserver/zerossl v0.1.3 // indirect github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/clbanning/mxj/v2 v2.7.0 // indirect github.com/cloudflare/circl v1.6.1 // indirect github.com/couchbase/go-couchbase v0.1.1 // indirect github.com/couchbase/gomemcached v0.3.3 // indirect @@ -238,6 +242,7 @@ require ( github.com/nwaples/rardecode v1.1.3 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/onsi/ginkgo v1.16.5 // indirect + github.com/patrickmn/go-cache v2.1.0+incompatible // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect @@ -261,6 +266,8 @@ require ( github.com/zeebo/assert v1.3.0 // indirect github.com/zeebo/blake3 v0.2.4 // indirect go.etcd.io/bbolt v1.4.0 // indirect + go.opentelemetry.io/otel v1.34.0 // indirect + go.opentelemetry.io/otel/trace v1.34.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect diff --git a/go.sum b/go.sum index 2e7c51f747..e440d0f931 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,12 @@ github.com/42wim/sshsig v0.0.0-20240818000253-e3a6333df815 h1:5EoemV++kUK2Sw98yW github.com/42wim/sshsig v0.0.0-20240818000253-e3a6333df815/go.mod h1:zjsWZdDLrcDojDIfpQg7A6J4YZLT0cbwuAD26AppDBo= github.com/6543/go-version v1.3.1 h1:HvOp+Telns7HWJ2Xo/05YXQSB2bE0WmVgbHqwMPZT4U= github.com/6543/go-version v1.3.1/go.mod h1:oqFAHCwtLVUTLdhQmVZWYvaHXTdsbB4SY85at64SQEo= +github.com/ArtisanCloud/PowerLibs/v3 v3.3.2 h1:IInr1YWwkhwOykxDqux1Goym0uFhrYwBjmgLnEwCLqs= +github.com/ArtisanCloud/PowerLibs/v3 v3.3.2/go.mod h1:xFGsskCnzAu+6rFEJbGVAlwhrwZPXAny6m7j71S/B5k= +github.com/ArtisanCloud/PowerSocialite/v3 v3.0.8 h1:0v/CMFzz5/0K9mEMebyBzlmap1tidv2PaUFSnq/bJhk= +github.com/ArtisanCloud/PowerSocialite/v3 v3.0.8/go.mod h1:VZQNCvcK/rldF3QaExiSl1gJEAkyc5/I8RLOd3WFZq4= +github.com/ArtisanCloud/PowerWeChat/v3 v3.4.21 h1:ZDLWTLGveYWpCL0wUHF+D8imEo783ux0OFCwczNvgAU= +github.com/ArtisanCloud/PowerWeChat/v3 v3.4.21/go.mod h1:boWl2cwbgXt1AbrYTWMXs9Ebby6ecbJ1CyNVRaNVqUY= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2 h1:F0gBpfdPLGsw+nsgk6aqqkZS1jiixa5WwFe3fk/T3Ys= @@ -201,6 +207,8 @@ github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moA github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= +github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= +github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= @@ -578,6 +586,8 @@ github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3I github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU= github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= @@ -736,6 +746,10 @@ gitlab.com/gitlab-org/api/client-go v0.127.0/go.mod h1:bYC6fPORKSmtuPRyD9Z2rtbAj go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk= go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk= +go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= +go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= +go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= +go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= diff --git a/models/migrations/devstar_v1_0/dv1.go b/models/migrations/devstar_v1_0/dv1.go new file mode 100644 index 0000000000..77461041aa --- /dev/null +++ b/models/migrations/devstar_v1_0/dv1.go @@ -0,0 +1,24 @@ +package devstar_v1_0 + +// 构建 DevStar Studio v1.0 所需数据库类型 +// 从 Gitea v300 到 dv1 + +import ( + wechat_models "code.gitea.io/gitea/models/wechat" + "xorm.io/xorm" +) + +// AddDBWeChatUser 创建微信公众号二维码登录所需要的数据库字段 +func AddDBWeChatUser(x *xorm.Engine) error { + + // 创建数据库表格 + err := x.Sync(new(wechat_models.UserWechatOpenid)) + if err != nil { + return ErrMigrateDevstarDatabase{ + Step: "create table 'user_wechat_openid'", + Message: err.Error(), + } + } + + return nil +} diff --git a/models/wechat/wechat.go b/models/wechat/wechat.go new file mode 100644 index 0000000000..fc3b4c8695 --- /dev/null +++ b/models/wechat/wechat.go @@ -0,0 +1,192 @@ +package wechat + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" +) + +// ErrFailedToOperateWechatOfficialAccountUserDB 错误类型:打开数据库失败 +type ErrFailedToOperateWechatOfficialAccountUserDB struct { + Action string + Message string +} + +func (err ErrFailedToOperateWechatOfficialAccountUserDB) Error() string { + return fmt.Sprintf("Failed to %v in WeChat Official Account DB: %v", err.Action, err.Message) +} + +// ErrWechatOfficialAccountUserNotExist 错误类型:找不到对应的微信公众号用户 +type ErrWechatOfficialAccountUserNotExist struct { + AppID string + OpenID string +} + +func (err ErrWechatOfficialAccountUserNotExist) Error() string { + return fmt.Sprintf("WeChat Official Account User not found: AppID = %v, OpenID = %v", err.AppID, err.OpenID) +} + +// UserWechatOpenid 用户ID与微信公众号openid一对一关联表 +// 数据库指定schema下 必须存在 `user_wechat_openid`,否则报错 ERROR 1146 (42S02): Table does not exist +// +// 遵循gonic规则映射数据库表 `user_wechat_openid`,各字段注解见 https://xorm.io/docs/chapter-02/4.columns/ +type UserWechatOpenid struct { + Id int64 `xorm:"BIGINT pk NOT NULL autoincr 'id' comment('主键')"` + Uid int64 `xorm:"BIGINT UNIQUE NOT NULL 'uid' comment('user表主键')"` + WechatAppid string `xorm:"VARCHAR(50) charset=utf8mb4 collate=utf8mb4_bin NOT NULL 'official_account_appid' comment('微信公众号关联的AppID,便于区分不同公众号用户、便于后期公众号迁移')"` + Openid string `xorm:"VARCHAR(50) charset=utf8mb4 collate=utf8mb4_bin UNIQUE NOT NULL 'openid' comment('微信公众号粉丝OpenID')"` +} + +// Use `charset=utf8mb4 collate=utf8mb4_bin` to fix log.Error: There are 2 table columns(`official_account_appid`, `openid`) using inconsistent collation, they should use "utf8mb4_bin". Please go to admin panel Self Check page + +func init() { + db.RegisterModel(new(UserWechatOpenid)) +} + +// QueryUserByOpenid 根据微信公众号用户openid查询 `user` 表用户 +func QueryUserByOpenid(ctx context.Context, openid string) (*user_model.User, error) { + + // 1. 根据公众号 AppID 和 OpenID 查询数据库中 user表 主键ID + wechatUser := &UserWechatOpenid{ + WechatAppid: setting.Wechat.UserConfig.AppID, + Openid: openid, + } + + var user *user_model.User + + // 2. 开启数据库事务,查询用户信息 + err := db.WithTx(ctx, func(ctx context.Context) error { + // 2.1 根据 `user_wechat_openid` 表 查询用户 openid + exist, err := db.GetEngine(ctx).Get(wechatUser) // 注: 断点必须打在操作数据库之后,否则超时导致报错: context canceled + if err != nil { + return ErrFailedToOperateWechatOfficialAccountUserDB{ + Action: fmt.Sprintf("Find WeChat openid '%s'", openid), + Message: err.Error(), + } + } + + if !exist { + return ErrWechatOfficialAccountUserNotExist{ + AppID: wechatUser.WechatAppid, + OpenID: wechatUser.Openid, + } + } + + // 2.2 根据 `user` 表 主键ID查询用户信息 + user, err = user_model.GetUserByID(ctx, wechatUser.Uid) + if err != nil { + return err + } + // 数据库事务完成,返回nil自动进行Commit + return nil + }) + + if err != nil { + return nil, err + } + + // 3. 检查用户是否被禁用,参考校验逻辑 services/auth/signin.go#UserSignIn. + if user.ProhibitLogin { + return nil, user_model.ErrUserProhibitLogin{UID: user.ID, Name: user.Name} + } + + return user, err +} + +// UpdateOrCreateWechatUser 更新/新建用户微信 +func UpdateOrCreateWechatUser(ctx context.Context, user *user_model.User, openid string) error { + + // 1. 构造查询条件:DevStar 用户ID,即 UID + wechatUser := &UserWechatOpenid{ + Uid: user.ID, + } + + WechatAppid := setting.Wechat.UserConfig.AppID + + // 2. 开启数据库事务,保证数据库表 `user_wechat_openid` 原子操作(出错时候自动进行 transaction Rollback) + err := db.WithTx(ctx, func(ctx context.Context) error { + // 2.1 根据 UID 查询数据库 + has, err := db.GetEngine(ctx).Get(wechatUser) + if err != nil { + return ErrFailedToOperateWechatOfficialAccountUserDB{ + Action: fmt.Sprintf("Find UserID %v", user.ID), + Message: err.Error(), + } + } + + // 2.2 数据库更新或插入新记录 + if has && wechatUser.WechatAppid == WechatAppid && wechatUser.Openid == openid { + // 有用户记录,且未发生变化,直接返回,结束事务 + return nil + } else if has && (wechatUser.WechatAppid != WechatAppid || wechatUser.Openid != openid) { + // 有用户记录,但公众号主体 AppId 或者 关联的用户号 OpenID 发生变化 + wechatUser.WechatAppid = WechatAppid + wechatUser.Openid = openid + _, err = db.GetEngine(ctx).ID(wechatUser.Id).Update(wechatUser) + if err != nil { + return ErrFailedToOperateWechatOfficialAccountUserDB{ + Action: fmt.Sprintf("Update existing UID %v", user.ID), + Message: err.Error(), + } + } + } else { + // 没有用户记录,需要插入新纪录 + wechatUser.WechatAppid = WechatAppid + wechatUser.Openid = openid + _, err = db.GetEngine(ctx).Insert(wechatUser) + if err != nil { + return ErrFailedToOperateWechatOfficialAccountUserDB{ + Action: fmt.Sprintf("Insert new user UID = %v", user.ID), + Message: err.Error(), + } + } + } + return nil + }) + + if err != nil { + return ErrFailedToOperateWechatOfficialAccountUserDB{ + Action: "Start an DB transaction", + Message: err.Error(), + } + } + + log.Info("微信公众号绑定成功: (UID, User.Name, OpenID) = (%v, %v, %v)", user.ID, user.Name, openid) + return nil +} + +// DeleteWechatUser 清空当前用户的微信公众号绑定信息 +func DeleteWechatUser(ctx context.Context, user *user_model.User) error { + + // 0. 检查参数 + if user == nil || user.ID <= 0 { + return ErrFailedToOperateWechatOfficialAccountUserDB{ + Action: "Find User", + Message: "User and its ID cannot be nil", + } + } + + // 1. 构造查询条件:DevStar 用户ID,即 UID + wechatUser := &UserWechatOpenid{ + Uid: user.ID, + } + + // 2. 开启数据库事务,删除数据库表 `user_wechat_openid` 中 user.ID 对应条目 + err := db.WithTx(ctx, func(ctx context.Context) error { + // 2.1 根据 UID 查询数据库 + _, err := db.GetEngine(ctx).Delete(wechatUser) + if err != nil { + return ErrFailedToOperateWechatOfficialAccountUserDB{ + Action: fmt.Sprintf("Delete WeChat Official Account Binding for User '%v'(ID = %v)", user.Name, user.ID), + Message: err.Error(), + } + } + return nil + }) + + return err +} diff --git a/modules/setting/service.go b/modules/setting/service.go index b1b9fedd62..5de92f1c67 100644 --- a/modules/setting/service.go +++ b/modules/setting/service.go @@ -188,8 +188,8 @@ func loadServiceFrom(rootCfg ConfigProvider) { Service.EnableReverseProxyAutoRegister = sec.Key("ENABLE_REVERSE_PROXY_AUTO_REGISTRATION").MustBool() Service.EnableReverseProxyEmail = sec.Key("ENABLE_REVERSE_PROXY_EMAIL").MustBool() Service.EnableReverseProxyFullName = sec.Key("ENABLE_REVERSE_PROXY_FULL_NAME").MustBool() - Service.EnableCaptcha = sec.Key("ENABLE_CAPTCHA").MustBool(false) - Service.RequireCaptchaForLogin = sec.Key("REQUIRE_CAPTCHA_FOR_LOGIN").MustBool(false) + Service.EnableCaptcha = sec.Key("ENABLE_CAPTCHA").MustBool(true) + Service.RequireCaptchaForLogin = sec.Key("REQUIRE_CAPTCHA_FOR_LOGIN").MustBool(true) Service.RequireExternalRegistrationCaptcha = sec.Key("REQUIRE_EXTERNAL_REGISTRATION_CAPTCHA").MustBool(Service.EnableCaptcha) Service.RequireExternalRegistrationPassword = sec.Key("REQUIRE_EXTERNAL_REGISTRATION_PASSWORD").MustBool() Service.CaptchaType = sec.Key("CAPTCHA_TYPE").MustString(ImageCaptcha) diff --git a/modules/setting/setting.go b/modules/setting/setting.go index e14997801f..d7748db28f 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -217,6 +217,7 @@ func LoadSettings() { loadProjectFrom(CfgProvider) loadMimeTypeMapFrom(CfgProvider) loadFederationFrom(CfgProvider) + loadWechatSettingsFrom(CfgProvider) } // LoadSettingsForInstall initializes the settings for install @@ -224,6 +225,7 @@ func LoadSettingsForInstall() { loadDBSetting(CfgProvider) loadServiceFrom(CfgProvider) loadMailerFrom(CfgProvider) + loadWechatSettingsFrom(CfgProvider) } var configuredPaths = make(map[string]string) diff --git a/modules/setting/wechat.go b/modules/setting/wechat.go new file mode 100644 index 0000000000..6988b5c4dc --- /dev/null +++ b/modules/setting/wechat.go @@ -0,0 +1,96 @@ +/** + 微信参数设置 +*/ + +package setting + +import ( + "code.gitea.io/gitea/modules/log" + "github.com/ArtisanCloud/PowerWeChat/v3/src/kernel" + wechat_sdk "github.com/ArtisanCloud/PowerWeChat/v3/src/officialAccount" +) + +var Wechat = struct { + Enabled bool + DefaultDomainName string + UserConfig WechatUserConfigType + TempQrExpireSeconds int + SDK *wechat_sdk.OfficialAccount + cache kernel.CacheInterface + + // 注册过期时间 + RegisterationExpireSeconds int +}{} + +type WechatUserConfigType struct { + AppID string + AppSecret string + RedisAddr string + MessageToken string + MessageAesKey string +} + +// loadWechatSettingsFrom +/** + * 创建PowerWechat全局工具类实例 + * 配置文件: custom/conf/app.ini + */ +func loadWechatSettingsFrom(rootCfg ConfigProvider) { + sec := rootCfg.Section("wechat") + Wechat.Enabled = sec.Key("ENABLED_WECHAT_QR_SIGNIN").MustBool(true) + log.Info("ENABLED_WECHAT_QR_SIGNIN == '%b'", Wechat.Enabled) + Wechat.DefaultDomainName = sec.Key("WECHAT_QR_SERVICE_DOMAIN_NAME").MustString("devstar.cn") + + Wechat.UserConfig.AppID = sec.Key("WECHAT_APP_ID").MustString("") + Wechat.UserConfig.AppSecret = sec.Key("WECHAT_APP_SECRET").MustString("") + Wechat.UserConfig.RedisAddr = sec.Key("WECHAT_REDIS_ADDR").MustString("") + Wechat.UserConfig.MessageToken = sec.Key("WECHAT_MESSAGE_TOKEN").MustString("") + Wechat.UserConfig.MessageAesKey = sec.Key("WECHAT_MESSAGE_AES_KEY").MustString("") + if Wechat.UserConfig.AppID != "" && Wechat.UserConfig.AppSecret != "" { + log.Info("createPowerWechatApp AppID:%s ", Wechat.UserConfig.AppID) + createPowerWechatApp(Wechat.UserConfig) + } + + Wechat.TempQrExpireSeconds = sec.Key("WECHAT_TEMP_QR_EXPIRE_SECONDS").MustInt(60) + // 扫码后,最长注册时间:默认24小时 + Wechat.RegisterationExpireSeconds = sec.Key("WECHAT_REGISTERATION_EXPIRE_SECONDS").MustInt(86400) +} + +/** + * 创建微信公众号工具类 + * + * @param userConfig 微信公众号配置信息, 详见 `custom/conf/app.ini` + * @return PowerWechat app 实例. + */ +func createPowerWechatApp(userConfig WechatUserConfigType) { + + if userConfig.RedisAddr != "" { + Wechat.cache = kernel.NewRedisClient(&kernel.UniversalOptions{ + Addrs: []string{userConfig.RedisAddr}, + }) + } + + var err error + + Wechat.SDK, err = wechat_sdk.NewOfficialAccount(&wechat_sdk.UserConfig{ + + AppID: userConfig.AppID, + Secret: userConfig.AppSecret, + + Token: userConfig.MessageToken, + AESKey: userConfig.MessageAesKey, + + Log: wechat_sdk.Log{ + Level: "error", + File: "./wechat-error.log", + }, + + HttpDebug: false, + Debug: false, + Cache: Wechat.cache, + // Sandbox: true, + }) + if err != nil { + log.Warn("创建微信工具类 PowerWechat 失败,请检查 modules/setting/wechat.go ") + } +} diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index bc3107897b..8aed12f33c 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -10,8 +10,10 @@ sign_out = Sign Out sign_up = Register link_account = Link Account register = Register +register_or_sign_in_with_provider = Register or Sign in with %s version = Version powered_by = Powered by %s +mofified_from= Modified from %s page = Page template = Template language = Language @@ -321,6 +323,8 @@ federated_avatar_lookup_popup = Enable federated avatar lookup using Libravatar. disable_registration = Disable Self-Registration disable_registration_popup = Disable user self-registration. Only administrators will be able to create new user accounts. allow_only_external_registration_popup = Allow Registration Only Through External Services +wechat_qr_signin = Enable Wechat QR Code Sign-In +wechat_qr_signin_popup = Enable user sign-in via Wechat QR Code. openid_signin = Enable OpenID Sign-In openid_signin_popup = Enable user sign-in via OpenID. openid_signup = Enable OpenID Self-Registration @@ -413,6 +417,10 @@ relevant_repositories = Only relevant repositories are being shown, Migrate repository. owner = Owner owner_helper = Some organizations may not show up in the dropdown due to a maximum repository count limit. @@ -1039,7 +1081,12 @@ repo_name_profile_public_hint= .profile is a special repository that you can use repo_name_profile_private_hint = .profile-private is a special repository that you can use to add a README.md to your organization member profile, visible only to organization members. Make sure it's private and initialize it with a README in the profile directory to get started. repo_name_helper = Good repository names use short, memorable and unique keywords. A repository named ".profile" or ".profile-private" could be used to add a README.md for the user/organization profile. repo_size = Repository Size -template = Template +template = Template in WebSite +devstar_template= DevStar Template +devstar_template_desc = Through the DevStar template, you can quickly generate a new project that includes a development environment and sample code. I also want to create a DevStar template. +git_url_template = Template(Git URL) +git_url_template_helper = Enter a Git repository URL with a commit ID +git_url_template_desc = A Git repository URL with a commit ID: https://domain-name/username/repo-name/commit/commit-id. template_select = Select a template. template_helper = Make repository a template template_description = Template repositories let users generate new repositories with the same directory structure, files, and optional settings. @@ -1162,13 +1209,13 @@ template.issue_labels = Issue Labels template.one_item = Must select at least one template item template.invalid = Must select a template repository -archive.title = This repo is archived. You can view files and clone it, but cannot push or open issues or pull requests. -archive.title_date = This repository has been archived on %s. You can view files and clone it, but cannot push or open issues or pull requests. +archive.title = This repo is archived. You can view files and clone it, but cannot push or create Dev Container or open issues or open pull requests. +archive.title_date = This repository has been archived on %s. You can view files and clone it, but cannot push or create Dev Container or open issues or open pull requests. archive.issue.nocomment = This repo is archived. You cannot comment on issues. archive.pull.nocomment = This repo is archived. You cannot comment on pull requests. -form.reach_limit_of_creation_1 = The owner has already reached the limit of %d repository. -form.reach_limit_of_creation_n = The owner has already reached the limit of %d repositories. +form.reach_limit_of_creation_1 = The owner has already reached the limit of %d repository. To request an upgrade, please contact the administrator %s +form.reach_limit_of_creation_n = The owner has already reached the limit of %d repositories. To request an upgrade, please contact the administrator %s form.name_reserved = The repository name "%s" is reserved. form.name_pattern_not_allowed = The pattern "%s" is not allowed in a repository name. @@ -2302,7 +2349,7 @@ settings.wiki_deletion_success = The repository wiki data has been deleted. settings.delete = Delete This Repository settings.delete_desc = Deleting a repository is permanent and cannot be undone. settings.delete_notices_1 = - This operation CANNOT be undone. -settings.delete_notices_2 = - This operation will permanently delete the %s repository including code, issues, comments, wiki data and collaborator settings. +settings.delete_notices_2 = - This operation will permanently delete the %s repository including code, Dev Containers, issues, comments, wiki data and collaborator settings. settings.delete_notices_fork_1 = - Forks of this repository will become independent after deletion. settings.deletion_success = The repository has been deleted. settings.update_settings_success = The repository settings have been updated. @@ -3006,6 +3053,9 @@ dashboard.delete_missing_repos.started = Delete all repositories missing their G dashboard.delete_generated_repository_avatars = Delete generated repository avatars dashboard.sync_repo_branches = Sync missed branches from git data to databases dashboard.sync_repo_tags = Sync tags from git data to database +dashboard.app_logo_config = Application Logo Configuration +dashboard.choose_new_logo = Choose New Logo +dashboard.update_logo = Update Logo dashboard.update_mirrors = Update Mirrors dashboard.repo_health_check = Health check all repositories dashboard.check_repo_stats = Check all repository statistics @@ -3830,6 +3880,8 @@ runners.status.offline = Offline runners.version = Version runners.reset_registration_token = Reset registration token runners.reset_registration_token_confirm = Would you like to invalidate the current token and generate a new one? +runners.regist_runner = Register a new runner +runners.regist_runner_success = Register a new runner successfully runners.reset_registration_token_success = Runner registration token reset successfully runs.all_workflows = All Workflows diff --git a/options/locale/locale_zh-CN.ini b/options/locale/locale_zh-CN.ini index 2289c9cf48..ecc2c4a866 100644 --- a/options/locale/locale_zh-CN.ini +++ b/options/locale/locale_zh-CN.ini @@ -318,6 +318,8 @@ federated_avatar_lookup_popup=启用 Federated Avatars 查找以使用开源的 disable_registration=禁止用户自助注册 disable_registration_popup=禁用用户自助注册。只有管理员才能创建新的用户帐户。 allow_only_external_registration_popup=仅允许通过外部服务注册 +wechat_qr_signin=启用 微信二维码 登录 +wechat_qr_signin_popup=启用通过 微信二维码 登录 openid_signin=启用 OpenID 登录 openid_signin_popup=启用通过 OpenID 登录 openid_signup=启用 OpenID 自助注册 @@ -410,6 +412,10 @@ relevant_repositories=只显示相关的仓库, 显示未过滤 create_new_account=注册帐号 already_have_account=已有账号? sign_in_now=立即登录 +register_bind_wechat=绑定微信 +register_bind_wechat_helper_msg=请点击上方微信二维码扫码后注册 +register_helper_msg=已经注册?立即登录! +social_register_helper_msg=已经注册?立即绑定! disable_register_prompt=对不起,注册功能已被关闭。请联系网站管理员。 disable_register_mail=已禁用注册邮件确认。 manual_activation_only=请联系您的站点管理员来完成激活。 @@ -657,6 +663,8 @@ target_ref_not_exist=目标引用 %s 不存在 admin_cannot_delete_self=当您是管理员时,您不能删除自己。请先移除您的管理员权限 +invalid_user_id_or_repo_id = 用户ID 或 仓库ID 输入有误,请重新检查 + [user] change_avatar=修改头像 joined_on=加入于 %s @@ -719,6 +727,8 @@ social=社交帐号绑定 applications=应用 orgs=管理组织 repos=仓库列表 +dev_containers_list = 开发容器列表 +dev_containers_none = 你没有任何开发容器。 delete=删除帐户 twofa=两步验证(TOTP) account_link=已绑定帐户 @@ -791,6 +801,24 @@ password_incorrect=当前密码不正确! change_password_success=您的密码已更新。从现在开始使用您的新密码登录。 password_change_disabled=非本地帐户不能通过 Gitea 的 web 界面更改密码。 +change_phone=更新手机号 +phone_number = 手机号 +phone_new_number=新的手机号 +phone_sms_code=手机短信验证码 +phone_sms_send=发送短信 +phone_sms_sent_success=短信已发送,请查收 +phone_sms_code_incorrect=短信验证码不正确 +change_phone_success=手机号成功更新 + +wechat_qr_login = 微信二维码 +unbind_wechat=解除微信绑定 +change_wechat=更新微信 +wechat_qr_prompt=使用微信扫描二维码,关注公众号 +wechat_qr_expired=微信二维码已过期, 请点击刷新 +wechat_bind_confirm=你确定要将当前用户绑定到微信 '%s'? +wechat_update_success=微信成功更新 +wechat_unbind_success=微信成功解除绑定 + emails=邮箱地址 manage_emails=管理邮箱地址 manage_themes=选择默认主题 @@ -1026,13 +1054,30 @@ visibility.private=私有 visibility.private_tooltip=仅对您已加入的组织的成员可见。 [repo] +dev_container = 开发容器 +dev_container_empty = 本仓库没有开发容器配置 +dev_container_invalid_config_prompt = 开发容器配置无效:需要上传有效的 devcontainer.json 至默认分支,且确保仓库未处于存档状态 +dev_container_control.update = 保存开发容器 +dev_container_control.create = 创建开发容器 +dev_container_control.creation_success_for_user = 用户 '%s' 已成功创建开发容器 +dev_container_control.creation_failed_for_user = 用户 '%s' 开发容器创建失败 +dev_container_control.delete = 删除开发容器 +dev_container_control.deletion_desc = 即将删除开发容器的配置信息和存储数据,此操作不可逆,是否继续? +dev_container_control.deletion_success_for_user = 用户 '%s' 已成功删除开发容器 +dev_container_control.deletion_failed_for_user = 用户 '%s' 开发容器删除失败 + new_repo_helper=代码仓库包含了所有的项目文件,包括版本历史记录。已经在其他地方托管了?迁移仓库。 owner=拥有者 owner_helper=由于最大仓库数量限制,一些组织可能不会显示在下拉列表中。 repo_name=仓库名称 repo_name_helper=理想的仓库名称应由简短、有意义和独特的关键词组成。「.profile」和「.profile-private」可用于为用户/组织添加 README.md。 repo_size=仓库大小 -template=模板 +template=站内模板 +devstar_template=DevStar模板 +devstar_template_desc = 通过DevStar模板可以快速生成包含开发环境和范例代码的新项目,我也要制作一个DevStar模板。 +git_url_template = 模板(Git URL) +git_url_template_helper = 输入一个带commit ID的Git仓库URL +git_url_template_desc = 一个带commit ID的Git仓库URL:https://domain-name/username/repo-name/commit/commit-id template_select=选择模板 template_helper=设置仓库为模板仓库 template_description=模板仓库让用户通过拷贝目录结构,文件和可选设置来生成仓库。 @@ -1155,8 +1200,8 @@ template.issue_labels=工单标签 template.one_item=必须至少选择一个模板项 template.invalid=必须选择一个模板仓库 -archive.title=该仓库已被归档。您可以查看文件和克隆它,但不能推送、创建工单或合并请求。 -archive.title_date=该仓库已于 %s 归档。您可以查看文件或克隆它,但不能推送、创建工单或合并请求。 +archive.title=该仓库已被归档。您可以查看文件和克隆它,但不能推送、创建开发容器、创建工单或合并请求。 +archive.title_date=该仓库已于 %s 归档。您可以查看文件或克隆它,但不能推送、创建开发容器、创建工单或合并请求。 archive.issue.nocomment=此仓库已存档,您不能在此工单添加评论。 archive.pull.nocomment=此仓库已存档,您不能在此合并请求添加评论。 @@ -2997,6 +3042,9 @@ dashboard.delete_missing_repos.started=删除所有丢失 Git 文件的仓库任 dashboard.delete_generated_repository_avatars=删除生成的仓库头像 dashboard.sync_repo_branches=将缺少的分支从 Git 数据同步到数据库 dashboard.sync_repo_tags=从 Git 数据同步标签到数据库 +dashboard.app_logo_config = 应用图标设置 +dashboard.choose_new_logo = 选择新的图标 +dashboard.update_logo = 更新图标 dashboard.update_mirrors=更新镜像仓库 dashboard.repo_health_check=健康检查所有仓库 dashboard.check_repo_stats=检查所有仓库统计 @@ -3819,6 +3867,8 @@ runners.status.offline=离线 runners.version=版本 runners.reset_registration_token=重置注册令牌 runners.reset_registration_token_confirm=是否吊销当前令牌并生成一个新令牌? +runners.regist_runner=启动并注册一个运行器 +runners.regist_runner_success=成功启动并注册一个运行器 runners.reset_registration_token_success=成功重置运行器注册令牌 runs.all_workflows=所有工作流 diff --git a/routers/api/wechat/init-wechat-routes.go b/routers/api/wechat/init-wechat-routes.go new file mode 100644 index 0000000000..a06522b722 --- /dev/null +++ b/routers/api/wechat/init-wechat-routes.go @@ -0,0 +1,31 @@ +package wechat + +import ( + "code.gitea.io/gitea/modules/web" + context "code.gitea.io/gitea/services/context" + wechat_service "code.gitea.io/gitea/services/wechat" +) + +/* + * 注册微信 + * 前缀 "/api/wechat" + */ +func InitWechatRoutes() *web.Router { + + wechatWebRouter := web.NewRouter() + wechatWebRouter.Use(context.APIContexter()) + + // 微信服务器回调接口 + wechatWebRouter.Group("/callback", func() { + wechatWebRouter.Get("/message", wechat_service.CallbackVerifyMessage) + wechatWebRouter.Post("/message", wechat_service.CallbackNotifyEvents) + }) + + // 微信公众号带参数临时二维码登录 + wechatWebRouter.Group("/login/qr", func() { + wechatWebRouter.Get("/generate", wechat_service.GenerateWechatQrCode) + wechatWebRouter.Get("/check-status", wechat_service.QrCheckCodeStatus) + }) + + return wechatWebRouter +} diff --git a/routers/init.go b/routers/init.go index 744feee2f0..5066723806 100644 --- a/routers/init.go +++ b/routers/init.go @@ -9,6 +9,8 @@ import ( "reflect" "runtime" + wechat_routers "code.gitea.io/gitea/routers/api/wechat" + "code.gitea.io/gitea/models" authmodel "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/modules/cache" @@ -188,6 +190,7 @@ func NormalRoutes() *web.Router { r.Mount("/", web_routers.Routes()) r.Mount("/api/v1", apiv1.Routes()) r.Mount("/api/internal", private.Routes()) + r.Mount("/api/wechat", wechat_routers.InitWechatRoutes()) r.Post("/-/fetch-redirect", common.FetchRedirectDelegate) diff --git a/routers/install/install.go b/routers/install/install.go index dc8f209f3b..d154c1f44e 100644 --- a/routers/install/install.go +++ b/routers/install/install.go @@ -141,6 +141,7 @@ func Install(ctx *context.Context) { form.EnableOpenIDSignIn = setting.Service.EnableOpenIDSignIn form.EnableOpenIDSignUp = setting.Service.EnableOpenIDSignUp + form.EnableWechatQRSignIn = setting.Wechat.Enabled form.DisableRegistration = setting.Service.DisableRegistration form.AllowOnlyExternalRegistration = setting.Service.AllowOnlyExternalRegistration form.EnableCaptcha = setting.Service.EnableCaptcha @@ -172,7 +173,7 @@ func checkDatabase(ctx *context.Context, form *forms.InstallForm) bool { if err = db.InitEngine(ctx); err != nil { if strings.Contains(err.Error(), `Unknown database type: sqlite3`) { ctx.Data["Err_DbType"] = true - ctx.RenderWithErr(ctx.Tr("install.sqlite3_not_available", "https://docs.gitea.com/installation/install-from-binary"), tplInstall, form) + ctx.RenderWithErr(ctx.Tr("install.sqlite3_not_available", "https://docs.gitea.cn/installation/install-from-binary"), tplInstall, form) } else { ctx.Data["Err_DbSetting"] = true ctx.RenderWithErr(ctx.Tr("install.invalid_db_setting", err), tplInstall, form) @@ -433,6 +434,21 @@ func SubmitInstall(ctx *context.Context) { ctx.RenderWithErr(ctx.Tr("install.save_config_failed", err), tplInstall, &form) return } + if form.EnableWechatQRSignIn { + ctx.Data["EnableWechatQRSignIn"] = form.EnableWechatQRSignIn + cfg.Section("wechat").Key("ENABLED_WECHAT_QR_SIGNIN").SetValue("true") + // cfg.Section("wechat").Key("WECHAT_QR_SERVICE_DOMAIN_NAME").SetValue("devstar.cn") + // cfg.Section("wechat").Key("WECHAT_OFFICIAL_ACCOUNT_TEMP_QR_EXPIRE_SECONDS").SetValue("180") + // cfg.Section("wechat").Key("WECHAT_OFFICIAL_ACCOUNT_REGISTERATION_EXPIRE_SECONDS").SetValue("86400") + // cfg.Section("wechat").Key("WECHAT_OFFICIAL_ACCOUNT_APP_ID").SetValue("") + // cfg.Section("wechat").Key("WECHAT_OFFICIAL_ACCOUNT_APP_SECRET").SetValue("") + // cfg.Section("wechat").Key("WECHAT_OFFICIAL_ACCOUNT_REDIS_ADDR").SetValue("") + // cfg.Section("wechat").Key("WECHAT_OFFICIAL_ACCOUNT_MESSAGE_TOKEN").SetValue("") + // cfg.Section("wechat").Key("WECHAT_OFFICIAL_ACCOUNT_MESSAGE_AES_KEY").SetValue("") + } else { + ctx.Data["EnableWechatQRSignIn"] = form.EnableWechatQRSignIn + cfg.Section("wechat").Key("ENABLED_WECHAT_QR_SIGNIN").SetValue("false") + } cfg.Section("openid").Key("ENABLE_OPENID_SIGNIN").SetValue(strconv.FormatBool(form.EnableOpenIDSignIn)) cfg.Section("openid").Key("ENABLE_OPENID_SIGNUP").SetValue(strconv.FormatBool(form.EnableOpenIDSignUp)) diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go index 13cd083771..749a642131 100644 --- a/routers/web/auth/auth.go +++ b/routers/web/auth/auth.go @@ -9,6 +9,7 @@ import ( "fmt" "html/template" "net/http" + "net/url" "strings" "code.gitea.io/gitea/models/auth" @@ -42,6 +43,9 @@ const ( tplSignUp templates.TplName = "user/auth/signup" // for sign up page TplActivate templates.TplName = "user/auth/activate" // for activate user TplActivatePrompt templates.TplName = "user/auth/activate_prompt" // for showing a message for user activation + + tplSignInSms templates.TplName = "user/auth/signin_sms" // 短信登录 + tplSignInWechatQr templates.TplName = "user/auth/signin_wechat_qr" // 微信公众号二维码登录 ) // autoSignIn reads cookie and try to auto-login. @@ -170,16 +174,55 @@ func prepareSignInPageData(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("sign_in") ctx.Data["SignInLink"] = setting.AppSubURL + "/user/login" ctx.Data["PageIsSignIn"] = true - ctx.Data["PageIsLogin"] = true ctx.Data["EnableSSPI"] = auth.IsSSPIEnabled(ctx) ctx.Data["EnablePasswordSignInForm"] = setting.Service.EnablePasswordSignInForm ctx.Data["EnablePasskeyAuth"] = setting.Service.EnablePasskeyAuth + ctx.Data["EnableWechatQRSignIn"] = setting.Wechat.Enabled + ctx.Data["PageIsWechatQrLogin"] = false + if setting.Service.EnableCaptcha && setting.Service.RequireCaptchaForLogin { context.SetCaptchaData(ctx) } } +// SignInWechatQr 渲染微信扫码登录页面 +func SignInWechatQr(ctx *context.Context) { + if CheckAutoLogin(ctx) { + return + } + + if ctx.IsSigned { + RedirectAfterLogin(ctx) + return + } + + prepareSignInPageData(ctx) + + ctx.Data["Title"] = ctx.Tr("sign_in") + ctx.Data["SignInWechatQrLink"] = setting.AppSubURL + "/user/login/wechat" + ctx.Data["PageIsSignIn"] = false + ctx.Data["PageIsWechatQrLogin"] = true + + wechatQrTicket, wechatQrCodeUrl, err := auth_service.GetWechatQRTicket(ctx) + if err != nil { + wechatQrFallbackLoginURL := "/user/login" + redirectTo := ctx.FormString("redirect_to") + if len(redirectTo) > 0 { + wechatQrFallbackLoginURL = wechatQrFallbackLoginURL + "?redirect_to=" + url.QueryEscape(redirectTo) + } + log.Warn("微信创建二维码失败,回退到默认密码登录页面") + ctx.Redirect(setting.AppSubURL + wechatQrFallbackLoginURL) + return + } + + ctx.Data["wechatQrTicket"] = wechatQrTicket + ctx.Data["wechatQrCodeUrl"] = wechatQrCodeUrl + ctx.Data["wechatQrExpireSeconds"] = setting.Wechat.TempQrExpireSeconds + + ctx.HTML(http.StatusOK, tplSignInWechatQr) +} + // SignIn render sign in page func SignIn(ctx *context.Context) { if CheckAutoLogin(ctx) { @@ -294,6 +337,21 @@ func SignInPost(ctx *context.Context) { ctx.Redirect(setting.AppSubURL + "/user/two_factor") } +// SignInSms 短信登录页面渲染 +func SignInSms(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("sign_in") + ctx.Data["SignInSmsLink"] = setting.AppSubURL + "/user/login/sms" + ctx.Data["PageIsSignIn"] = true + ctx.Data["PageIsSmsLogin"] = true + ctx.Data["EnableSSPI"] = auth.IsSSPIEnabled(ctx) + + if setting.Service.EnableCaptcha && setting.Service.RequireCaptchaForLogin { + context.SetCaptchaData(ctx) + } + + ctx.HTML(http.StatusOK, tplSignInSms) +} + // This handles the final part of the sign-in process of the user. func handleSignIn(ctx *context.Context, u *user_model.User, remember bool) { redirect := handleSignInFull(ctx, u, remember, true) @@ -441,6 +499,14 @@ func SignUp(ctx *context.Context) { // Show Disabled Registration message if DisableRegistration or AllowOnlyExternalRegistration options are true ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration || setting.Service.AllowOnlyExternalRegistration + if setting.Wechat.Enabled { + if ctx.FormString("ticket") != "" { + ctx.Data["wechatQrTicket"] = ctx.FormString("ticket") + } else { + ctx.Data["Err_WechatQrTicket"] = true + } + } + redirectTo := ctx.FormString("redirect_to") if len(redirectTo) > 0 { middleware.SetRedirectToCookie(ctx.Resp, redirectTo) @@ -520,6 +586,16 @@ func SignUpPost(ctx *context.Context) { Passwd: form.Password, } + if setting.Wechat.Enabled { + if form.WechatQrTicket != "" { + ctx.Data["WechatQrTicket"] = form.WechatQrTicket + } else { + ctx.Data["Err_WechatQrTicket"] = true + ctx.RenderWithErr(ctx.Tr("auth.register_bind_wechat_helper_msg"), tplSignUp, &form) + return + } + } + if !createAndHandleCreatedUser(ctx, tplSignUp, form, u, nil, nil) { // error already handled return @@ -638,6 +714,11 @@ func handleUserCreated(ctx *context.Context, u *user_model.User, possibleLinkAcc } } + wechatQrTicket, ok := ctx.Data["WechatQrTicket"].(string) + if ok && len(wechatQrTicket) > 0 { + return handleWechatRegistration(ctx, wechatQrTicket, u) + } + // for active user or the first (admin) user, we don't need to send confirmation email if u.IsActive || u.ID == 1 { return true diff --git a/routers/web/auth/wechat_qr_auth.go b/routers/web/auth/wechat_qr_auth.go new file mode 100644 index 0000000000..ce4fd08897 --- /dev/null +++ b/routers/web/auth/wechat_qr_auth.go @@ -0,0 +1,81 @@ +package auth + +import ( + user_model "code.gitea.io/gitea/models/user" + wechat_model "code.gitea.io/gitea/models/wechat" + context "code.gitea.io/gitea/services/context" + wechat_service "code.gitea.io/gitea/services/wechat" +) + +// WechatQrSignInSuccess 处理扫码登录用户Cookie保存等 +// +// 由前端页面 window.location.href 跳转到 /user/login/wechat/success?ticket=${ticket} +func WechatQrSignInSuccess(ctx *context.Context) { + + // 取出 微信公众号二维码 ticket + ticket := ctx.Base.Req.URL.Query().Get("ticket") + + // 取出扫码用户状态 + qrStatus, err := wechat_service.GetWechatQrStatusByTicket(ticket) + if err != nil { + // 重定向到主页,最终重定向到扫码登录页面 + ctx.Redirect("/") + return + } + defer wechat_service.DeleteWechatQrByTicket(ticket) + + // 检查用户扫码状态:已扫描、绑定,否则重定向到主页,最终重定向到扫码登录页面 + if qrStatus == nil || !qrStatus.IsScanned || !qrStatus.IsBinded { + ctx.Redirect("/") + return + } + + // 查询数据库扫码人信息 + user, err := wechat_model.QueryUserByOpenid(ctx, qrStatus.OpenId) + + // 登录成功,跳转目标页面 + redirect := handleSignInFull(ctx, user, false, true) + if ctx.Written() { + return + } + ctx.Redirect(redirect) + return +} + +// 检查是否携带了微信扫码注册账号信息,若携带了,同时绑定微信公众号 OpenID +// 若携带了微信公众号二维码ticket的后续步骤发生错误,直接返回 ok = false 阻断注册过程 +// 否则继续检查其他注册过程是否合规 +// handleWechatRegistration 处理微信扫码注册逻辑 +func handleWechatRegistration(ctx *context.Context, ticket string, u *user_model.User) bool { + // 根据微信二维码 Ticket 获取扫码信息,并删除相应缓存 + qrStatus, err := wechat_service.GetWechatQrStatusByTicket(ticket) + if err != nil || qrStatus == nil { + ctx.Flash.Error("微信公众号扫码注册失败: 微信二维码无效,请使用密码登录") + ctx.Redirect("/user/login") + return false + } + + // 检查二维码是否被扫描以及 OpenID 的有效性 + if !qrStatus.IsScanned || len(qrStatus.OpenId) == 0 { + ctx.Flash.Error("微信公众号扫码注册失败: 微信二维码无效,请使用密码登录") + ctx.Redirect("/user/login") + return false + } + + // 检查微信账号是否已绑定 + if qrStatus.IsBinded { + ctx.Flash.Error("微信公众号扫码注册失败:该微信账号已绑定,请直接扫码登录") + ctx.Redirect("/") + return false + } + + // 绑定微信公众号 OpenID 到 DevStar 用户 + err = wechat_model.UpdateOrCreateWechatUser(ctx, u, qrStatus.OpenId) + if err != nil { + ctx.Flash.Error("绑定微信公众号失败,请使用密码登录: " + err.Error()) + ctx.Redirect("/user/login") + return false + } + + return true // 返回 true 表示注册流程继续 +} diff --git a/routers/web/user/setting/account.go b/routers/web/user/setting/account.go index 6b17da50e5..ad4310ab56 100644 --- a/routers/web/user/setting/account.go +++ b/routers/web/user/setting/account.go @@ -21,6 +21,7 @@ import ( "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/services/auth" + auth_service "code.gitea.io/gitea/services/auth" "code.gitea.io/gitea/services/auth/source/db" "code.gitea.io/gitea/services/auth/source/smtp" "code.gitea.io/gitea/services/context" @@ -46,6 +47,16 @@ func Account(ctx *context.Context) { loadAccountData(ctx) + // 界面原型:更新微信,展示公众号带参数二维码 + wechatQrTicket, wechatQrCodeUrl, err := auth_service.GetWechatQRTicket(ctx) + if err != nil { + log.Warn("微信创建二维码失败,跳过") + } + + ctx.Data["wechatQrTicket"] = wechatQrTicket + ctx.Data["wechatQrCodeUrl"] = wechatQrCodeUrl + ctx.Data["wechatQrExpireSeconds"] = setting.Wechat.TempQrExpireSeconds + ctx.HTML(http.StatusOK, tplSettingsAccount) } diff --git a/routers/web/user/setting/wechat.go b/routers/web/user/setting/wechat.go new file mode 100644 index 0000000000..a161e53a1f --- /dev/null +++ b/routers/web/user/setting/wechat.go @@ -0,0 +1,48 @@ +package setting + +import ( + wechat_model "code.gitea.io/gitea/models/wechat" + "code.gitea.io/gitea/modules/log" + context "code.gitea.io/gitea/services/context" + wechat_service "code.gitea.io/gitea/services/wechat" +) + +// BindWechatQR 将二维码扫描人 OpenID 关联到数据库用户中 +func BindWechatQR(ctx *context.Context) { + + // 1. 取出 微信公众号二维码 ticket + ticket := ctx.Base.Req.URL.Query().Get("ticket") + + // 2. 取出用户openid并清空对应的ticket + qrStatus, err := wechat_service.GetWechatQrStatusByTicket(ticket) + if err != nil { + log.Error("绑定微信失败: " + err.Error()) + ctx.Flash.Error("绑定微信失败:" + err.Error()) + ctx.Redirect("/user/settings/account") + return + } + defer wechat_service.DeleteWechatQrByTicket(ticket) + + // 3. 从 Gitea Web Context 中获取用户信息 + user := ctx.Doer + + // 4. 更新数据库 `user_wechat_openid` + err = wechat_model.UpdateOrCreateWechatUser(ctx, user, qrStatus.OpenId) + if err != nil { + log.Error("绑定微信失败: " + err.Error()) + ctx.Flash.Error("绑定微信失败: " + err.Error()) + ctx.Redirect("/user/settings/account") + return + } + + // 5. 携带扫码成功信息,重定向回用户修改信息页面 + ctx.Data["wechatQRScanSuccess"] = true + Account(ctx) +} + +// UnbindWechatQR 解绑微信 +func UnbindWechatQR(ctx *context.Context) { + _ = wechat_model.DeleteWechatUser(ctx, ctx.Doer) + ctx.Flash.Success(ctx.Tr("settings.wechat_unbind_success")) + ctx.Redirect("/user/settings/account") +} diff --git a/routers/web/web.go b/routers/web/web.go index bfafb75184..a7cc46169c 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -190,7 +190,7 @@ func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.Cont if ctx.Req.URL.Path != "/user/events" { middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI()) } - ctx.Redirect(setting.AppSubURL + "/user/login") + ctx.Redirect(setting.AppSubURL + string(setting.LandingPageLogin)) return } else if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm { ctx.Data["Title"] = ctx.Tr("auth.active_your_account") @@ -205,7 +205,7 @@ func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.Cont if ctx.Req.URL.Path != "/user/events" { middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI()) } - ctx.Redirect(setting.AppSubURL + "/user/login") + ctx.Redirect(setting.AppSubURL + string(setting.LandingPageLogin)) return } @@ -519,9 +519,11 @@ func registerWebRoutes(m *web.Router) { m.Get("/pulls", reqSignIn, user.Pulls) m.Get("/milestones", reqSignIn, reqMilestonesDashboardPageEnabled, user.Milestones) - // ***** START: User ***** - // "user/login" doesn't need signOut, then logged-in users can still access this route for redirection purposes by "/user/login?redirec_to=..." m.Get("/user/login", auth.SignIn) + m.Get("/user/login/sms", auth.SignInSms) + m.Get("/user/login/wechat", auth.SignInWechatQr) + m.Get("/user/login/wechat/success", auth.WechatQrSignInSuccess) + m.Group("/user", func() { m.Post("/login", web.Bind(forms.SignInForm{}), auth.SignInPost) m.Group("", func() { @@ -586,6 +588,9 @@ func registerWebRoutes(m *web.Router) { m.Group("/account", func() { m.Combo("").Get(user_setting.Account).Post(web.Bind(forms.ChangePasswordForm{}), user_setting.AccountPost) m.Post("/email", web.Bind(forms.AddEmailForm{}), user_setting.EmailPost) + m.Get("/wechat/bind-success", user_setting.BindWechatQR) + m.Get("/wechat/unbind-success", user_setting.UnbindWechatQR) + m.Post("/email/delete", user_setting.DeleteEmail) m.Post("/delete", user_setting.DeleteAccount) }) @@ -716,6 +721,7 @@ func registerWebRoutes(m *web.Router) { m.Get("", admin.Dashboard) m.Get("/system_status", admin.SystemStatus) m.Post("", web.Bind(forms.AdminDashboardForm{}), admin.DashboardPost) + m.Post("/logo", web.Bind(forms.AvatarForm{}), admin.LogoPost) m.Get("/self_check", admin.SelfCheck) m.Post("/self_check", admin.SelfCheckPost) diff --git a/services/auth/wechat_qr.go b/services/auth/wechat_qr.go new file mode 100644 index 0000000000..dd902b0a42 --- /dev/null +++ b/services/auth/wechat_qr.go @@ -0,0 +1,176 @@ +package auth + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "code.gitea.io/gitea/services/context" + + wechat_model "code.gitea.io/gitea/models/wechat" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + wechat_service "code.gitea.io/gitea/services/wechat" +) + +// Define a Wechat Error type message +type WechatError struct { + message string +} + +// Implement the Error() method for the `WechatError` type +func (e *WechatError) Error() string { + return e.message +} + +type ResponseData struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data wechat_service.WechatTempQRData `json:"data"` +} + +/** + * GetWechatQRTicket 生成微信官方账户临时二维码 + * + * @return string 生成的微信二维码的 ticket + * @return string 生成的微信二维码图片URL + * @return error 如果生成二维码过程中出现错误,则返回相应的错误信息 + */ +func GetWechatQRTicket(ctx *context.Context) (wechatQrTicket string, QRImageURL string, errorGenerateQr error) { + + sceneStr := setting.Domain + qrExpireSeconds := setting.Wechat.TempQrExpireSeconds + + // 构建请求的 URL + url := fmt.Sprintf("https://%s/api/wechat/login/qr/generate?qrExpireSeconds=%d&sceneStr=%s", setting.Wechat.DefaultDomainName, qrExpireSeconds, sceneStr) + + // 发送 GET 请求 + resp, err := http.Get(url) + if err != nil { + return "", "", fmt.Errorf("failed to send GET request: %v", err) + } + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return "", "", fmt.Errorf("failed to read response body: %v", err) + } + // 检查响应状态码 + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("received non-200 response status: %s", resp.Status) + } + + // 解析 JSON 响应 + var data ResponseData + if err := json.Unmarshal(bodyBytes, &data); err != nil { + return "", "", fmt.Errorf("failed to unmarshal JSON response: %v", err) + } + quit := make(chan bool) // 用来通知goroutine停止 + // 启动一个新的goroutine来进行轮询 + go func() { + // 创建一个超时定时器通道 + timeout := time.After(time.Duration(qrExpireSeconds) * time.Second) + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + checkWechatQrTicketStatus(ctx, wechatQrTicket, quit) + + case <-quit: + log.Info("Stopping polling...") + return + + case <-timeout: + log.Info("Polling timed out after", qrExpireSeconds, "seconds.") + quit <- true // 发送停止信号给轮询goroutine + } + } + }() + return data.Data.Ticket, data.Data.QrImageSrcUrl, nil +} + +// Response represents the top-level JSON structure +type Response struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data wechat_service.WechatTempQRStatus `json:"data"` +} + +// 假设这是用于检查二维码状态的函数 +func checkWechatQrTicketStatus(ctx *context.Context, qrTicket string, quit chan bool) { + + url := fmt.Sprintf("https://%s/api/wechat/login/qr/check-status?ticket=%s&_=%d", + setting.Wechat.DefaultDomainName, qrTicket, time.Now().UnixMilli()) + + resp, err := http.Get(url) + if err != nil { + log.Error("There was a problem with the fetch operation:", err) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + log.Error("Network response was not ok") + return + } + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + log.Error("Error reading response body:", err) + return + } + // 解析 JSON 响应 + var data Response + if err := json.Unmarshal(bodyBytes, &data); err != nil { + log.Error("failed to unmarshal JSON response: %v", err) + return + } + if data.Code == 0 && data.Data.IsScanned { + log.Info("Caching WeChat QR Scanned Info: %s\n", bodyBytes) + // {"code":0,"msg":"操作成功","data":{ + // "is_scanned":true, + // "scene_str":"2d521f80047c42aba27ee9beade35985@p2Z6hfheDxg=", + // "openid":"oQowJ6cD9WSuoxYaCc7mryfn-lVo", + // "is_binded":true}} + // 准备扫码状态VO对象 + qrStatus := wechat_service.WechatTempQRStatus{ + SceneStr: data.Data.SceneStr, + IsScanned: data.Data.IsScanned, + OpenId: data.Data.OpenId, + } + // 从微信服务器消息推送中解析扫码人的 OpenId + user, err := wechat_model.QueryUserByOpenid(ctx, qrStatus.OpenId) + if user == nil { + // 未找到 OpenId 对应的 DevStar 用户信息,提示前端导向注册页 + qrStatus.IsBinded = false + qrStatusString, err := qrStatus.Marshal2JSONString() + if err == nil { + // 将扫码人的微信公众号 OpenId 标记为等待注册,等待时间用户注册完成,默认24小时 + // key: qrScanResponseDigest.Ticket + // value: JSON 字符串 + // TTL: setting.Wechat.TempQrExpireSeconds + wechat_service.SetWechatQrTicketWithTTL( + qrTicket, + qrStatusString, + setting.Wechat.RegisterationExpireSeconds) + } + quit <- true + return + } + + qrStatus.IsBinded = true + qrVOJsonString, err := qrStatus.Marshal2JSONString() + if err == nil { + wechat_service.SetWechatQrTicketWithTTL( + qrTicket, + qrVOJsonString, + setting.Wechat.TempQrExpireSeconds) + } + quit <- true + return + } +} diff --git a/services/context/context.go b/services/context/context.go index e843e1c6d3..3f5d998fcd 100644 --- a/services/context/context.go +++ b/services/context/context.go @@ -162,6 +162,7 @@ func Contexter() func(next http.Handler) http.Handler { ctx := NewWebContext(base, rnd, session.GetContextSession(req)) ctx.Data.MergeFrom(middleware.CommonTemplateContextData()) ctx.Data["CurrentURL"] = setting.AppSubURL + req.URL.RequestURI() + ctx.Data["EnableWechatQRSignIn"] = setting.Wechat.Enabled ctx.Data["Link"] = ctx.Link // PageData is passed by reference, and it will be rendered to `window.config.pageData` in `head.tmpl` for JavaScript modules diff --git a/services/forms/user_form.go b/services/forms/user_form.go index ddf2bd09b0..5cf4ed67a9 100644 --- a/services/forms/user_form.go +++ b/services/forms/user_form.go @@ -50,6 +50,7 @@ type InstallForm struct { EnableFederatedAvatar bool EnableOpenIDSignIn bool EnableOpenIDSignUp bool + EnableWechatQRSignIn bool DisableRegistration bool AllowOnlyExternalRegistration bool EnableCaptcha bool @@ -92,6 +93,9 @@ type RegisterForm struct { Email string `binding:"Required;MaxSize(254)"` Password string `binding:"MaxSize(255)"` Retype string + + // WechatQrTicket 微信公众号临时二维码 Ticket + WechatQrTicket string } // Validate validates the fields diff --git a/services/user/user.go b/services/user/user.go index c7252430de..cc623fbd3a 100644 --- a/services/user/user.go +++ b/services/user/user.go @@ -10,6 +10,8 @@ import ( "strings" "time" + wechat_model "code.gitea.io/gitea/models/wechat" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" packages_model "code.gitea.io/gitea/models/packages" @@ -274,6 +276,9 @@ func DeleteUser(ctx context.Context, u *user_model.User, purge bool) error { } } + // 删除用户微信绑定 + _ = wechat_model.DeleteWechatUser(ctx, u) + return nil } diff --git a/services/wechat/callback.go b/services/wechat/callback.go new file mode 100644 index 0000000000..f4811d611f --- /dev/null +++ b/services/wechat/callback.go @@ -0,0 +1,186 @@ +package wechat + +import ( + "context" + XmlUtils "encoding/xml" + "net/http" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "github.com/ArtisanCloud/PowerLibs/v3/http/helper" + + "fmt" + + "github.com/ArtisanCloud/PowerWeChat/v3/src/kernel/contract" + "github.com/ArtisanCloud/PowerWeChat/v3/src/kernel/messages" + models2 "github.com/ArtisanCloud/PowerWeChat/v3/src/kernel/models" + "github.com/ArtisanCloud/PowerWeChat/v3/src/officialAccount/server/handlers/models" +) + +// CallbackVerifyMessage +/** + * 微信服务器验证消息 + * GET /api/wechat/callback/message + * https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Receiving_standard_messages.html + */ +func CallbackVerifyMessage(responseWriter http.ResponseWriter, request *http.Request) { + resp, err := setting.Wechat.SDK.Server.VerifyURL(request) + if err != nil { + log.Error("[X] Failed to verify message in Wechat Official Account callback") + responseWriter.WriteHeader(resp.StatusCode) + responseWriter.Write([]byte("Failed to verify message in Wechat Official Account callback (CallbackVerifyMessage): " + err.Error())) + return + } + + // 给微信服务器回信 + err = helper.HttpResponseSend(resp, responseWriter) + if err != nil { + log.Error("[X] Failed to reply back to Wechat Server (CallbackVerifyMessage): ", err.Error()) + responseWriter.WriteHeader(500) + } +} + +// CallbackNotifyEvents +/** + * 微信服务器通知事件 + * POST /api/wechat/callback/message + * https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Receiving_standard_messages.html + */ +func CallbackNotifyEvents(responseWriter http.ResponseWriter, request *http.Request) { + + resp, err := setting.Wechat.SDK.Server.Notify(request, func(event contract.EventInterface) interface{} { + + switch event.GetMsgType() { + + case models2.CALLBACK_MSG_TYPE_EVENT: + // "event" 类型消息处理 + return callbackMsgEventHandler(request, event) + + case models2.CALLBACK_MSG_TYPE_TEXT: + // "text" 类型消息处理 + return callbackMsgTextHandler(event) + } + + return messages.NewText("undefined event type: " + event.GetMsgType()) + }) + + if err != nil { + log.Error("[X] Failed to get notified in Wechat Official Account callback (CallbackNotifyEvents): ", err.Error()) + responseWriter.WriteHeader(resp.StatusCode) + responseWriter.Write([]byte("Failed to verify message in Wechat Official Account callback" + err.Error())) + } + + // 给微信服务器回信 + err = helper.HttpResponseSend(resp, responseWriter) + if err != nil { + log.Error("[X] Failed to reply back to Wechat Server (CallbackNotifyEvents): ", err.Error()) + responseWriter.WriteHeader(500) + } +} + +func callbackMsgEventHandler(request *http.Request, event contract.EventInterface) *messages.Text { + ctx := request.Context() + + // 根据不同 event 分别处理 + switch event.GetEvent() { + + case models.CALLBACK_EVENT_SUBSCRIBE: + // event:subscribe 新用户关注公众号事件 + return eventSubscribeHandler(event) + + case models.CALLBACK_EVENT_SCAN: + // event:SCAN 老用户扫描二维码事件 + return eventScanHandler(ctx, event) + + case models.CALLBACK_EVENT_UNSUBSCRIBE: + // event:unsubscribe 掉粉事件处理 + return eventUnsubscribeHandler(event) + } + return messages.NewText("undefined event type: " + event.GetChangeType()) +} + +// "text" 类型消息处理 +func callbackMsgTextHandler(event contract.EventInterface) *messages.Text { + msg := models.MessageText{} + err := event.ReadMessage(&msg) + if err != nil { + log.Error("[X] Failed to handle callback Message TEXT: " + err.Error()) + return messages.NewText("error: " + err.Error()) + } + // fmt.Dump(msg) + log.Info("[+] Got message from " + msg.FromUserName + ": " + msg.Content) + return messages.NewText("您发送的消息已收到,感谢您使用梦宁软件DevStar Studio \n\n" + " -- DevStar Studio" + "\n\n") +} + +// event:subscribe 新用户关注公众号事件 +func eventSubscribeHandler(event contract.EventInterface) *messages.Text { + msg := models.EventSubscribe{} + err := event.ReadMessage(&msg) + if err != nil { + log.Error("[X] Failed to handle callback Message event:subscribe: " + err.Error()) + return messages.NewText("error: " + err.Error()) + } + // log.Info("[+] event:subscribe User Subscribed: " + msg.FromUserName + ", at Timestamp " + msg.CreateTime) + return messages.NewText("欢迎新用户! 请关注公众号后重新扫码!\n\n" + " -- DevStar Studio" + "\n\n") +} + +// event:SCAN 类型消息,已关注公众号的老用户扫码 +func eventScanHandler(ctx context.Context, event contract.EventInterface) *messages.Text { + + msg := models.EventScanCodePush{} + err := event.ReadMessage(&msg) + if err != nil { + log.Error("[X] Failed to handle callback event SCAN: " + err.Error()) + return messages.NewText("error: " + err.Error()) + } + + var qrScanResponseDigest struct { + FromUserName string `xml:"FromUserName"` + Ticket string `xml:"Ticket"` + SceneStr string `xml:"EventKey"` + } + err = XmlUtils.Unmarshal([]byte(event.GetContent()), &qrScanResponseDigest) + if err != nil { + log.Error("[X] Failed to handle callback event SCAN: " + err.Error()) + return messages.NewText("error: " + err.Error()) + } + + // 准备扫码状态VO对象 + qrStatus := WechatTempQRStatus{ + SceneStr: qrScanResponseDigest.SceneStr, + IsScanned: true, + OpenId: qrScanResponseDigest.FromUserName, + } + + log.Info("扫码成功:\n" + + fmt.Sprintf(" Ticket: %s\n", qrScanResponseDigest.Ticket) + + fmt.Sprintf(" SceneStr: %s\n", qrStatus.SceneStr)) + + qrStatus.IsBinded = false + qrStatus.OpenId = qrScanResponseDigest.FromUserName + qrVOJsonString, err := qrStatus.Marshal2JSONString() + if err == nil { + SetWechatQrTicketWithTTL( + qrScanResponseDigest.Ticket, + qrVOJsonString, + setting.Wechat.TempQrExpireSeconds) + } + + loginSuccessInfo := "您正在微信扫码登录" + fmt.Sprintf(" %s ", msg.EventKey) + + "\n\n" + " -- DevStar Studio" + "\n\n" + + "如非本人操作,请尽快重新绑定微信!" + + return messages.NewText(loginSuccessInfo) +} + +// event:unsubscribe 掉粉事件处理 +func eventUnsubscribeHandler(event contract.EventInterface) *messages.Text { + msg := models.EventUnSubscribe{} + err := event.ReadMessage(&msg) + if err != nil { + log.Error("[X] Failed to handle callback Message event:unsubscribe: " + err.Error()) + return messages.NewText("error: " + err.Error()) + } + // log.Info("[+] event:unsubscribe User Unsubscribed: " + msg.ToUserName + ", at Timestamp " + msg.CreateTime) + return messages.NewText("user unsubscribed: " + msg.FromUserName) +} diff --git a/services/wechat/qr-cache.go b/services/wechat/qr-cache.go new file mode 100644 index 0000000000..7a223dbae2 --- /dev/null +++ b/services/wechat/qr-cache.go @@ -0,0 +1,52 @@ +package wechat + +import ( + "code.gitea.io/gitea/modules/cache" + "code.gitea.io/gitea/modules/json" +) + +const ( + // KeyPrefix 缓存键前缀:微信公众号二维码 Ticket + KeyPrefix = "wechat:official-account:qr:ticket:" +) + +// SetWechatQrTicketWithTTL 设置 WeChat QR Ticket 与 VO JSON 的映射关系,并设置过期时间 +func SetWechatQrTicketWithTTL(ticket, valueJSON string, ttl int) (ok bool) { + keyStr := KeyPrefix + ticket + + err := cache.GetCache().Put(keyStr, valueJSON, int64(ttl)) + + return err == nil +} + +// 获取 WeChat QR Ticket 扫码状态(只读取状态、不删除) +func GetWechatQrStatusByTicket(ticket string) (*WechatTempQRStatus, error) { + // 1. 将 ticket 与二维码缓存键拼接,作为 cache key 查询缓存 + keyStr := KeyPrefix + ticket + wechatQrCacher := cache.GetCache() + qrStatusJson, ok := wechatQrCacher.Get(keyStr) + if !ok { + return &WechatTempQRStatus{ + IsScanned: false, + }, nil + } + + // 2. 对缓存查询结果进行 JSON 反序列化为 VO 对象 + var qrStatus WechatTempQRStatus + err := json.Unmarshal([]byte(qrStatusJson), &qrStatus) + if err != nil { + return nil, ErrorWechatTempQRStatus{ + Action: "Convert QR Status", + Message: err.Error(), + } + } + return &qrStatus, nil +} + +// DeleteWechatQrByTicket 通过 ticket 删除微信公众号临时二维码缓存记录 +func DeleteWechatQrByTicket(ticket string) error { + + keyStr := KeyPrefix + ticket + + return cache.GetCache().Delete(keyStr) +} diff --git a/services/wechat/qr-code.go b/services/wechat/qr-code.go new file mode 100644 index 0000000000..b9d6fff273 --- /dev/null +++ b/services/wechat/qr-code.go @@ -0,0 +1,283 @@ +package wechat + +import ( + "context" + "encoding/base64" + binaryUtils "encoding/binary" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + context2 "code.gitea.io/gitea/services/context" + "github.com/google/uuid" +) + +// ResultType 定义了响应格式 +type ResultType struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data interface{} `json:"data,omitempty"` // Data 字段可选 +} + +// RespondJson2HttpResponseWriter +// 将 ResultType 响应转换为 JSON 并写入响应 +func (t *ResultType) RespondJson2HttpResponseWriter(w http.ResponseWriter) { + responseBytes, err := json.Marshal(*t) + if err != nil { + // 只有序列化 ResultType 失败时候, 才返回 HTTP 500 Internal Server Error + http.Error(w, http.StatusText(http.StatusInternalServerError)+": failed to marshal JSON", http.StatusInternalServerError) + return + } + + // 序列化 ResultType 成功,无论成功或者失败,统一返回 HTTP 200 OK + if setting.CORSConfig.Enabled { + AllowOrigin := setting.CORSConfig.AllowDomain[0] + if AllowOrigin == "" { + AllowOrigin = "*" + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Access-Control-Allow-Origin", AllowOrigin) + w.Header().Set("Access-Control-Allow-Methods", strings.Join(setting.CORSConfig.Methods, ",")) + w.Header().Set("Access-Control-Allow-Headers", strings.Join(setting.CORSConfig.Headers, ",")) + } else { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE") + w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization") + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(responseBytes) +} + +// RespSuccess 操作成功常量 +var RespSuccess = ResultType{ + Code: 0, + Msg: "操作成功", +} + +// RespUnauthorizedFailure 获取 devContainer 信息失败:用户未授权 +var RespUnauthorizedFailure = ResultType{ + Code: 00001, + Msg: "未登录,禁止访问", +} + +// 错误码 100xx 表示微信相关错误信息 + +// RespFailedWechatMalconfigured 微信配置错误,功能不可用 +var RespFailedWechatMalconfigured = ResultType{ + Code: 10000, + Msg: "微信配置错误,功能不可用", + //Data: map[string]string{ + // "ErrorMsg": + //}, +} + +// RespPendingQrNotScanned 用户未扫码返回结果常量 +var RespPendingQrNotScanned = ResultType{ + Code: 10001, + Msg: "用户未扫描微信公众号带参数二维码", +} + +// RespFailedIllegalWechatQrTicket 二维码凭证Ticket参数无效 +var RespFailedIllegalWechatQrTicket = ResultType{ + Code: 10002, + Msg: "提交的微信公众号带参数二维码凭证Ticket参数无效", +} + +// RespFailedGenerateWechatOfficialAccountTempQR 微信公众号临时二维码生成失败 +var RespFailedGenerateWechatOfficialAccountTempQR = ResultType{ + Code: 10003, + Msg: "微信公众号临时二维码生成失败", + //Data: map[string]string{ + // "ErrorMsg": err.Error(), + //}, +} + +// RespFailedGetWechatOfficialAccountTempQRStatus 获取微信公众号临时二维码扫码状态出错 +var RespFailedGetWechatOfficialAccountTempQRStatus = ResultType{ + Code: 10004, + Msg: "获取微信公众号临时二维码扫码状态出错", + //Data: map[string]string{ + // "ErrorMsg": err.Error(), + //}, +} + +// ErrorWechatTempQRStatus 获取微信公众号临时二维码出错 +type ErrorWechatTempQRStatus struct { + Action string + Message string +} + +func (err ErrorWechatTempQRStatus) Error() string { + return fmt.Sprintf("Failed to %s in Wechat Service: %s", + err.Action, err.Message, + ) +} + +// WechatTempQRStatus 获取微信公众号临时二维码扫码状态 +type WechatTempQRStatus struct { + IsScanned bool `json:"is_scanned"` // 微信公众号二维码是否已被扫描 + + // 下列3个参数如果为空,则在 JSON 中不出现 + + SceneStr string `json:"scene_str,omitempty"` // 微信公众号二维码场景值 + OpenId string `json:"openid,omitempty"` // 微信公众号二维码扫码人 OpenID + IsBinded bool `json:"is_binded,omitempty"` // 微信公众号二维码扫码人 OpenID 是否绑定到了 DevStar UserID +} + +// Marshal2JSONString 将结构体解析为 JSON字符串 +func (qrStatus WechatTempQRStatus) Marshal2JSONString() (string, error) { + + voJSONBytes, err := json.Marshal(qrStatus) + if err != nil { + return "", err + } + return string(voJSONBytes), nil +} + +// WechatTempQRData 封装微信公众号临时带参数二维码返回值 +type WechatTempQRData struct { + Ticket string `json:"ticket"` + ExpireSeconds int64 `json:"expire_seconds"` + + // Url 是指微信端扫码跳转的URL + Url string `json:"url"` + + // QrImageSrcUrl 是指网页端显示微信二维码图片二维码地址,也即 HTML 中 + QrImageSrcUrl string `json:"qr_image_url"` +} + +// GenerateTempQR 生成微信公众号临时二维码 +func GenerateTempQR( + ctx context.Context, + sceneStr string, qrExpireSeconds int, +) (*WechatTempQRData, error) { + + // 1. 检查参数 qrExpireSecondsOverride 和 sceneStrOverride: 若用户未指定,则从配置文件 app.ini 读取 + if qrExpireSeconds <= 0 { + qrExpireSeconds = setting.Wechat.TempQrExpireSeconds + } + if len(sceneStr) == 0 { + // 生成随机 sceneStr 场景值 + // sceneStr生成规则:UUIDv4后边拼接 当前UnixNano时间戳转为byte数组后的Base64 + // e.g, sceneStr == "1c78e8d914fb4307a3588ac0f6bc092a@yPXAm+ve5hc=" + bytesArrayUnit64 := make([]byte, 8) + binaryUtils.LittleEndian.PutUint64(bytesArrayUnit64, uint64(time.Now().UnixNano())) + currentTimestampNanoBase64 := base64.StdEncoding.EncodeToString(bytesArrayUnit64) + sceneStr = strings.ReplaceAll(uuid.New().String(), "-", "") + "@" + currentTimestampNanoBase64 + } + + // 2. 调用 Wechat.SDK 生成微信公众号临时二维码 + qrData, err := setting.Wechat.SDK.QRCode.Temporary(ctx, sceneStr, qrExpireSeconds) + if err != nil { + return nil, err + } + + // 3. 封装 VO 返回 + wechatQr := &WechatTempQRData{ + Ticket: qrData.Ticket, + ExpireSeconds: qrData.ExpireSeconds, + Url: qrData.Url, + QrImageSrcUrl: "https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=" + qrData.Ticket, + } + return wechatQr, nil +} + +// GenerateWechatQrCode 生成微信公众号临时带参数二维码 +// +// GET /api/wechat/login/qr/generate?qrExpireSeconds=${qrExpireSeconds}&sceneStr=${sceneStr} +func GenerateWechatQrCode(ctx *context2.APIContext) { + + // 1. 检查微信功能是否启用 + if setting.Wechat.SDK == nil { + errorMsg := "微信公众号功能禁用, 不会生成公众号带参数二维码" + log.Warn(errorMsg) + respFailed := ResultType{ + Code: RespFailedWechatMalconfigured.Code, + Msg: RespFailedWechatMalconfigured.Msg, + Data: map[string]string{ + "ErrorMsg": errorMsg, + }, + } + respFailed.RespondJson2HttpResponseWriter(ctx.Resp) + return + } + + // 2. 解析 HTTP GET 请求参数,调用 service 层生成二维码 + qrExpireSeconds := ctx.FormInt("qrExpireSeconds") + sceneStr := ctx.FormString("sceneStr") + log.Info("sceneStr:" + sceneStr) + qrCode, err := GenerateTempQR(ctx, sceneStr, qrExpireSeconds) + if err != nil { + respFailed := ResultType{ + Code: RespFailedGenerateWechatOfficialAccountTempQR.Code, + Msg: RespFailedGenerateWechatOfficialAccountTempQR.Msg, + Data: map[string]string{ + "ErrorMsg": err.Error(), + }, + } + respFailed.RespondJson2HttpResponseWriter(ctx.Resp) + return + } + + // 3. 返回 (自动对VO对象进行JSON序列化) + repsSuccessGenerateQRCode := ResultType{ + Code: RespSuccess.Code, + Msg: RespSuccess.Msg, + Data: qrCode, + } + repsSuccessGenerateQRCode.RespondJson2HttpResponseWriter(ctx.Resp) + return +} + +// QrCheckCodeStatus 检查二维码扫描状态 +/** + - 微信服务器验证消息 + - GET /api/wechat/login/qr/check-status + - 请求参数: + - ticket: 微信公众号带参数临时二维码 ticket + - _: UNIX时间戳,仅用作防止GET请求被缓存,保证每次GET 请求都能够到达服务器 + - 响应参数:(请使用VAR定义) + - { + Code: , // 状态码,只有在 HTTP 200 OK 后,该字段才有意义 + Msg: , // + Data: + } +*/ +func QrCheckCodeStatus(responseWriter http.ResponseWriter, request *http.Request) { + + // 设置响应头为 JSON 格式 + responseWriter.Header().Set("Content-Type", "application/json") + + // 从请求中提取 ticket 参数 + ticket := request.URL.Query().Get("ticket") + if ticket == "" { + RespFailedIllegalWechatQrTicket.RespondJson2HttpResponseWriter(responseWriter) + return + } + + qrStatus, err := GetWechatQrStatusByTicket(ticket) + if err != nil { + respFailed := ResultType{ + Code: RespFailedGetWechatOfficialAccountTempQRStatus.Code, + Msg: RespFailedGetWechatOfficialAccountTempQRStatus.Msg, + Data: map[string]string{ + "ErrorMsg": err.Error(), + }, + } + respFailed.RespondJson2HttpResponseWriter(responseWriter) + return + } + + // 将扫码信息返回 + result := ResultType{ + Code: RespSuccess.Code, + Msg: RespSuccess.Msg, + Data: qrStatus, + } + result.RespondJson2HttpResponseWriter(responseWriter) +} diff --git a/templates/base/head_navbar.tmpl b/templates/base/head_navbar.tmpl index 2d30294394..d2ffce6d2f 100644 --- a/templates/base/head_navbar.tmpl +++ b/templates/base/head_navbar.tmpl @@ -134,9 +134,8 @@ {{ctx.Locale.Tr "register"}} {{end}} - - {{svg "octicon-sign-in"}} - {{ctx.Locale.Tr "sign_in"}} + + {{svg "octicon-sign-in"}} {{ctx.Locale.Tr "sign_in"}} {{end}} diff --git a/templates/install.tmpl b/templates/install.tmpl index 0aec52f27b..fea40eaca5 100644 --- a/templates/install.tmpl +++ b/templates/install.tmpl @@ -245,6 +245,18 @@ +
+
+ + +
+
+
+
+ + +
+
diff --git a/templates/user/auth/signin.tmpl b/templates/user/auth/signin.tmpl index 75e1bb27f9..76b00e8603 100644 --- a/templates/user/auth/signin.tmpl +++ b/templates/user/auth/signin.tmpl @@ -1,5 +1,6 @@ {{template "base/head" .}}
+ {{template "user/auth/signin_navbar" .}}
{{/* these styles are quite tricky and should also apply to the signup and link_account pages */}}
diff --git a/templates/user/auth/signin_navbar.tmpl b/templates/user/auth/signin_navbar.tmpl new file mode 100644 index 0000000000..3bd329e8d4 --- /dev/null +++ b/templates/user/auth/signin_navbar.tmpl @@ -0,0 +1,29 @@ +{{if or .EnableOpenIDSignIn .EnableSSPI .EnableWechatQRSignIn}} + + + +{{end}} diff --git a/templates/user/auth/signin_sms.tmpl b/templates/user/auth/signin_sms.tmpl new file mode 100644 index 0000000000..6c3149040b --- /dev/null +++ b/templates/user/auth/signin_sms.tmpl @@ -0,0 +1,12 @@ +{{template "base/head" .}} +
+ {{template "user/auth/signin_navbar" .}} + + {{/* 短信登录页面表单项容器 */}} +
+
+ {{template "user/auth/signin_sms_inner" .}} +
+
+
+{{template "base/footer" .}} diff --git a/templates/user/auth/signin_sms_inner.tmpl b/templates/user/auth/signin_sms_inner.tmpl new file mode 100644 index 0000000000..7522ca99c6 --- /dev/null +++ b/templates/user/auth/signin_sms_inner.tmpl @@ -0,0 +1,37 @@ +

+ {{ctx.Locale.Tr "register_or_sign_in_with_provider" (ctx.Locale.Tr "settings.phone_sms_code")}} +

+ +
+
+ {{.CsrfTokenHtml}} +
+ + +
+ + {{template "user/auth/captcha" .}} + +
+ + +
+ + +
+ + +
+ + + {{if not .LinkAccountMode}} +
+
+ + +
+
+ {{end}} + +
+
diff --git a/templates/user/auth/signin_wechat_qr.tmpl b/templates/user/auth/signin_wechat_qr.tmpl new file mode 100644 index 0000000000..36f84db56f --- /dev/null +++ b/templates/user/auth/signin_wechat_qr.tmpl @@ -0,0 +1,12 @@ +{{template "base/head" .}} +
+ {{template "user/auth/signin_navbar" .}} + + {{/* 登录页面表单项容器 */}} +
+
+ {{template "user/auth/signin_wechat_qr_inner" .}} +
+
+
+{{template "base/footer" .}} diff --git a/templates/user/auth/signin_wechat_qr_inner.tmpl b/templates/user/auth/signin_wechat_qr_inner.tmpl new file mode 100644 index 0000000000..9dde7638dd --- /dev/null +++ b/templates/user/auth/signin_wechat_qr_inner.tmpl @@ -0,0 +1,207 @@ +{{/* 引入微信公众号二维码扫码注册、登录错误信息一次性提示信息 ctx.Flash.Error() 显示容器 */}} +{{template "base/alert" .}} + + +{{if .wechatQRScanSuccess}} + +
+

{{ctx.Locale.Tr "settings.wechat_update_success"}}

+
+ +{{else}} + +{{/* ============================================================= 扫码失败,需要扫码 - 开始 ============================================================= */}} +{{if .PageIsSignIn}} +

+ {{ctx.Locale.Tr "settings.wechat_qr_prompt"}} +

+{{end}} + +
+
+
+ Wechat Official Accout QR Code Ticket {{.wechatQrTicket}} +
+
+
+ + + + + + + +{{/* ============================================================= 扫码失败,需要扫码 - 结束 ============================================================= */}} +{{end}} diff --git a/templates/user/auth/signup.tmpl b/templates/user/auth/signup.tmpl index 1ce3934f84..25e557abd2 100644 --- a/templates/user/auth/signup.tmpl +++ b/templates/user/auth/signup.tmpl @@ -1,5 +1,6 @@ {{template "base/head" .}}
+ {{template "user/auth/signin_navbar" .}}
{{template "user/auth/signup_inner" .}} diff --git a/templates/user/auth/signup_inner.tmpl b/templates/user/auth/signup_inner.tmpl index a3f6e1471f..67b11e1769 100644 --- a/templates/user/auth/signup_inner.tmpl +++ b/templates/user/auth/signup_inner.tmpl @@ -38,6 +38,13 @@
{{end}} + {{if .EnableWechatQRSignIn}} +
+ + +
+ {{end}} + {{template "user/auth/captcha" .}}
diff --git a/templates/user/settings/account.tmpl b/templates/user/settings/account.tmpl index 7dbac0ebd4..50344ac2d4 100644 --- a/templates/user/settings/account.tmpl +++ b/templates/user/settings/account.tmpl @@ -1,5 +1,45 @@ {{template "user/settings/layout_head" (dict "ctxData" . "pageClass" "user settings account")}} +
+ {{if false}} +

+ {{ctx.Locale.Tr "settings.change_phone"}} +

+
+
+ {{template "base/disable_form_autofill"}} + {{.CsrfTokenHtml}} +
+ + +
+
+ + +
+ +
+ + +
+
+
+ {{end}} + {{if .EnableWechatQRSignIn}} +

+ {{ctx.Locale.Tr "settings.change_wechat"}} +

+
+
+
+ {{template "user/auth/signin_wechat_qr_inner" .}} +
+ +
+ {{end}} +

{{ctx.Locale.Tr "settings.password"}}