diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bae94e1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + 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 +. \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py index 2029d46..87d71d2 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -16,6 +16,7 @@ from flask_wtf.csrf import CSRFError from werkzeug.middleware.proxy_fix import ProxyFix from .access_logging import AccessLoggingService +from .compression import GzipMiddleware from .acl import AclService from .bucket_policies import BucketPolicyStore from .config import AppConfig @@ -89,13 +90,24 @@ def create_app( # Trust X-Forwarded-* headers from proxies app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1) + # Enable gzip compression for responses (10-20x smaller JSON payloads) + if app.config.get("ENABLE_GZIP", True): + app.wsgi_app = GzipMiddleware(app.wsgi_app, compression_level=6) + _configure_cors(app) _configure_logging(app) limiter.init_app(app) csrf.init_app(app) - storage = ObjectStorage(Path(app.config["STORAGE_ROOT"])) + storage = ObjectStorage( + Path(app.config["STORAGE_ROOT"]), + cache_ttl=app.config.get("OBJECT_CACHE_TTL", 5), + ) + + if app.config.get("WARM_CACHE_ON_STARTUP", True) and not app.config.get("TESTING"): + storage.warm_cache_async() + iam = IamService( Path(app.config["IAM_CONFIG"]), auth_max_attempts=app.config.get("AUTH_MAX_ATTEMPTS", 5), diff --git a/app/compression.py b/app/compression.py new file mode 100644 index 0000000..a0bed7c --- /dev/null +++ b/app/compression.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import gzip +import io +from typing import Callable, Iterable, List, Tuple + +COMPRESSIBLE_MIMES = frozenset([ + 'application/json', + 'application/javascript', + 'application/xml', + 'text/html', + 'text/css', + 'text/plain', + 'text/xml', + 'text/javascript', + 'application/x-ndjson', +]) + +MIN_SIZE_FOR_COMPRESSION = 500 + + +class GzipMiddleware: + def __init__(self, app: Callable, compression_level: int = 6, min_size: int = MIN_SIZE_FOR_COMPRESSION): + self.app = app + self.compression_level = compression_level + self.min_size = min_size + + def __call__(self, environ: dict, start_response: Callable) -> Iterable[bytes]: + accept_encoding = environ.get('HTTP_ACCEPT_ENCODING', '') + if 'gzip' not in accept_encoding.lower(): + return self.app(environ, start_response) + + response_started = False + status_code = None + response_headers: List[Tuple[str, str]] = [] + content_type = None + content_length = None + should_compress = False + exc_info_holder = [None] + + def custom_start_response(status: str, headers: List[Tuple[str, str]], exc_info=None): + nonlocal response_started, status_code, response_headers, content_type, content_length, should_compress + response_started = True + status_code = int(status.split(' ', 1)[0]) + response_headers = list(headers) + exc_info_holder[0] = exc_info + + for name, value in headers: + name_lower = name.lower() + if name_lower == 'content-type': + content_type = value.split(';')[0].strip().lower() + elif name_lower == 'content-length': + content_length = int(value) + elif name_lower == 'content-encoding': + should_compress = False + return start_response(status, headers, exc_info) + + if content_type and content_type in COMPRESSIBLE_MIMES: + if content_length is None or content_length >= self.min_size: + should_compress = True + + return None + + response_body = b''.join(self.app(environ, custom_start_response)) + + if not response_started: + return [response_body] + + if should_compress and len(response_body) >= self.min_size: + buf = io.BytesIO() + with gzip.GzipFile(fileobj=buf, mode='wb', compresslevel=self.compression_level) as gz: + gz.write(response_body) + compressed = buf.getvalue() + + if len(compressed) < len(response_body): + response_body = compressed + new_headers = [] + for name, value in response_headers: + if name.lower() not in ('content-length', 'content-encoding'): + new_headers.append((name, value)) + new_headers.append(('Content-Encoding', 'gzip')) + new_headers.append(('Content-Length', str(len(response_body)))) + new_headers.append(('Vary', 'Accept-Encoding')) + response_headers = new_headers + + status_str = f"{status_code} " + { + 200: "OK", 201: "Created", 204: "No Content", 206: "Partial Content", + 301: "Moved Permanently", 302: "Found", 304: "Not Modified", + 400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found", + 405: "Method Not Allowed", 409: "Conflict", 500: "Internal Server Error", + }.get(status_code, "Unknown") + + start_response(status_str, response_headers, exc_info_holder[0]) + return [response_body] diff --git a/app/config.py b/app/config.py index 02f72db..47e172f 100644 --- a/app/config.py +++ b/app/config.py @@ -67,6 +67,7 @@ class AppConfig: stream_chunk_size: int multipart_min_part_size: int bucket_stats_cache_ttl: int + object_cache_ttl: int encryption_enabled: bool encryption_master_key_path: Path kms_enabled: bool @@ -161,8 +162,9 @@ class AppConfig: cors_allow_headers = _csv(str(_get("CORS_ALLOW_HEADERS", "*")), ["*"]) cors_expose_headers = _csv(str(_get("CORS_EXPOSE_HEADERS", "*")), ["*"]) session_lifetime_days = int(_get("SESSION_LIFETIME_DAYS", 30)) - bucket_stats_cache_ttl = int(_get("BUCKET_STATS_CACHE_TTL", 60)) - + bucket_stats_cache_ttl = int(_get("BUCKET_STATS_CACHE_TTL", 60)) + object_cache_ttl = int(_get("OBJECT_CACHE_TTL", 5)) + encryption_enabled = str(_get("ENCRYPTION_ENABLED", "0")).lower() in {"1", "true", "yes", "on"} encryption_keys_dir = storage_root / ".myfsio.sys" / "keys" encryption_master_key_path = Path(_get("ENCRYPTION_MASTER_KEY_PATH", encryption_keys_dir / "master.key")).resolve() @@ -200,6 +202,7 @@ class AppConfig: stream_chunk_size=stream_chunk_size, multipart_min_part_size=multipart_min_part_size, bucket_stats_cache_ttl=bucket_stats_cache_ttl, + object_cache_ttl=object_cache_ttl, encryption_enabled=encryption_enabled, encryption_master_key_path=encryption_master_key_path, kms_enabled=kms_enabled, @@ -315,6 +318,7 @@ class AppConfig: "STREAM_CHUNK_SIZE": self.stream_chunk_size, "MULTIPART_MIN_PART_SIZE": self.multipart_min_part_size, "BUCKET_STATS_CACHE_TTL": self.bucket_stats_cache_ttl, + "OBJECT_CACHE_TTL": self.object_cache_ttl, "LOG_LEVEL": self.log_level, "LOG_TO_FILE": self.log_to_file, "LOG_FILE": str(self.log_path), diff --git a/app/s3_api.py b/app/s3_api.py index f12d5f6..f825aaf 100644 --- a/app/s3_api.py +++ b/app/s3_api.py @@ -921,6 +921,7 @@ def _maybe_handle_bucket_subresource(bucket_name: str) -> Response | None: "object-lock": _bucket_object_lock_handler, "notification": _bucket_notification_handler, "logging": _bucket_logging_handler, + "uploads": _bucket_uploads_handler, } requested = [key for key in handlers if key in request.args] if not requested: @@ -1813,6 +1814,72 @@ def _bucket_logging_handler(bucket_name: str) -> Response: return Response(status=200) +def _bucket_uploads_handler(bucket_name: str) -> Response: + if request.method != "GET": + return _method_not_allowed(["GET"]) + + principal, error = _require_principal() + if error: + return error + try: + _authorize_action(principal, bucket_name, "list") + except IamError as exc: + return _error_response("AccessDenied", str(exc), 403) + + storage = _storage() + if not storage.bucket_exists(bucket_name): + return _error_response("NoSuchBucket", "Bucket does not exist", 404) + + key_marker = request.args.get("key-marker", "") + upload_id_marker = request.args.get("upload-id-marker", "") + prefix = request.args.get("prefix", "") + delimiter = request.args.get("delimiter", "") + try: + max_uploads = max(1, min(int(request.args.get("max-uploads", 1000)), 1000)) + except ValueError: + return _error_response("InvalidArgument", "max-uploads must be an integer", 400) + + uploads = storage.list_multipart_uploads(bucket_name, include_orphaned=True) + + if prefix: + uploads = [u for u in uploads if u["object_key"].startswith(prefix)] + if key_marker: + uploads = [u for u in uploads if u["object_key"] > key_marker or + (u["object_key"] == key_marker and upload_id_marker and u["upload_id"] > upload_id_marker)] + + uploads.sort(key=lambda u: (u["object_key"], u["upload_id"])) + + is_truncated = len(uploads) > max_uploads + if is_truncated: + uploads = uploads[:max_uploads] + + root = Element("ListMultipartUploadsResult", xmlns="http://s3.amazonaws.com/doc/2006-03-01/") + SubElement(root, "Bucket").text = bucket_name + SubElement(root, "KeyMarker").text = key_marker + SubElement(root, "UploadIdMarker").text = upload_id_marker + if prefix: + SubElement(root, "Prefix").text = prefix + if delimiter: + SubElement(root, "Delimiter").text = delimiter + SubElement(root, "MaxUploads").text = str(max_uploads) + SubElement(root, "IsTruncated").text = "true" if is_truncated else "false" + + if is_truncated and uploads: + SubElement(root, "NextKeyMarker").text = uploads[-1]["object_key"] + SubElement(root, "NextUploadIdMarker").text = uploads[-1]["upload_id"] + + for upload in uploads: + upload_el = SubElement(root, "Upload") + SubElement(upload_el, "Key").text = upload["object_key"] + SubElement(upload_el, "UploadId").text = upload["upload_id"] + if upload.get("created_at"): + SubElement(upload_el, "Initiated").text = upload["created_at"] + if upload.get("orphaned"): + SubElement(upload_el, "StorageClass").text = "ORPHANED" + + return _xml_response(root) + + def _object_retention_handler(bucket_name: str, object_key: str) -> Response: if request.method not in {"GET", "PUT"}: return _method_not_allowed(["GET", "PUT"]) diff --git a/app/storage.py b/app/storage.py index 32403ec..d37279b 100644 --- a/app/storage.py +++ b/app/storage.py @@ -137,10 +137,10 @@ class ObjectStorage: BUCKET_VERSIONS_DIR = "versions" MULTIPART_MANIFEST = "manifest.json" BUCKET_CONFIG_FILE = ".bucket.json" - KEY_INDEX_CACHE_TTL = 30 + DEFAULT_CACHE_TTL = 5 OBJECT_CACHE_MAX_SIZE = 100 - def __init__(self, root: Path) -> None: + def __init__(self, root: Path, cache_ttl: int = DEFAULT_CACHE_TTL) -> None: self.root = Path(root) self.root.mkdir(parents=True, exist_ok=True) self._ensure_system_roots() @@ -150,6 +150,7 @@ class ObjectStorage: self._cache_version: Dict[str, int] = {} self._bucket_config_cache: Dict[str, tuple[dict[str, Any], float]] = {} self._bucket_config_cache_ttl = 30.0 + self._cache_ttl = cache_ttl def _get_bucket_lock(self, bucket_id: str) -> threading.Lock: """Get or create a lock for a specific bucket. Reduces global lock contention.""" @@ -1147,47 +1148,57 @@ class ObjectStorage: parts.sort(key=lambda x: x["PartNumber"]) return parts - def list_multipart_uploads(self, bucket_name: str) -> List[Dict[str, Any]]: - """List all active multipart uploads for a bucket.""" + def list_multipart_uploads(self, bucket_name: str, include_orphaned: bool = False) -> List[Dict[str, Any]]: + """List all active multipart uploads for a bucket. + + Args: + bucket_name: The bucket to list uploads for. + include_orphaned: If True, also include upload directories that have + files but no valid manifest.json (orphaned/interrupted uploads). + """ bucket_path = self._bucket_path(bucket_name) if not bucket_path.exists(): raise BucketNotFoundError("Bucket does not exist") bucket_id = bucket_path.name uploads = [] - multipart_root = self._multipart_bucket_root(bucket_id) - if multipart_root.exists(): + + for multipart_root in ( + self._multipart_bucket_root(bucket_id), + self._legacy_multipart_bucket_root(bucket_id), + ): + if not multipart_root.exists(): + continue for upload_dir in multipart_root.iterdir(): if not upload_dir.is_dir(): continue manifest_path = upload_dir / "manifest.json" - if not manifest_path.exists(): - continue - try: - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - uploads.append({ - "upload_id": manifest.get("upload_id", upload_dir.name), - "object_key": manifest.get("object_key", ""), - "created_at": manifest.get("created_at", ""), - }) - except (OSError, json.JSONDecodeError): - continue - legacy_root = self._legacy_multipart_bucket_root(bucket_id) - if legacy_root.exists(): - for upload_dir in legacy_root.iterdir(): - if not upload_dir.is_dir(): - continue - manifest_path = upload_dir / "manifest.json" - if not manifest_path.exists(): - continue - try: - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - uploads.append({ - "upload_id": manifest.get("upload_id", upload_dir.name), - "object_key": manifest.get("object_key", ""), - "created_at": manifest.get("created_at", ""), - }) - except (OSError, json.JSONDecodeError): - continue + if manifest_path.exists(): + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + uploads.append({ + "upload_id": manifest.get("upload_id", upload_dir.name), + "object_key": manifest.get("object_key", ""), + "created_at": manifest.get("created_at", ""), + }) + except (OSError, json.JSONDecodeError): + if include_orphaned: + has_files = any(upload_dir.rglob("*")) + if has_files: + uploads.append({ + "upload_id": upload_dir.name, + "object_key": "(unknown)", + "created_at": "", + "orphaned": True, + }) + elif include_orphaned: + has_files = any(f.is_file() for f in upload_dir.rglob("*")) + if has_files: + uploads.append({ + "upload_id": upload_dir.name, + "object_key": "(unknown)", + "created_at": "", + "orphaned": True, + }) return uploads def _bucket_path(self, bucket_name: str) -> Path: @@ -1398,7 +1409,7 @@ class ObjectStorage: cached = self._object_cache.get(bucket_id) if cached: objects, timestamp = cached - if now - timestamp < self.KEY_INDEX_CACHE_TTL: + if now - timestamp < self._cache_ttl: self._object_cache.move_to_end(bucket_id) return objects cache_version = self._cache_version.get(bucket_id, 0) @@ -1409,7 +1420,7 @@ class ObjectStorage: cached = self._object_cache.get(bucket_id) if cached: objects, timestamp = cached - if now - timestamp < self.KEY_INDEX_CACHE_TTL: + if now - timestamp < self._cache_ttl: self._object_cache.move_to_end(bucket_id) return objects objects = self._build_object_cache(bucket_path) @@ -1455,6 +1466,36 @@ class ObjectStorage: else: objects[key] = meta + def warm_cache(self, bucket_names: Optional[List[str]] = None) -> None: + """Pre-warm the object cache for specified buckets or all buckets. + + This is called on startup to ensure the first request is fast. + """ + if bucket_names is None: + bucket_names = [b.name for b in self.list_buckets()] + + for bucket_name in bucket_names: + try: + bucket_path = self._bucket_path(bucket_name) + if bucket_path.exists(): + self._get_object_cache(bucket_path.name, bucket_path) + except Exception: + pass + + def warm_cache_async(self, bucket_names: Optional[List[str]] = None) -> threading.Thread: + """Start cache warming in a background thread. + + Returns the thread object so caller can optionally wait for it. + """ + thread = threading.Thread( + target=self.warm_cache, + args=(bucket_names,), + daemon=True, + name="cache-warmer", + ) + thread.start() + return thread + def _ensure_system_roots(self) -> None: for path in ( self._system_root_path(), diff --git a/app/ui.py b/app/ui.py index 0dd8b90..204ba48 100644 --- a/app/ui.py +++ b/app/ui.py @@ -102,6 +102,12 @@ def _friendly_error_message(exc: Exception) -> str: return message +def _wants_json() -> bool: + return request.accept_mimetypes.best_match( + ["application/json", "text/html"] + ) == "application/json" + + def _policy_allows_public_read(policy: dict[str, Any]) -> bool: statements = policy.get("Statement", []) if isinstance(statements, dict): @@ -285,13 +291,19 @@ def create_bucket(): principal = _current_principal() bucket_name = request.form.get("bucket_name", "").strip() if not bucket_name: + if _wants_json(): + return jsonify({"error": "Bucket name is required"}), 400 flash("Bucket name is required", "danger") return redirect(url_for("ui.buckets_overview")) try: _authorize_ui(principal, bucket_name, "write") _storage().create_bucket(bucket_name) + if _wants_json(): + return jsonify({"success": True, "message": f"Bucket '{bucket_name}' created", "bucket_name": bucket_name}) flash(f"Bucket '{bucket_name}' created", "success") except (StorageError, FileExistsError, IamError) as exc: + if _wants_json(): + return jsonify({"error": _friendly_error_message(exc)}), 400 flash(_friendly_error_message(exc), "danger") return redirect(url_for("ui.buckets_overview")) @@ -374,6 +386,7 @@ def bucket_detail(bucket_name: str): kms_keys = kms_manager.list_keys() if kms_manager else [] kms_enabled = current_app.config.get("KMS_ENABLED", False) encryption_enabled = current_app.config.get("ENCRYPTION_ENABLED", False) + lifecycle_enabled = current_app.config.get("LIFECYCLE_ENABLED", False) can_manage_encryption = can_manage_versioning bucket_quota = storage.get_bucket_quota(bucket_name) @@ -386,6 +399,7 @@ def bucket_detail(bucket_name: str): pass objects_api_url = url_for("ui.list_bucket_objects", bucket_name=bucket_name) + objects_stream_url = url_for("ui.stream_bucket_objects", bucket_name=bucket_name) lifecycle_url = url_for("ui.bucket_lifecycle", bucket_name=bucket_name) cors_url = url_for("ui.bucket_cors", bucket_name=bucket_name) @@ -397,6 +411,7 @@ def bucket_detail(bucket_name: str): "bucket_detail.html", bucket_name=bucket_name, objects_api_url=objects_api_url, + objects_stream_url=objects_stream_url, lifecycle_url=lifecycle_url, cors_url=cors_url, acl_url=acl_url, @@ -418,6 +433,7 @@ def bucket_detail(bucket_name: str): kms_keys=kms_keys, kms_enabled=kms_enabled, encryption_enabled=encryption_enabled, + lifecycle_enabled=lifecycle_enabled, bucket_quota=bucket_quota, bucket_stats=bucket_stats, can_manage_quota=can_manage_quota, @@ -492,6 +508,100 @@ def list_bucket_objects(bucket_name: str): }) +@ui_bp.get("/buckets//objects/stream") +def stream_bucket_objects(bucket_name: str): + """Streaming NDJSON endpoint for progressive object listing. + + Streams objects as newline-delimited JSON for fast progressive rendering. + First line is metadata, subsequent lines are objects. + """ + principal = _current_principal() + storage = _storage() + try: + _authorize_ui(principal, bucket_name, "list") + except IamError as exc: + return jsonify({"error": str(exc)}), 403 + + prefix = request.args.get("prefix") or None + + try: + versioning_enabled = storage.is_versioning_enabled(bucket_name) + except StorageError: + versioning_enabled = False + + preview_template = url_for("ui.object_preview", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER") + delete_template = url_for("ui.delete_object", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER") + presign_template = url_for("ui.object_presign", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER") + versions_template = url_for("ui.object_versions", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER") + restore_template = url_for("ui.restore_object_version", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER", version_id="VERSION_ID_PLACEHOLDER") + tags_template = url_for("ui.object_tags", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER") + copy_template = url_for("ui.copy_object", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER") + move_template = url_for("ui.move_object", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER") + + def generate(): + meta_line = json.dumps({ + "type": "meta", + "versioning_enabled": versioning_enabled, + "url_templates": { + "preview": preview_template, + "download": preview_template + "?download=1", + "presign": presign_template, + "delete": delete_template, + "versions": versions_template, + "restore": restore_template, + "tags": tags_template, + "copy": copy_template, + "move": move_template, + }, + }) + "\n" + yield meta_line + + continuation_token = None + total_count = None + batch_size = 5000 + + while True: + try: + result = storage.list_objects( + bucket_name, + max_keys=batch_size, + continuation_token=continuation_token, + prefix=prefix, + ) + except StorageError as exc: + yield json.dumps({"type": "error", "error": str(exc)}) + "\n" + return + + if total_count is None: + total_count = result.total_count + yield json.dumps({"type": "count", "total_count": total_count}) + "\n" + + for obj in result.objects: + yield json.dumps({ + "type": "object", + "key": obj.key, + "size": obj.size, + "last_modified": obj.last_modified.isoformat(), + "last_modified_display": obj.last_modified.strftime("%b %d, %Y %H:%M"), + "etag": obj.etag, + }) + "\n" + + if not result.is_truncated: + break + continuation_token = result.next_continuation_token + + yield json.dumps({"type": "done"}) + "\n" + + return Response( + generate(), + mimetype='application/x-ndjson', + headers={ + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no', + } + ) + + @ui_bp.post("/buckets//upload") @limiter.limit("30 per minute") def upload_object(bucket_name: str): @@ -647,8 +757,12 @@ def delete_bucket(bucket_name: str): _storage().delete_bucket(bucket_name) _bucket_policies().delete_policy(bucket_name) _replication_manager().delete_rule(bucket_name) + if _wants_json(): + return jsonify({"success": True, "message": f"Bucket '{bucket_name}' removed"}) flash(f"Bucket '{bucket_name}' removed", "success") except (StorageError, IamError) as exc: + if _wants_json(): + return jsonify({"error": _friendly_error_message(exc)}), 400 flash(_friendly_error_message(exc), "danger") return redirect(url_for("ui.buckets_overview")) @@ -662,12 +776,17 @@ def delete_object(bucket_name: str, object_key: str): _authorize_ui(principal, bucket_name, "delete", object_key=object_key) if purge_versions: _storage().purge_object(bucket_name, object_key) - flash(f"Permanently deleted '{object_key}' and all versions", "success") + message = f"Permanently deleted '{object_key}' and all versions" else: _storage().delete_object(bucket_name, object_key) _replication_manager().trigger_replication(bucket_name, object_key, action="delete") - flash(f"Deleted '{object_key}'", "success") + message = f"Deleted '{object_key}'" + if _wants_json(): + return jsonify({"success": True, "message": message}) + flash(message, "success") except (IamError, StorageError) as exc: + if _wants_json(): + return jsonify({"error": _friendly_error_message(exc)}), 400 flash(_friendly_error_message(exc), "danger") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name)) @@ -977,22 +1096,32 @@ def update_bucket_policy(bucket_name: str): try: _authorize_ui(principal, bucket_name, "policy") except IamError as exc: + if _wants_json(): + return jsonify({"error": str(exc)}), 403 flash(str(exc), "danger") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name)) store = _bucket_policies() if action == "delete": store.delete_policy(bucket_name) + if _wants_json(): + return jsonify({"success": True, "message": "Bucket policy removed"}) flash("Bucket policy removed", "info") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="permissions")) document = request.form.get("policy_document", "").strip() if not document: + if _wants_json(): + return jsonify({"error": "Provide a JSON policy document"}), 400 flash("Provide a JSON policy document", "danger") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="permissions")) try: payload = json.loads(document) store.set_policy(bucket_name, payload) + if _wants_json(): + return jsonify({"success": True, "message": "Bucket policy saved"}) flash("Bucket policy saved", "success") except (json.JSONDecodeError, ValueError) as exc: + if _wants_json(): + return jsonify({"error": f"Policy error: {exc}"}), 400 flash(f"Policy error: {exc}", "danger") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="permissions")) @@ -1003,6 +1132,8 @@ def update_bucket_versioning(bucket_name: str): try: _authorize_ui(principal, bucket_name, "write") except IamError as exc: + if _wants_json(): + return jsonify({"error": _friendly_error_message(exc)}), 403 flash(_friendly_error_message(exc), "danger") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties")) state = request.form.get("state", "enable") @@ -1010,9 +1141,14 @@ def update_bucket_versioning(bucket_name: str): try: _storage().set_bucket_versioning(bucket_name, enable) except StorageError as exc: + if _wants_json(): + return jsonify({"error": _friendly_error_message(exc)}), 400 flash(_friendly_error_message(exc), "danger") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties")) - flash("Versioning enabled" if enable else "Versioning suspended", "success") + message = "Versioning enabled" if enable else "Versioning suspended" + if _wants_json(): + return jsonify({"success": True, "message": message, "enabled": enable}) + flash(message, "success") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties")) @@ -1020,62 +1156,83 @@ def update_bucket_versioning(bucket_name: str): def update_bucket_quota(bucket_name: str): """Update bucket quota configuration (admin only).""" principal = _current_principal() - + is_admin = False try: _iam().authorize(principal, None, "iam:list_users") is_admin = True except IamError: pass - + if not is_admin: + if _wants_json(): + return jsonify({"error": "Only administrators can manage bucket quotas"}), 403 flash("Only administrators can manage bucket quotas", "danger") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties")) - + action = request.form.get("action", "set") - + if action == "remove": try: _storage().set_bucket_quota(bucket_name, max_bytes=None, max_objects=None) + if _wants_json(): + return jsonify({"success": True, "message": "Bucket quota removed"}) flash("Bucket quota removed", "info") except StorageError as exc: + if _wants_json(): + return jsonify({"error": _friendly_error_message(exc)}), 400 flash(_friendly_error_message(exc), "danger") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties")) - + max_mb_str = request.form.get("max_mb", "").strip() max_objects_str = request.form.get("max_objects", "").strip() - + max_bytes = None max_objects = None - + if max_mb_str: try: max_mb = int(max_mb_str) if max_mb < 1: raise ValueError("Size must be at least 1 MB") - max_bytes = max_mb * 1024 * 1024 + max_bytes = max_mb * 1024 * 1024 except ValueError as exc: + if _wants_json(): + return jsonify({"error": f"Invalid size value: {exc}"}), 400 flash(f"Invalid size value: {exc}", "danger") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties")) - + if max_objects_str: try: max_objects = int(max_objects_str) if max_objects < 0: raise ValueError("Object count must be non-negative") except ValueError as exc: + if _wants_json(): + return jsonify({"error": f"Invalid object count: {exc}"}), 400 flash(f"Invalid object count: {exc}", "danger") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties")) - + try: _storage().set_bucket_quota(bucket_name, max_bytes=max_bytes, max_objects=max_objects) if max_bytes is None and max_objects is None: - flash("Bucket quota removed", "info") + message = "Bucket quota removed" else: - flash("Bucket quota updated", "success") + message = "Bucket quota updated" + if _wants_json(): + return jsonify({ + "success": True, + "message": message, + "max_bytes": max_bytes, + "max_objects": max_objects, + "has_quota": max_bytes is not None or max_objects is not None + }) + flash(message, "success" if max_bytes or max_objects else "info") except StorageError as exc: + if _wants_json(): + return jsonify({"error": _friendly_error_message(exc)}), 400 flash(_friendly_error_message(exc), "danger") - + return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties")) @@ -1086,26 +1243,34 @@ def update_bucket_encryption(bucket_name: str): try: _authorize_ui(principal, bucket_name, "write") except IamError as exc: + if _wants_json(): + return jsonify({"error": _friendly_error_message(exc)}), 403 flash(_friendly_error_message(exc), "danger") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties")) - + action = request.form.get("action", "enable") - + if action == "disable": try: _storage().set_bucket_encryption(bucket_name, None) + if _wants_json(): + return jsonify({"success": True, "message": "Default encryption disabled", "enabled": False}) flash("Default encryption disabled", "info") except StorageError as exc: + if _wants_json(): + return jsonify({"error": _friendly_error_message(exc)}), 400 flash(_friendly_error_message(exc), "danger") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties")) - + algorithm = request.form.get("algorithm", "AES256") kms_key_id = request.form.get("kms_key_id", "").strip() or None - + if algorithm not in ("AES256", "aws:kms"): + if _wants_json(): + return jsonify({"error": "Invalid encryption algorithm"}), 400 flash("Invalid encryption algorithm", "danger") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties")) - + encryption_config: dict[str, Any] = { "Rules": [ { @@ -1115,19 +1280,24 @@ def update_bucket_encryption(bucket_name: str): } ] } - + if algorithm == "aws:kms" and kms_key_id: encryption_config["Rules"][0]["ApplyServerSideEncryptionByDefault"]["KMSMasterKeyID"] = kms_key_id - + try: _storage().set_bucket_encryption(bucket_name, encryption_config) if algorithm == "aws:kms": - flash("Default KMS encryption enabled", "success") + message = "Default KMS encryption enabled" else: - flash("Default AES-256 encryption enabled", "success") + message = "Default AES-256 encryption enabled" + if _wants_json(): + return jsonify({"success": True, "message": message, "enabled": True, "algorithm": algorithm}) + flash(message, "success") except StorageError as exc: + if _wants_json(): + return jsonify({"error": _friendly_error_message(exc)}), 400 flash(_friendly_error_message(exc), "danger") - + return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties")) @@ -1176,10 +1346,14 @@ def create_iam_user(): try: _iam().authorize(principal, None, "iam:create_user") except IamError as exc: + if _wants_json(): + return jsonify({"error": str(exc)}), 403 flash(str(exc), "danger") return redirect(url_for("ui.iam_dashboard")) display_name = request.form.get("display_name", "").strip() or "Unnamed" if len(display_name) > 64: + if _wants_json(): + return jsonify({"error": "Display name must be 64 characters or fewer"}), 400 flash("Display name must be 64 characters or fewer", "danger") return redirect(url_for("ui.iam_dashboard")) policies_text = request.form.get("policies", "").strip() @@ -1188,11 +1362,15 @@ def create_iam_user(): try: policies = json.loads(policies_text) except json.JSONDecodeError as exc: + if _wants_json(): + return jsonify({"error": f"Invalid JSON: {exc}"}), 400 flash(f"Invalid JSON: {exc}", "danger") return redirect(url_for("ui.iam_dashboard")) try: created = _iam().create_user(display_name=display_name, policies=policies) except IamError as exc: + if _wants_json(): + return jsonify({"error": str(exc)}), 400 flash(str(exc), "danger") return redirect(url_for("ui.iam_dashboard")) @@ -1203,6 +1381,15 @@ def create_iam_user(): "operation": "create", } ) + if _wants_json(): + return jsonify({ + "success": True, + "message": f"Created user {created['access_key']}", + "access_key": created["access_key"], + "secret_key": created["secret_key"], + "display_name": display_name, + "policies": policies or [] + }) flash(f"Created user {created['access_key']}. Copy the secret below.", "success") return redirect(url_for("ui.iam_dashboard", secret_token=token)) @@ -1254,18 +1441,26 @@ def update_iam_user(access_key: str): try: _iam().authorize(principal, None, "iam:create_user") except IamError as exc: + if _wants_json(): + return jsonify({"error": str(exc)}), 403 flash(str(exc), "danger") return redirect(url_for("ui.iam_dashboard")) display_name = request.form.get("display_name", "").strip() if display_name: if len(display_name) > 64: + if _wants_json(): + return jsonify({"error": "Display name must be 64 characters or fewer"}), 400 flash("Display name must be 64 characters or fewer", "danger") else: try: _iam().update_user(access_key, display_name) + if _wants_json(): + return jsonify({"success": True, "message": f"Updated user {access_key}", "display_name": display_name}) flash(f"Updated user {access_key}", "success") except IamError as exc: + if _wants_json(): + return jsonify({"error": str(exc)}), 400 flash(str(exc), "danger") return redirect(url_for("ui.iam_dashboard")) @@ -1277,6 +1472,8 @@ def delete_iam_user(access_key: str): try: _iam().authorize(principal, None, "iam:delete_user") except IamError as exc: + if _wants_json(): + return jsonify({"error": str(exc)}), 403 flash(str(exc), "danger") return redirect(url_for("ui.iam_dashboard")) @@ -1284,16 +1481,24 @@ def delete_iam_user(access_key: str): try: _iam().delete_user(access_key) session.pop("credentials", None) + if _wants_json(): + return jsonify({"success": True, "message": "Your account has been deleted", "redirect": url_for("ui.login")}) flash("Your account has been deleted.", "info") return redirect(url_for("ui.login")) except IamError as exc: + if _wants_json(): + return jsonify({"error": str(exc)}), 400 flash(str(exc), "danger") return redirect(url_for("ui.iam_dashboard")) try: _iam().delete_user(access_key) + if _wants_json(): + return jsonify({"success": True, "message": f"Deleted user {access_key}"}) flash(f"Deleted user {access_key}", "success") except IamError as exc: + if _wants_json(): + return jsonify({"error": str(exc)}), 400 flash(str(exc), "danger") return redirect(url_for("ui.iam_dashboard")) @@ -1304,6 +1509,8 @@ def update_iam_policies(access_key: str): try: _iam().authorize(principal, None, "iam:update_policy") except IamError as exc: + if _wants_json(): + return jsonify({"error": str(exc)}), 403 flash(str(exc), "danger") return redirect(url_for("ui.iam_dashboard")) @@ -1316,13 +1523,19 @@ def update_iam_policies(access_key: str): if not isinstance(policies, list): raise ValueError("Policies must be a list") except (ValueError, json.JSONDecodeError): + if _wants_json(): + return jsonify({"error": "Invalid JSON format for policies"}), 400 flash("Invalid JSON format for policies", "danger") return redirect(url_for("ui.iam_dashboard")) try: _iam().update_user_policies(access_key, policies) + if _wants_json(): + return jsonify({"success": True, "message": f"Updated policies for {access_key}", "policies": policies}) flash(f"Updated policies for {access_key}", "success") except IamError as exc: + if _wants_json(): + return jsonify({"error": str(exc)}), 400 flash(str(exc), "danger") return redirect(url_for("ui.iam_dashboard")) @@ -1334,19 +1547,23 @@ def create_connection(): try: _iam().authorize(principal, None, "iam:list_users") except IamError: + if _wants_json(): + return jsonify({"error": "Access denied"}), 403 flash("Access denied", "danger") return redirect(url_for("ui.buckets_overview")) - + name = request.form.get("name", "").strip() endpoint = request.form.get("endpoint_url", "").strip() access_key = request.form.get("access_key", "").strip() secret_key = request.form.get("secret_key", "").strip() region = request.form.get("region", "us-east-1").strip() - + if not all([name, endpoint, access_key, secret_key]): + if _wants_json(): + return jsonify({"error": "All fields are required"}), 400 flash("All fields are required", "danger") return redirect(url_for("ui.connections_dashboard")) - + conn = RemoteConnection( id=str(uuid.uuid4()), name=name, @@ -1356,6 +1573,8 @@ def create_connection(): region=region ) _connections().add(conn) + if _wants_json(): + return jsonify({"success": True, "message": f"Connection '{name}' created", "connection_id": conn.id}) flash(f"Connection '{name}' created", "success") return redirect(url_for("ui.connections_dashboard")) @@ -1415,11 +1634,15 @@ def update_connection(connection_id: str): try: _iam().authorize(principal, None, "iam:list_users") except IamError: + if _wants_json(): + return jsonify({"error": "Access denied"}), 403 flash("Access denied", "danger") return redirect(url_for("ui.buckets_overview")) conn = _connections().get(connection_id) if not conn: + if _wants_json(): + return jsonify({"error": "Connection not found"}), 404 flash("Connection not found", "danger") return redirect(url_for("ui.connections_dashboard")) @@ -1430,6 +1653,8 @@ def update_connection(connection_id: str): region = request.form.get("region", "us-east-1").strip() if not all([name, endpoint, access_key, secret_key]): + if _wants_json(): + return jsonify({"error": "All fields are required"}), 400 flash("All fields are required", "danger") return redirect(url_for("ui.connections_dashboard")) @@ -1438,8 +1663,20 @@ def update_connection(connection_id: str): conn.access_key = access_key conn.secret_key = secret_key conn.region = region - + _connections().save() + if _wants_json(): + return jsonify({ + "success": True, + "message": f"Connection '{name}' updated", + "connection": { + "id": connection_id, + "name": name, + "endpoint_url": endpoint, + "access_key": access_key, + "region": region + } + }) flash(f"Connection '{name}' updated", "success") return redirect(url_for("ui.connections_dashboard")) @@ -1450,10 +1687,14 @@ def delete_connection(connection_id: str): try: _iam().authorize(principal, None, "iam:list_users") except IamError: + if _wants_json(): + return jsonify({"error": "Access denied"}), 403 flash("Access denied", "danger") return redirect(url_for("ui.buckets_overview")) - + _connections().delete(connection_id) + if _wants_json(): + return jsonify({"success": True, "message": "Connection deleted"}) flash("Connection deleted", "success") return redirect(url_for("ui.connections_dashboard")) @@ -1464,31 +1705,41 @@ def update_bucket_replication(bucket_name: str): try: _authorize_ui(principal, bucket_name, "replication") except IamError as exc: + if _wants_json(): + return jsonify({"error": str(exc)}), 403 flash(str(exc), "danger") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="replication")) - + is_admin = False try: _iam().authorize(principal, None, "iam:list_users") is_admin = True except IamError: is_admin = False - + action = request.form.get("action") - + if action == "delete": if not is_admin: + if _wants_json(): + return jsonify({"error": "Only administrators can remove replication configuration"}), 403 flash("Only administrators can remove replication configuration", "danger") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="replication")) _replication().delete_rule(bucket_name) + if _wants_json(): + return jsonify({"success": True, "message": "Replication configuration removed", "action": "delete"}) flash("Replication configuration removed", "info") elif action == "pause": rule = _replication().get_rule(bucket_name) if rule: rule.enabled = False _replication().set_rule(rule) + if _wants_json(): + return jsonify({"success": True, "message": "Replication paused", "action": "pause", "enabled": False}) flash("Replication paused", "info") else: + if _wants_json(): + return jsonify({"error": "No replication configuration to pause"}), 404 flash("No replication configuration to pause", "warning") elif action == "resume": from .replication import REPLICATION_MODE_ALL @@ -1498,24 +1749,33 @@ def update_bucket_replication(bucket_name: str): _replication().set_rule(rule) if rule.mode == REPLICATION_MODE_ALL: _replication().replicate_existing_objects(bucket_name) - flash("Replication resumed. Syncing pending objects in background.", "success") + message = "Replication resumed. Syncing pending objects in background." else: - flash("Replication resumed", "success") + message = "Replication resumed" + if _wants_json(): + return jsonify({"success": True, "message": message, "action": "resume", "enabled": True}) + flash(message, "success") else: + if _wants_json(): + return jsonify({"error": "No replication configuration to resume"}), 404 flash("No replication configuration to resume", "warning") elif action == "create": if not is_admin: + if _wants_json(): + return jsonify({"error": "Only administrators can configure replication settings"}), 403 flash("Only administrators can configure replication settings", "danger") return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="replication")) - + from .replication import REPLICATION_MODE_NEW_ONLY, REPLICATION_MODE_ALL import time - + target_conn_id = request.form.get("target_connection_id") target_bucket = request.form.get("target_bucket", "").strip() replication_mode = request.form.get("replication_mode", REPLICATION_MODE_NEW_ONLY) - + if not target_conn_id or not target_bucket: + if _wants_json(): + return jsonify({"error": "Target connection and bucket are required"}), 400 flash("Target connection and bucket are required", "danger") else: rule = ReplicationRule( @@ -1527,15 +1787,20 @@ def update_bucket_replication(bucket_name: str): created_at=time.time(), ) _replication().set_rule(rule) - + if replication_mode == REPLICATION_MODE_ALL: _replication().replicate_existing_objects(bucket_name) - flash("Replication configured. Existing objects are being replicated in the background.", "success") + message = "Replication configured. Existing objects are being replicated in the background." else: - flash("Replication configured. Only new uploads will be replicated.", "success") + message = "Replication configured. Only new uploads will be replicated." + if _wants_json(): + return jsonify({"success": True, "message": message, "action": "create", "enabled": True}) + flash(message, "success") else: + if _wants_json(): + return jsonify({"error": "Invalid action"}), 400 flash("Invalid action", "danger") - + return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="replication")) @@ -1767,6 +2032,67 @@ def metrics_dashboard(): ) +@ui_bp.route("/metrics/api") +def metrics_api(): + principal = _current_principal() + + try: + _iam().authorize(principal, None, "iam:list_users") + except IamError: + return jsonify({"error": "Access denied"}), 403 + + import time + + cpu_percent = psutil.cpu_percent(interval=0.1) + memory = psutil.virtual_memory() + + storage_root = current_app.config["STORAGE_ROOT"] + disk = psutil.disk_usage(storage_root) + + storage = _storage() + buckets = storage.list_buckets() + total_buckets = len(buckets) + + total_objects = 0 + total_bytes_used = 0 + total_versions = 0 + + cache_ttl = current_app.config.get("BUCKET_STATS_CACHE_TTL", 60) + for bucket in buckets: + stats = storage.bucket_stats(bucket.name, cache_ttl=cache_ttl) + total_objects += stats.get("total_objects", stats.get("objects", 0)) + total_bytes_used += stats.get("total_bytes", stats.get("bytes", 0)) + total_versions += stats.get("version_count", 0) + + boot_time = psutil.boot_time() + uptime_seconds = time.time() - boot_time + uptime_days = int(uptime_seconds / 86400) + + return jsonify({ + "cpu_percent": cpu_percent, + "memory": { + "total": _format_bytes(memory.total), + "available": _format_bytes(memory.available), + "used": _format_bytes(memory.used), + "percent": memory.percent, + }, + "disk": { + "total": _format_bytes(disk.total), + "free": _format_bytes(disk.free), + "used": _format_bytes(disk.used), + "percent": disk.percent, + }, + "app": { + "buckets": total_buckets, + "objects": total_objects, + "versions": total_versions, + "storage_used": _format_bytes(total_bytes_used), + "storage_raw": total_bytes_used, + "uptime_days": uptime_days, + } + }) + + @ui_bp.route("/buckets//lifecycle", methods=["GET", "POST", "DELETE"]) def bucket_lifecycle(bucket_name: str): principal = _current_principal() diff --git a/app/version.py b/app/version.py index 6f91c30..3e11219 100644 --- a/app/version.py +++ b/app/version.py @@ -1,6 +1,6 @@ from __future__ import annotations -APP_VERSION = "0.2.0" +APP_VERSION = "0.2.1" def get_version() -> str: diff --git a/docs.md b/docs.md index 6515b6c..3cbbf98 100644 --- a/docs.md +++ b/docs.md @@ -189,6 +189,52 @@ All configuration is done via environment variables. The table below lists every | `KMS_ENABLED` | `false` | Enable KMS key management for encryption. | | `KMS_KEYS_PATH` | `data/.myfsio.sys/keys/kms_keys.json` | Path to store KMS key metadata. | + +## Lifecycle Rules + +Lifecycle rules automate object management by scheduling deletions based on object age. + +### Enabling Lifecycle Enforcement + +By default, lifecycle enforcement is disabled. Enable it by setting the environment variable: + +```bash +LIFECYCLE_ENABLED=true python run.py +``` + +Or in your `myfsio.env` file: +``` +LIFECYCLE_ENABLED=true +LIFECYCLE_INTERVAL_SECONDS=3600 # Check interval (default: 1 hour) +``` + +### Configuring Rules + +Once enabled, configure lifecycle rules via: +- **Web UI:** Bucket Details → Lifecycle tab → Add Rule +- **S3 API:** `PUT /?lifecycle` with XML configuration + +### Available Actions + +| Action | Description | +|--------|-------------| +| **Expiration** | Delete current version objects after N days | +| **NoncurrentVersionExpiration** | Delete old versions N days after becoming noncurrent (requires versioning) | +| **AbortIncompleteMultipartUpload** | Clean up incomplete multipart uploads after N days | + +### Example Configuration (XML) + +```xml + + + DeleteOldLogs + Enabled + logs/ + 30 + + +``` + ### Performance Tuning | Variable | Default | Notes | diff --git a/requirements.txt b/requirements.txt index e69aa35..8fe9bb3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ Flask>=3.1.2 Flask-Limiter>=4.1.1 Flask-Cors>=6.0.2 Flask-WTF>=1.2.2 +python-dotenv>=1.2.1 pytest>=9.0.2 requests>=2.32.5 boto3>=1.42.14 diff --git a/run.py b/run.py index da78033..3de61c4 100644 --- a/run.py +++ b/run.py @@ -6,6 +6,17 @@ import os import sys import warnings from multiprocessing import Process +from pathlib import Path + +from dotenv import load_dotenv + +for _env_file in [ + Path("/opt/myfsio/myfsio.env"), + Path.cwd() / ".env", + Path.cwd() / "myfsio.env", +]: + if _env_file.exists(): + load_dotenv(_env_file, override=True) from app import create_api_app, create_ui_app from app.config import AppConfig diff --git a/static/js/bucket-detail-main.js b/static/js/bucket-detail-main.js new file mode 100644 index 0000000..cbd657b --- /dev/null +++ b/static/js/bucket-detail-main.js @@ -0,0 +1,4169 @@ +(function() { + 'use strict'; + + const { formatBytes, escapeHtml, fallbackCopy, setupJsonAutoIndent } = window.BucketDetailUtils || { + formatBytes: (bytes) => { + if (!Number.isFinite(bytes)) return `${bytes} bytes`; + const units = ['bytes', 'KB', 'MB', 'GB', 'TB']; + let i = 0; + let size = bytes; + while (size >= 1024 && i < units.length - 1) { + size /= 1024; + i++; + } + return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; + }, + escapeHtml: (value) => { + if (value === null || value === undefined) return ''; + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + }, + fallbackCopy: () => false, + setupJsonAutoIndent: () => {} + }; + + setupJsonAutoIndent(document.getElementById('policyDocument')); + + const selectAllCheckbox = document.querySelector('[data-select-all]'); + const bulkDeleteButton = document.querySelector('[data-bulk-delete-trigger]'); + const bulkDeleteLabel = bulkDeleteButton?.querySelector('[data-bulk-delete-label]'); + const bulkDeleteModalEl = document.getElementById('bulkDeleteModal'); + const bulkDeleteModal = bulkDeleteModalEl ? new bootstrap.Modal(bulkDeleteModalEl) : null; + const bulkDeleteList = document.getElementById('bulkDeleteList'); + const bulkDeleteCount = document.getElementById('bulkDeleteCount'); + const bulkDeleteStatus = document.getElementById('bulkDeleteStatus'); + const bulkDeleteConfirm = document.getElementById('bulkDeleteConfirm'); + const bulkDeletePurge = document.getElementById('bulkDeletePurge'); + const previewPanel = document.getElementById('preview-panel'); + const previewEmpty = document.getElementById('preview-empty'); + const previewKey = document.getElementById('preview-key'); + const previewSize = document.getElementById('preview-size'); + const previewModified = document.getElementById('preview-modified'); + const previewEtag = document.getElementById('preview-etag'); + const previewMetadata = document.getElementById('preview-metadata'); + const previewMetadataList = document.getElementById('preview-metadata-list'); + const previewPlaceholder = document.getElementById('preview-placeholder'); + const previewImage = document.getElementById('preview-image'); + const previewVideo = document.getElementById('preview-video'); + const previewIframe = document.getElementById('preview-iframe'); + const downloadButton = document.getElementById('downloadButton'); + const presignButton = document.getElementById('presignButton'); + const presignModalEl = document.getElementById('presignModal'); + const presignModal = presignModalEl ? new bootstrap.Modal(presignModalEl) : null; + const presignMethod = document.getElementById('presignMethod'); + const presignTtl = document.getElementById('presignTtl'); + const presignLink = document.getElementById('presignLink'); + const copyPresignLink = document.getElementById('copyPresignLink'); + const copyPresignDefaultLabel = copyPresignLink?.textContent?.trim() || 'Copy'; + const generatePresignButton = document.getElementById('generatePresignButton'); + const policyForm = document.getElementById('bucketPolicyForm'); + const policyTextarea = document.getElementById('policyDocument'); + const policyPreset = document.getElementById('policyPreset'); + const policyMode = document.getElementById('policyMode'); + const uploadForm = document.querySelector('[data-upload-form]'); + const uploadModalEl = document.getElementById('uploadModal'); + const uploadModal = uploadModalEl ? bootstrap.Modal.getOrCreateInstance(uploadModalEl) : null; + const uploadFileInput = uploadForm?.querySelector('input[name="object"]'); + const uploadDropZone = uploadForm?.querySelector('[data-dropzone]'); + const uploadDropZoneLabel = uploadDropZone?.querySelector('[data-dropzone-label]'); + const messageModalEl = document.getElementById('messageModal'); + const messageModal = messageModalEl ? new bootstrap.Modal(messageModalEl) : null; + const messageModalTitle = document.getElementById('messageModalTitle'); + const messageModalBody = document.getElementById('messageModalBody'); + const messageModalAction = document.getElementById('messageModalAction'); + let messageModalActionHandler = null; + let isGeneratingPresign = false; + const objectsContainer = document.querySelector('.objects-table-container[data-bucket]'); + const bulkDeleteEndpoint = objectsContainer?.dataset.bulkDeleteEndpoint || ''; + const objectsApiUrl = objectsContainer?.dataset.objectsApi || ''; + const objectsStreamUrl = objectsContainer?.dataset.objectsStream || ''; + const versionPanel = document.getElementById('version-panel'); + const versionList = document.getElementById('version-list'); + const refreshVersionsButton = document.getElementById('refreshVersionsButton'); + const archivedCard = document.getElementById('archived-objects-card'); + const archivedBody = archivedCard?.querySelector('[data-archived-body]'); + const archivedCountBadge = archivedCard?.querySelector('[data-archived-count]'); + const archivedRefreshButton = archivedCard?.querySelector('[data-archived-refresh]'); + const archivedEndpoint = archivedCard?.dataset.archivedEndpoint; + let versioningEnabled = objectsContainer?.dataset.versioning === 'true'; + const versionsCache = new Map(); + let activeRow = null; + const selectedRows = new Map(); + let bulkDeleting = false; + if (presignButton) presignButton.disabled = true; + if (generatePresignButton) generatePresignButton.disabled = true; + if (downloadButton) downloadButton.classList.add('disabled'); + + const objectCountBadge = document.getElementById('object-count-badge'); + const loadMoreContainer = document.getElementById('load-more-container'); + const loadMoreSpinner = document.getElementById('load-more-spinner'); + const loadMoreStatus = document.getElementById('load-more-status'); + const objectsLoadingRow = document.getElementById('objects-loading-row'); + let nextContinuationToken = null; + let totalObjectCount = 0; + let loadedObjectCount = 0; + let isLoadingObjects = false; + let hasMoreObjects = false; + let currentFilterTerm = ''; + let pageSize = 5000; + let currentPrefix = ''; + let allObjects = []; + let urlTemplates = null; + let streamAbortController = null; + let useStreaming = !!objectsStreamUrl; + let streamingComplete = false; + const STREAM_RENDER_BATCH = 500; + let pendingStreamObjects = []; + let streamRenderScheduled = false; + + const buildUrlFromTemplate = (template, key) => { + if (!template) return ''; + return template.replace('KEY_PLACEHOLDER', encodeURIComponent(key).replace(/%2F/g, '/')); + }; + + const ROW_HEIGHT = 53; + const BUFFER_ROWS = 10; + let visibleItems = []; + let renderedRange = { start: 0, end: 0 }; + + const createObjectRow = (obj, displayKey = null) => { + const tr = document.createElement('tr'); + tr.dataset.objectRow = ''; + tr.dataset.key = obj.key; + tr.dataset.size = obj.size; + tr.dataset.lastModified = obj.lastModified || obj.last_modified; + tr.dataset.etag = obj.etag; + tr.dataset.previewUrl = obj.previewUrl || obj.preview_url; + tr.dataset.downloadUrl = obj.downloadUrl || obj.download_url; + tr.dataset.presignEndpoint = obj.presignEndpoint || obj.presign_endpoint; + tr.dataset.deleteEndpoint = obj.deleteEndpoint || obj.delete_endpoint; + tr.dataset.metadata = typeof obj.metadata === 'string' ? obj.metadata : JSON.stringify(obj.metadata || {}); + tr.dataset.versionsEndpoint = obj.versionsEndpoint || obj.versions_endpoint; + tr.dataset.restoreTemplate = obj.restoreTemplate || obj.restore_template; + tr.dataset.tagsUrl = obj.tagsUrl || obj.tags_url; + tr.dataset.copyUrl = obj.copyUrl || obj.copy_url; + tr.dataset.moveUrl = obj.moveUrl || obj.move_url; + + const keyToShow = displayKey || obj.key; + const lastModDisplay = obj.lastModifiedDisplay || obj.last_modified_display || new Date(obj.lastModified || obj.last_modified).toLocaleDateString(); + + tr.innerHTML = ` + + + + +
${escapeHtml(keyToShow)}
+
Modified ${escapeHtml(lastModDisplay)}
+ + + ${formatBytes(obj.size)} + + +
+ + + + +
+ + `; + + return tr; + }; + + const showEmptyState = () => { + if (!objectsTableBody) return; + objectsTableBody.innerHTML = ` + + +
+
+ + + + +
+
No objects yet
+

Drag and drop files here or click Upload to get started.

+ +
+ + + `; + }; + + const showLoadError = (message) => { + if (!objectsTableBody) return; + objectsTableBody.innerHTML = ` + + +
+ + + +

Failed to load objects

+

${escapeHtml(message)}

+ +
+ + + `; + }; + + const updateObjectCountBadge = () => { + if (!objectCountBadge) return; + if (totalObjectCount === 0) { + objectCountBadge.textContent = '0 objects'; + } else { + objectCountBadge.textContent = `${totalObjectCount.toLocaleString()} object${totalObjectCount !== 1 ? 's' : ''}`; + } + }; + + let topSpacer = null; + let bottomSpacer = null; + + const initVirtualScrollElements = () => { + if (!objectsTableBody) return; + + if (!topSpacer) { + topSpacer = document.createElement('tr'); + topSpacer.id = 'virtual-top-spacer'; + topSpacer.innerHTML = ''; + } + if (!bottomSpacer) { + bottomSpacer = document.createElement('tr'); + bottomSpacer.id = 'virtual-bottom-spacer'; + bottomSpacer.innerHTML = ''; + } + }; + + const computeVisibleItems = () => { + const items = []; + const folders = new Set(); + + allObjects.forEach(obj => { + if (!obj.key.startsWith(currentPrefix)) return; + + const remainder = obj.key.slice(currentPrefix.length); + + if (!remainder) return; + + const isFolderMarker = obj.key.endsWith('/') && obj.size === 0; + const slashIndex = remainder.indexOf('/'); + + if (slashIndex === -1 && !isFolderMarker) { + if (!currentFilterTerm || remainder.toLowerCase().includes(currentFilterTerm)) { + items.push({ type: 'file', data: obj, displayKey: remainder }); + } + } else { + const effectiveSlashIndex = isFolderMarker && slashIndex === remainder.length - 1 + ? slashIndex + : (slashIndex === -1 ? remainder.length - 1 : slashIndex); + const folderName = remainder.slice(0, effectiveSlashIndex); + const folderPath = currentPrefix + folderName + '/'; + if (!folders.has(folderPath)) { + folders.add(folderPath); + if (!currentFilterTerm || folderName.toLowerCase().includes(currentFilterTerm)) { + items.push({ type: 'folder', path: folderPath, displayKey: folderName }); + } + } + } + }); + + items.sort((a, b) => { + if (a.type === 'folder' && b.type === 'file') return -1; + if (a.type === 'file' && b.type === 'folder') return 1; + const aKey = a.type === 'folder' ? a.path : a.data.key; + const bKey = b.type === 'folder' ? b.path : b.data.key; + return aKey.localeCompare(bKey); + }); + + return items; + }; + + const renderVirtualRows = () => { + if (!objectsTableBody || !scrollContainer) return; + + const containerHeight = scrollContainer.clientHeight; + const scrollTop = scrollContainer.scrollTop; + + const startIndex = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - BUFFER_ROWS); + const endIndex = Math.min(visibleItems.length, Math.ceil((scrollTop + containerHeight) / ROW_HEIGHT) + BUFFER_ROWS); + + if (startIndex === renderedRange.start && endIndex === renderedRange.end) return; + + renderedRange = { start: startIndex, end: endIndex }; + + objectsTableBody.innerHTML = ''; + + initVirtualScrollElements(); + topSpacer.querySelector('td').style.height = `${startIndex * ROW_HEIGHT}px`; + objectsTableBody.appendChild(topSpacer); + + for (let i = startIndex; i < endIndex; i++) { + const item = visibleItems[i]; + if (!item) continue; + + let row; + if (item.type === 'folder') { + row = createFolderRow(item.path, item.displayKey); + } else { + row = createObjectRow(item.data, item.displayKey); + } + row.dataset.virtualIndex = i; + objectsTableBody.appendChild(row); + } + + const remainingRows = visibleItems.length - endIndex; + bottomSpacer.querySelector('td').style.height = `${remainingRows * ROW_HEIGHT}px`; + objectsTableBody.appendChild(bottomSpacer); + + attachRowHandlers(); + }; + + let scrollTimeout = null; + const handleVirtualScroll = () => { + if (scrollTimeout) cancelAnimationFrame(scrollTimeout); + scrollTimeout = requestAnimationFrame(renderVirtualRows); + }; + + const refreshVirtualList = () => { + visibleItems = computeVisibleItems(); + renderedRange = { start: -1, end: -1 }; + + if (visibleItems.length === 0) { + if (allObjects.length === 0 && !hasMoreObjects) { + showEmptyState(); + } else { + objectsTableBody.innerHTML = ` + + +
+
+ + + +
+
Empty folder
+

This folder contains no objects${hasMoreObjects ? ' yet. Loading more...' : '.'}

+
+ + + `; + } + } else { + renderVirtualRows(); + } + + updateFolderViewStatus(); + }; + + const updateFolderViewStatus = () => { + const folderViewStatusEl = document.getElementById('folder-view-status'); + if (!folderViewStatusEl) return; + + if (currentPrefix) { + const folderCount = visibleItems.filter(i => i.type === 'folder').length; + const fileCount = visibleItems.filter(i => i.type === 'file').length; + folderViewStatusEl.innerHTML = `${folderCount} folder${folderCount !== 1 ? 's' : ''}, ${fileCount} file${fileCount !== 1 ? 's' : ''} in this view`; + folderViewStatusEl.classList.remove('d-none'); + } else { + folderViewStatusEl.classList.add('d-none'); + } + }; + + const processStreamObject = (obj) => { + const key = obj.key; + return { + key: key, + size: obj.size, + lastModified: obj.last_modified, + lastModifiedDisplay: obj.last_modified_display, + etag: obj.etag, + previewUrl: urlTemplates ? buildUrlFromTemplate(urlTemplates.preview, key) : '', + downloadUrl: urlTemplates ? buildUrlFromTemplate(urlTemplates.download, key) : '', + presignEndpoint: urlTemplates ? buildUrlFromTemplate(urlTemplates.presign, key) : '', + deleteEndpoint: urlTemplates ? buildUrlFromTemplate(urlTemplates.delete, key) : '', + metadata: '{}', + versionsEndpoint: urlTemplates ? buildUrlFromTemplate(urlTemplates.versions, key) : '', + restoreTemplate: urlTemplates ? urlTemplates.restore.replace('KEY_PLACEHOLDER', encodeURIComponent(key).replace(/%2F/g, '/')) : '', + tagsUrl: urlTemplates ? buildUrlFromTemplate(urlTemplates.tags, key) : '', + copyUrl: urlTemplates ? buildUrlFromTemplate(urlTemplates.copy, key) : '', + moveUrl: urlTemplates ? buildUrlFromTemplate(urlTemplates.move, key) : '' + }; + }; + + const flushPendingStreamObjects = () => { + if (pendingStreamObjects.length === 0) return; + const batch = pendingStreamObjects.splice(0, pendingStreamObjects.length); + batch.forEach(obj => { + loadedObjectCount++; + allObjects.push(obj); + }); + updateObjectCountBadge(); + if (loadMoreStatus) { + if (streamingComplete) { + loadMoreStatus.textContent = `${loadedObjectCount.toLocaleString()} objects`; + } else { + const countText = totalObjectCount > 0 ? ` of ${totalObjectCount.toLocaleString()}` : ''; + loadMoreStatus.textContent = `${loadedObjectCount.toLocaleString()}${countText} loading...`; + } + } + refreshVirtualList(); + streamRenderScheduled = false; + }; + + const scheduleStreamRender = () => { + if (streamRenderScheduled) return; + streamRenderScheduled = true; + requestAnimationFrame(flushPendingStreamObjects); + }; + + const loadObjectsStreaming = async () => { + if (isLoadingObjects) return; + isLoadingObjects = true; + streamingComplete = false; + + if (objectsLoadingRow) objectsLoadingRow.style.display = ''; + nextContinuationToken = null; + loadedObjectCount = 0; + totalObjectCount = 0; + allObjects = []; + pendingStreamObjects = []; + + streamAbortController = new AbortController(); + + try { + const params = new URLSearchParams(); + if (currentPrefix) params.set('prefix', currentPrefix); + + const response = await fetch(`${objectsStreamUrl}?${params}`, { + signal: streamAbortController.signal + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + if (objectsLoadingRow) objectsLoadingRow.remove(); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.trim()) continue; + try { + const msg = JSON.parse(line); + switch (msg.type) { + case 'meta': + urlTemplates = msg.url_templates; + versioningEnabled = msg.versioning_enabled; + if (objectsContainer) { + objectsContainer.dataset.versioning = versioningEnabled ? 'true' : 'false'; + } + break; + case 'count': + totalObjectCount = msg.total_count || 0; + break; + case 'object': + pendingStreamObjects.push(processStreamObject(msg)); + if (pendingStreamObjects.length >= STREAM_RENDER_BATCH) { + scheduleStreamRender(); + } + break; + case 'error': + throw new Error(msg.error); + case 'done': + streamingComplete = true; + break; + } + } catch (parseErr) { + console.warn('Failed to parse stream line:', line, parseErr); + } + } + if (pendingStreamObjects.length > 0) { + scheduleStreamRender(); + } + } + + if (buffer.trim()) { + try { + const msg = JSON.parse(buffer); + if (msg.type === 'object') { + pendingStreamObjects.push(processStreamObject(msg)); + } else if (msg.type === 'done') { + streamingComplete = true; + } + } catch (e) {} + } + + flushPendingStreamObjects(); + streamingComplete = true; + hasMoreObjects = false; + updateObjectCountBadge(); + + if (loadMoreStatus) { + loadMoreStatus.textContent = `${loadedObjectCount.toLocaleString()} objects`; + } + if (typeof updateLoadMoreButton === 'function') { + updateLoadMoreButton(); + } + refreshVirtualList(); + renderBreadcrumb(currentPrefix); + + } catch (error) { + if (error.name === 'AbortError') return; + console.error('Streaming failed, falling back to paginated:', error); + useStreaming = false; + isLoadingObjects = false; + await loadObjectsPaginated(false); + return; + } finally { + isLoadingObjects = false; + streamAbortController = null; + } + }; + + const loadObjectsPaginated = async (append = false) => { + if (isLoadingObjects) return; + isLoadingObjects = true; + + if (!append) { + if (objectsLoadingRow) objectsLoadingRow.style.display = ''; + nextContinuationToken = null; + loadedObjectCount = 0; + totalObjectCount = 0; + allObjects = []; + } + + if (append && loadMoreSpinner) { + loadMoreSpinner.classList.remove('d-none'); + } + + try { + const params = new URLSearchParams({ max_keys: String(pageSize) }); + if (nextContinuationToken) { + params.set('continuation_token', nextContinuationToken); + } + + const response = await fetch(`${objectsApiUrl}?${params}`); + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.error || `HTTP ${response.status}`); + } + + const data = await response.json(); + + versioningEnabled = data.versioning_enabled; + if (objectsContainer) { + objectsContainer.dataset.versioning = versioningEnabled ? 'true' : 'false'; + } + + totalObjectCount = data.total_count || 0; + nextContinuationToken = data.next_continuation_token; + + if (!append && objectsLoadingRow) { + objectsLoadingRow.remove(); + } + + if (data.url_templates && !urlTemplates) { + urlTemplates = data.url_templates; + } + + data.objects.forEach(obj => { + loadedObjectCount++; + allObjects.push(processStreamObject(obj)); + }); + + updateObjectCountBadge(); + hasMoreObjects = data.is_truncated; + + if (loadMoreStatus) { + if (data.is_truncated) { + loadMoreStatus.textContent = `${loadedObjectCount.toLocaleString()} of ${totalObjectCount.toLocaleString()} loaded`; + } else { + loadMoreStatus.textContent = `${loadedObjectCount.toLocaleString()} objects`; + } + } + + if (typeof updateLoadMoreButton === 'function') { + updateLoadMoreButton(); + } + + refreshVirtualList(); + renderBreadcrumb(currentPrefix); + + } catch (error) { + console.error('Failed to load objects:', error); + if (!append) { + showLoadError(error.message); + } else { + showMessage({ title: 'Load Failed', body: error.message, variant: 'danger' }); + } + } finally { + isLoadingObjects = false; + if (loadMoreSpinner) { + loadMoreSpinner.classList.add('d-none'); + } + } + }; + + const loadObjects = async (append = false) => { + if (useStreaming && !append) { + return loadObjectsStreaming(); + } + return loadObjectsPaginated(append); + }; + + const attachRowHandlers = () => { + const objectRows = document.querySelectorAll('[data-object-row]'); + objectRows.forEach(row => { + if (row.dataset.handlersAttached) return; + row.dataset.handlersAttached = 'true'; + + const deleteBtn = row.querySelector('[data-delete-object]'); + deleteBtn?.addEventListener('click', (e) => { + e.stopPropagation(); + const deleteModalEl = document.getElementById('deleteObjectModal'); + const deleteModal = deleteModalEl ? bootstrap.Modal.getOrCreateInstance(deleteModalEl) : null; + const deleteObjectForm = document.getElementById('deleteObjectForm'); + const deleteObjectKey = document.getElementById('deleteObjectKey'); + if (deleteModal && deleteObjectForm) { + deleteObjectForm.setAttribute('action', row.dataset.deleteEndpoint); + if (deleteObjectKey) deleteObjectKey.textContent = row.dataset.key; + deleteModal.show(); + } + }); + + const selectCheckbox = row.querySelector('[data-object-select]'); + selectCheckbox?.addEventListener('click', (event) => event.stopPropagation()); + selectCheckbox?.addEventListener('change', () => { + toggleRowSelection(row, selectCheckbox.checked); + }); + + if (selectedRows.has(row.dataset.key)) { + selectCheckbox.checked = true; + row.classList.add('table-active'); + } + }); + + const folderRows = document.querySelectorAll('.folder-row'); + folderRows.forEach(row => { + if (row.dataset.handlersAttached) return; + row.dataset.handlersAttached = 'true'; + + const folderPath = row.dataset.folderPath; + + const checkbox = row.querySelector('[data-folder-select]'); + checkbox?.addEventListener('change', (e) => { + e.stopPropagation(); + const folderObjects = allObjects.filter(obj => obj.key.startsWith(folderPath)); + folderObjects.forEach(obj => { + if (checkbox.checked) { + selectedRows.set(obj.key, obj); + } else { + selectedRows.delete(obj.key); + } + }); + updateBulkDeleteState(); + }); + + const folderBtn = row.querySelector('button'); + folderBtn?.addEventListener('click', (e) => { + e.stopPropagation(); + navigateToFolder(folderPath); + }); + + row.addEventListener('click', (e) => { + if (e.target.closest('[data-folder-select]') || e.target.closest('button')) return; + navigateToFolder(folderPath); + }); + }); + + updateBulkDeleteState(); + }; + + const scrollSentinel = document.getElementById('scroll-sentinel'); + const scrollContainer = document.querySelector('.objects-table-container'); + const loadMoreBtn = document.getElementById('load-more-btn'); + + if (scrollContainer) { + scrollContainer.addEventListener('scroll', handleVirtualScroll, { passive: true }); + } + + loadMoreBtn?.addEventListener('click', () => { + if (hasMoreObjects && !isLoadingObjects) { + loadObjects(true); + } + }); + + function updateLoadMoreButton() { + if (loadMoreBtn) { + loadMoreBtn.classList.toggle('d-none', !hasMoreObjects); + } + } + + if (scrollSentinel && scrollContainer) { + const containerObserver = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting && hasMoreObjects && !isLoadingObjects) { + loadObjects(true); + } + }); + }, { + root: scrollContainer, + rootMargin: '500px', + threshold: 0 + }); + containerObserver.observe(scrollSentinel); + + const viewportObserver = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting && hasMoreObjects && !isLoadingObjects) { + loadObjects(true); + } + }); + }, { + root: null, + rootMargin: '500px', + threshold: 0 + }); + viewportObserver.observe(scrollSentinel); + } + + const pageSizeSelect = document.getElementById('page-size-select'); + pageSizeSelect?.addEventListener('change', (e) => { + pageSize = parseInt(e.target.value, 10); + }); + + if (objectsApiUrl) { + loadObjects(); + } + + const folderBreadcrumb = document.getElementById('folder-breadcrumb'); + const objectsTableBody = document.querySelector('#objects-table tbody'); + + if (objectsTableBody) { + objectsTableBody.addEventListener('click', (e) => { + const row = e.target.closest('[data-object-row]'); + if (!row) return; + + if (e.target.closest('[data-delete-object]') || e.target.closest('[data-object-select]') || e.target.closest('a')) { + return; + } + + selectRow(row); + }); + } + + const hasFolders = () => allObjects.some(obj => obj.key.includes('/')); + + const getFoldersAtPrefix = (prefix) => { + const folders = new Set(); + const files = []; + + allObjects.forEach(obj => { + const key = obj.key; + if (!key.startsWith(prefix)) return; + + const remainder = key.slice(prefix.length); + const slashIndex = remainder.indexOf('/'); + + if (slashIndex === -1) { + + files.push(obj); + } else { + + const folderName = remainder.slice(0, slashIndex + 1); + folders.add(prefix + folderName); + } + }); + + return { folders: Array.from(folders).sort(), files }; + }; + + const countObjectsInFolder = (folderPrefix) => { + const count = allObjects.filter(obj => obj.key.startsWith(folderPrefix)).length; + return { count, mayHaveMore: hasMoreObjects }; + }; + + const renderBreadcrumb = (prefix) => { + if (!folderBreadcrumb) return; + + if (!prefix && !hasFolders()) { + folderBreadcrumb.classList.add('d-none'); + return; + } + + folderBreadcrumb.classList.remove('d-none'); + const ol = folderBreadcrumb.querySelector('ol'); + ol.innerHTML = ''; + + const rootLi = document.createElement('li'); + rootLi.className = 'breadcrumb-item'; + if (!prefix) { + rootLi.classList.add('active'); + rootLi.setAttribute('aria-current', 'page'); + rootLi.innerHTML = ` + + + + Root + `; + } else { + rootLi.innerHTML = ` + + + + + Root + + `; + } + ol.appendChild(rootLi); + + if (prefix) { + const parts = prefix.split('/').filter(Boolean); + let accumulated = ''; + parts.forEach((part, index) => { + accumulated += part + '/'; + const li = document.createElement('li'); + li.className = 'breadcrumb-item'; + + if (index === parts.length - 1) { + li.classList.add('active'); + li.setAttribute('aria-current', 'page'); + li.textContent = part; + } else { + const a = document.createElement('a'); + a.href = '#'; + a.className = 'text-decoration-none'; + a.dataset.folderNav = accumulated; + a.textContent = part; + li.appendChild(a); + } + ol.appendChild(li); + }); + } + + ol.querySelectorAll('[data-folder-nav]').forEach(link => { + link.addEventListener('click', (e) => { + e.preventDefault(); + navigateToFolder(link.dataset.folderNav); + }); + }); + }; + + const getObjectsInFolder = (folderPrefix) => { + return allObjects.filter(obj => obj.key.startsWith(folderPrefix)); + }; + + const createFolderRow = (folderPath, displayName = null) => { + const folderName = displayName || folderPath.slice(currentPrefix.length).replace(/\/$/, ''); + const { count: objectCount, mayHaveMore } = countObjectsInFolder(folderPath); + const countDisplay = mayHaveMore ? `${objectCount}+` : objectCount; + + const tr = document.createElement('tr'); + tr.className = 'folder-row'; + tr.dataset.folderPath = folderPath; + tr.style.cursor = 'pointer'; + + tr.innerHTML = ` + + + + +
+ + + + ${escapeHtml(folderName)}/ +
+
${countDisplay} object${objectCount !== 1 ? 's' : ''}
+ + + — + + + + + `; + + return tr; + }; + + const navigateToFolder = (prefix) => { + currentPrefix = prefix; + + if (scrollContainer) scrollContainer.scrollTop = 0; + + refreshVirtualList(); + renderBreadcrumb(prefix); + + selectedRows.clear(); + + if (typeof updateBulkDeleteState === 'function') { + updateBulkDeleteState(); + } + + if (previewPanel) previewPanel.classList.add('d-none'); + if (previewEmpty) previewEmpty.classList.remove('d-none'); + activeRow = null; + }; + + const renderObjectsView = () => { + if (!objectsTableBody) return; + + const { folders, files } = getFoldersAtPrefix(currentPrefix); + + objectsTableBody.innerHTML = ''; + + folders.forEach(folderPath => { + objectsTableBody.appendChild(createFolderRow(folderPath)); + }); + + files.forEach(obj => { + objectsTableBody.appendChild(obj.element); + obj.element.style.display = ''; + + const keyCell = obj.element.querySelector('.object-key .fw-medium'); + if (keyCell && currentPrefix) { + const displayName = obj.key.slice(currentPrefix.length); + keyCell.textContent = displayName; + keyCell.closest('.object-key').title = obj.key; + } else if (keyCell) { + keyCell.textContent = obj.key; + } + }); + + allObjects.forEach(obj => { + if (!files.includes(obj)) { + obj.element.style.display = 'none'; + } + }); + + if (folders.length === 0 && files.length === 0) { + const emptyRow = document.createElement('tr'); + emptyRow.innerHTML = ` + +
+
+ + + +
+
Empty folder
+

This folder contains no objects.

+
+ + `; + objectsTableBody.appendChild(emptyRow); + } + + if (typeof updateBulkDeleteState === 'function') { + updateBulkDeleteState(); + } + }; + + const showMessage = ({ title = 'Notice', body = '', bodyHtml = null, variant = 'info', actionText = null, onAction = null }) => { + if (!actionText && !onAction && window.showToast) { + window.showToast(body || title, title, variant); + return; + } + if (!messageModal) { + window.alert(body || title); + return; + } + document.querySelectorAll('.modal.show').forEach(modal => { + const instance = bootstrap.Modal.getInstance(modal); + if (instance && modal.id !== 'messageModal') { + instance.hide(); + } + }); + const iconEl = document.getElementById('messageModalIcon'); + if (iconEl) { + const iconPaths = { + success: '', + danger: '', + warning: '', + info: '' + }; + const iconColors = { success: 'text-success', danger: 'text-danger', warning: 'text-warning', info: 'text-primary' }; + iconEl.innerHTML = iconPaths[variant] || iconPaths.info; + iconEl.classList.remove('text-success', 'text-danger', 'text-warning', 'text-primary'); + iconEl.classList.add(iconColors[variant] || 'text-primary'); + } + messageModalTitle.textContent = title; + if (bodyHtml) { + messageModalBody.innerHTML = bodyHtml; + } else { + messageModalBody.textContent = body; + } + messageModalActionHandler = null; + const variantClass = { + success: 'btn-success', + danger: 'btn-danger', + warning: 'btn-warning', + info: 'btn-primary', + }; + Object.values(variantClass).forEach((cls) => messageModalAction.classList.remove(cls)); + if (actionText && typeof onAction === 'function') { + messageModalAction.textContent = actionText; + messageModalAction.classList.remove('d-none'); + messageModalAction.classList.add(variantClass[variant] || 'btn-primary'); + messageModalActionHandler = onAction; + } else { + messageModalAction.classList.add('d-none'); + } + setTimeout(() => messageModal.show(), 150); + }; + + messageModalAction?.addEventListener('click', () => { + if (typeof messageModalActionHandler === 'function') { + messageModalActionHandler(); + } + messageModal?.hide(); + }); + + messageModalEl?.addEventListener('hidden.bs.modal', () => { + messageModalActionHandler = null; + messageModalAction.classList.add('d-none'); + }); + + const normalizePolicyTemplate = (rawTemplate) => { + if (!rawTemplate) { + return ''; + } + try { + let parsed = JSON.parse(rawTemplate); + if (typeof parsed === 'string') { + parsed = JSON.parse(parsed); + } + return JSON.stringify(parsed, null, 2); + } catch { + return rawTemplate; + } + }; + + let publicPolicyTemplate = normalizePolicyTemplate(policyTextarea?.dataset.publicTemplate || ''); + let customPolicyDraft = policyTextarea?.value || ''; + + const setPolicyTextareaState = (readonly) => { + if (!policyTextarea) return; + if (readonly) { + policyTextarea.setAttribute('readonly', 'readonly'); + policyTextarea.classList.add('bg-body-secondary'); + } else { + policyTextarea.removeAttribute('readonly'); + policyTextarea.classList.remove('bg-body-secondary'); + } + }; + + const policyReadonlyHint = document.getElementById('policyReadonlyHint'); + + const applyPolicyPreset = (preset) => { + if (!policyTextarea || !policyMode) return; + const isPresetMode = preset === 'private' || preset === 'public'; + if (policyReadonlyHint) { + policyReadonlyHint.classList.toggle('d-none', !isPresetMode); + } + switch (preset) { + case 'private': + setPolicyTextareaState(true); + policyTextarea.value = ''; + policyMode.value = 'delete'; + break; + case 'public': + setPolicyTextareaState(true); + policyTextarea.value = publicPolicyTemplate || ''; + policyMode.value = 'upsert'; + break; + default: + setPolicyTextareaState(false); + policyTextarea.value = customPolicyDraft; + policyMode.value = 'upsert'; + break; + } + }; + + policyTextarea?.addEventListener('input', () => { + if (policyPreset?.value === 'custom') { + customPolicyDraft = policyTextarea.value; + } + }); + + const presetButtons = document.querySelectorAll('.preset-btn[data-preset]'); + presetButtons.forEach(btn => { + btn.addEventListener('click', () => { + const preset = btn.dataset.preset; + if (policyPreset) policyPreset.value = preset; + presetButtons.forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + applyPolicyPreset(preset); + }); + }); + + if (policyPreset) { + applyPolicyPreset(policyPreset.value || policyPreset.dataset.default || 'custom'); + } + + policyForm?.addEventListener('submit', () => { + if (!policyMode || !policyPreset || !policyTextarea) { + return; + } + if (policyPreset.value === 'private') { + policyMode.value = 'delete'; + policyTextarea.value = ''; + } else if (policyPreset.value === 'public') { + policyMode.value = 'upsert'; + policyTextarea.value = publicPolicyTemplate || policyTextarea.value; + } else { + policyMode.value = 'upsert'; + } + }); + + const bulkActionsWrapper = document.getElementById('bulk-actions-wrapper'); + const updateBulkDeleteState = () => { + const selectedCount = selectedRows.size; + if (bulkDeleteButton) { + const shouldShow = Boolean(bulkDeleteEndpoint) && (selectedCount > 0 || bulkDeleting); + bulkDeleteButton.disabled = !bulkDeleteEndpoint || selectedCount === 0 || bulkDeleting; + if (bulkDeleteLabel) { + bulkDeleteLabel.textContent = selectedCount ? `Delete (${selectedCount})` : 'Delete'; + } + if (bulkActionsWrapper) { + bulkActionsWrapper.classList.toggle('d-none', !shouldShow); + } + } + if (bulkDeleteConfirm) { + bulkDeleteConfirm.disabled = selectedCount === 0 || bulkDeleting; + } + if (selectAllCheckbox) { + const filesInView = visibleItems.filter(item => item.type === 'file'); + const total = filesInView.length; + const visibleSelectedCount = filesInView.filter(item => selectedRows.has(item.data.key)).length; + selectAllCheckbox.disabled = total === 0; + selectAllCheckbox.checked = visibleSelectedCount > 0 && visibleSelectedCount === total && total > 0; + selectAllCheckbox.indeterminate = visibleSelectedCount > 0 && visibleSelectedCount < total; + } + }; + + function toggleRowSelection(row, shouldSelect) { + if (!row || !row.dataset.key) return; + if (shouldSelect) { + selectedRows.set(row.dataset.key, row); + } else { + selectedRows.delete(row.dataset.key); + } + updateBulkDeleteState(); + } + + const renderBulkDeletePreview = () => { + if (!bulkDeleteList) return; + const keys = Array.from(selectedRows.keys()); + bulkDeleteList.innerHTML = ''; + if (bulkDeleteCount) { + const label = keys.length === 1 ? 'object' : 'objects'; + bulkDeleteCount.textContent = `${keys.length} ${label} selected`; + } + if (!keys.length) { + const empty = document.createElement('li'); + empty.className = 'list-group-item py-2 small text-muted'; + empty.textContent = 'No objects selected.'; + bulkDeleteList.appendChild(empty); + if (bulkDeleteStatus) { + bulkDeleteStatus.textContent = ''; + } + return; + } + const preview = keys.slice(0, 6); + preview.forEach((key) => { + const item = document.createElement('li'); + item.className = 'list-group-item py-1 small text-break'; + item.textContent = key; + bulkDeleteList.appendChild(item); + }); + if (bulkDeleteStatus) { + bulkDeleteStatus.textContent = keys.length > preview.length ? `+${keys.length - preview.length} more not shown` : ''; + } + }; + + const openBulkDeleteModal = () => { + if (!bulkDeleteModal) { + return; + } + if (selectedRows.size === 0) { + showMessage({ title: 'Select objects', body: 'Choose at least one object to delete.', variant: 'warning' }); + return; + } + renderBulkDeletePreview(); + if (bulkDeletePurge) { + bulkDeletePurge.checked = false; + } + if (bulkDeleteConfirm) { + bulkDeleteConfirm.disabled = bulkDeleting; + bulkDeleteConfirm.textContent = bulkDeleting ? 'Deleting…' : 'Delete objects'; + } + bulkDeleteModal.show(); + }; + + const performBulkDelete = async () => { + if (!bulkDeleteEndpoint || selectedRows.size === 0 || !bulkDeleteConfirm) { + return; + } + bulkDeleting = true; + bulkDeleteConfirm.disabled = true; + bulkDeleteConfirm.textContent = 'Deleting…'; + updateBulkDeleteState(); + const payload = { + keys: Array.from(selectedRows.keys()), + }; + if (versioningEnabled && bulkDeletePurge?.checked) { + payload.purge_versions = true; + } + try { + const response = await fetch(bulkDeleteEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify(payload), + }); + let data = {}; + try { + data = await response.json(); + } catch { + data = {}; + } + if (!response.ok || data.error) { + throw new Error(data.error || data.message || 'Unable to delete selected objects'); + } + bulkDeleteModal?.hide(); + const deletedCount = Array.isArray(data.deleted) ? data.deleted.length : selectedRows.size; + const errorCount = Array.isArray(data.errors) ? data.errors.length : 0; + const messageParts = []; + if (deletedCount) { + messageParts.push(`${deletedCount} deleted`); + } + if (errorCount) { + messageParts.push(`${errorCount} failed`); + } + const summary = messageParts.length ? messageParts.join(', ') : 'Bulk delete finished'; + showMessage({ title: 'Bulk delete complete', body: data.message || summary, variant: errorCount ? 'warning' : 'success' }); + selectedRows.clear(); + previewEmpty.classList.remove('d-none'); + previewPanel.classList.add('d-none'); + activeRow = null; + loadObjects(false); + } catch (error) { + bulkDeleteModal?.hide(); + showMessage({ title: 'Delete failed', body: (error && error.message) || 'Unable to delete selected objects', variant: 'danger' }); + } finally { + bulkDeleting = false; + if (bulkDeleteConfirm) { + bulkDeleteConfirm.disabled = false; + bulkDeleteConfirm.textContent = 'Delete objects'; + } + updateBulkDeleteState(); + } + }; + + const updateGeneratePresignState = () => { + if (!generatePresignButton) return; + if (isGeneratingPresign) { + generatePresignButton.disabled = true; + generatePresignButton.textContent = 'Generating…'; + return; + } + generatePresignButton.textContent = 'Generate link'; + generatePresignButton.disabled = !activeRow; + }; + + const requestPresignedUrl = async () => { + if (!activeRow) { + showMessage({ title: 'Select an object', body: 'Choose an object before generating a presigned URL.', variant: 'warning' }); + return; + } + const endpoint = activeRow.dataset.presignEndpoint; + if (!endpoint) { + showMessage({ title: 'Unavailable', body: 'Presign endpoint unavailable for this object.', variant: 'danger' }); + return; + } + if (isGeneratingPresign) { + return; + } + isGeneratingPresign = true; + updateGeneratePresignState(); + presignLink.value = ''; + try { + const payload = { + method: presignMethod?.value || 'GET', + expires_in: Number(presignTtl?.value) || 900, + }; + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || 'Unable to generate presigned URL'); + } + presignLink.value = data.url; + } catch (error) { + presignModal?.hide(); + showMessage({ title: 'Presign failed', body: (error && error.message) || 'Unable to generate presigned URL', variant: 'danger' }); + } finally { + isGeneratingPresign = false; + updateGeneratePresignState(); + } + }; + + const renderMetadata = (metadata) => { + if (!previewMetadata || !previewMetadataList) return; + previewMetadataList.innerHTML = ''; + if (!metadata || Object.keys(metadata).length === 0) { + previewMetadata.classList.add('d-none'); + return; + } + previewMetadata.classList.remove('d-none'); + Object.entries(metadata).forEach(([key, value]) => { + const wrapper = document.createElement('div'); + wrapper.className = 'metadata-entry'; + const label = document.createElement('div'); + label.className = 'metadata-key small'; + label.textContent = key; + const val = document.createElement('div'); + val.className = 'metadata-value text-break'; + val.textContent = value; + wrapper.appendChild(label); + wrapper.appendChild(val); + previewMetadataList.appendChild(wrapper); + }); + }; + + const describeVersionReason = (reason) => { + switch (reason) { + case 'delete': + return 'delete marker'; + case 'restore-overwrite': + return 'restore overwrite'; + default: + return reason || 'update'; + } + }; + + const confirmVersionRestore = (row, version, label = null, onConfirm) => { + if (!version) return; + const timestamp = version.archived_at ? new Date(version.archived_at).toLocaleString() : version.version_id; + const sizeLabel = formatBytes(Number(version.size) || 0); + const reasonLabel = describeVersionReason(version.reason); + const targetLabel = label || row?.dataset.key || 'this object'; + const metadata = version.metadata && typeof version.metadata === 'object' ? Object.entries(version.metadata) : []; + const metadataHtml = metadata.length + ? `
Metadata

` + : ''; + const summaryHtml = ` +
+
Target: ${escapeHtml(targetLabel)}
+
Version ID: ${escapeHtml(version.version_id)}
+
Timestamp: ${escapeHtml(timestamp)}
+
Size: ${escapeHtml(sizeLabel)}
+
Reason: ${escapeHtml(reasonLabel)}
+
+ ${metadataHtml} + `; + const fallbackText = `Restore ${targetLabel} from ${timestamp}? Size ${sizeLabel}. Reason: ${reasonLabel}.`; + showMessage({ + title: 'Restore archived version?', + body: fallbackText, + bodyHtml: summaryHtml, + variant: 'warning', + actionText: 'Restore version', + onAction: () => { + if (typeof onConfirm === 'function') { + onConfirm(); + } else { + restoreVersion(row, version); + } + }, + }); + }; + + const updateArchivedCount = (count) => { + if (!archivedCountBadge) return; + const label = count === 1 ? 'item' : 'items'; + archivedCountBadge.textContent = `${count} ${label}`; + }; + + function renderArchivedRows(items) { + if (!archivedBody) return; + archivedBody.innerHTML = ''; + if (!items || items.length === 0) { + archivedBody.innerHTML = 'No archived-only objects.'; + updateArchivedCount(0); + return; + } + updateArchivedCount(items.length); + items.forEach((item) => { + const row = document.createElement('tr'); + + const keyCell = document.createElement('td'); + const keyLabel = document.createElement('div'); + keyLabel.className = 'fw-semibold text-break'; + keyLabel.textContent = item.key; + const badgeWrap = document.createElement('div'); + badgeWrap.className = 'mt-1'; + const badge = document.createElement('span'); + badge.className = 'badge text-bg-warning'; + badge.textContent = 'Archived'; + badgeWrap.appendChild(badge); + keyCell.appendChild(keyLabel); + keyCell.appendChild(badgeWrap); + + const latestCell = document.createElement('td'); + if (item.latest) { + const ts = item.latest.archived_at ? new Date(item.latest.archived_at).toLocaleString() : item.latest.version_id; + const sizeLabel = formatBytes(Number(item.latest.size) || 0); + latestCell.innerHTML = `
${ts}
${sizeLabel} · ${describeVersionReason(item.latest.reason)}
`; + } else { + latestCell.innerHTML = 'Unknown'; + } + + const countCell = document.createElement('td'); + countCell.className = 'text-end text-muted'; + countCell.textContent = item.versions; + + const actionsCell = document.createElement('td'); + actionsCell.className = 'text-end'; + const btnGroup = document.createElement('div'); + btnGroup.className = 'btn-group btn-group-sm'; + + const restoreButton = document.createElement('button'); + restoreButton.type = 'button'; + restoreButton.className = 'btn btn-outline-primary'; + restoreButton.textContent = 'Restore'; + restoreButton.disabled = !item.latest || !item.restore_url; + restoreButton.addEventListener('click', () => confirmVersionRestore(null, item.latest, item.key, () => restoreArchivedObject(item))); + + const purgeButton = document.createElement('button'); + purgeButton.type = 'button'; + purgeButton.className = 'btn btn-outline-danger'; + purgeButton.textContent = 'Delete versions'; + purgeButton.addEventListener('click', () => confirmArchivedPurge(item)); + + btnGroup.appendChild(restoreButton); + btnGroup.appendChild(purgeButton); + actionsCell.appendChild(btnGroup); + + row.appendChild(keyCell); + row.appendChild(latestCell); + row.appendChild(countCell); + row.appendChild(actionsCell); + archivedBody.appendChild(row); + }); + } + + async function restoreArchivedObject(item) { + if (!item?.restore_url) return; + try { + const response = await fetch(item.restore_url, { method: 'POST' }); + let data = {}; + try { + data = await response.json(); + } catch { + data = {}; + } + if (!response.ok) { + throw new Error(data.error || 'Unable to restore archived object'); + } + showMessage({ title: 'Restore scheduled', body: data.message || 'Object restored from archive.', variant: 'success' }); + await loadArchivedObjects(); + loadObjects(false); + } catch (error) { + showMessage({ title: 'Restore failed', body: (error && error.message) || 'Unable to restore archived object', variant: 'danger' }); + } + } + + async function purgeArchivedObject(item) { + if (!item?.purge_url) return; + try { + const response = await fetch(item.purge_url, { + method: 'POST', + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + }); + let data = {}; + try { + data = await response.json(); + } catch { + data = {}; + } + if (!response.ok) { + throw new Error(data.error || 'Unable to delete archived versions'); + } + showMessage({ title: 'Archived versions removed', body: data.message || 'All archived data for this key has been deleted.', variant: 'success' }); + await loadArchivedObjects(); + } catch (error) { + showMessage({ title: 'Delete failed', body: (error && error.message) || 'Unable to delete archived versions', variant: 'danger' }); + } + } + + function confirmArchivedPurge(item) { + const label = item?.key || 'this object'; + const count = item?.versions || 0; + const countLabel = count === 1 ? 'version' : 'versions'; + showMessage({ + title: 'Delete archived versions?', + body: `Permanently remove ${count} archived ${countLabel} for ${label}? This cannot be undone.`, + variant: 'danger', + actionText: 'Delete versions', + onAction: () => purgeArchivedObject(item), + }); + } + + async function loadArchivedObjects() { + if (!archivedEndpoint || !archivedBody) return; + archivedBody.innerHTML = 'Loading…'; + try { + const response = await fetch(archivedEndpoint); + let data = {}; + try { + data = await response.json(); + } catch { + data = {}; + } + if (!response.ok) { + throw new Error(data.error || 'Unable to load archived objects'); + } + const items = Array.isArray(data.objects) ? data.objects : []; + renderArchivedRows(items); + } catch (error) { + archivedBody.innerHTML = `${(error && error.message) || 'Unable to load archived objects'}`; + updateArchivedCount(0); + } + } + + if (archivedRefreshButton) { + archivedRefreshButton.addEventListener('click', () => loadArchivedObjects()); + } + if (archivedCard && archivedEndpoint) { + loadArchivedObjects(); + } + + async function restoreVersion(row, version) { + if (!row || !version?.version_id) return; + const template = row.dataset.restoreTemplate; + if (!template) return; + const url = template.replace('VERSION_ID_PLACEHOLDER', version.version_id); + try { + const response = await fetch(url, { method: 'POST' }); + let data = {}; + try { + data = await response.json(); + } catch { + data = {}; + } + if (!response.ok) { + throw new Error(data.error || 'Unable to restore version'); + } + const endpoint = row.dataset.versionsEndpoint; + if (endpoint) { + versionsCache.delete(endpoint); + } + await loadObjectVersions(row, { force: true }); + showMessage({ title: 'Version restored', body: data.message || 'The selected version has been restored.', variant: 'success' }); + loadObjects(false); + } catch (error) { + showMessage({ title: 'Restore failed', body: (error && error.message) || 'Unable to restore version', variant: 'danger' }); + } + } + + function renderVersionEntries(entries, row) { + if (!versionList) return; + if (!entries || entries.length === 0) { + versionList.innerHTML = '

No previous versions yet.

'; + return; + } + versionList.innerHTML = ''; + entries.forEach((entry, index) => { + const versionNumber = index + 1; + const item = document.createElement('div'); + item.className = 'd-flex align-items-center justify-content-between py-2 border-bottom'; + const textStack = document.createElement('div'); + textStack.className = 'me-3'; + const heading = document.createElement('div'); + heading.className = 'd-flex align-items-center'; + const badge = document.createElement('span'); + badge.className = 'badge text-bg-secondary me-2'; + badge.textContent = `#${versionNumber}`; + const title = document.createElement('div'); + title.className = 'fw-semibold small'; + const timestamp = entry.archived_at ? new Date(entry.archived_at).toLocaleString() : entry.version_id; + title.textContent = timestamp; + heading.appendChild(badge); + heading.appendChild(title); + const meta = document.createElement('div'); + meta.className = 'text-muted small'; + const reason = describeVersionReason(entry.reason); + const sizeLabel = formatBytes(Number(entry.size) || 0); + meta.textContent = `${sizeLabel} · ${reason}`; + textStack.appendChild(heading); + textStack.appendChild(meta); + const restoreButton = document.createElement('button'); + restoreButton.type = 'button'; + restoreButton.className = 'btn btn-outline-primary btn-sm'; + restoreButton.textContent = 'Restore'; + restoreButton.addEventListener('click', () => confirmVersionRestore(row, entry)); + item.appendChild(textStack); + item.appendChild(restoreButton); + versionList.appendChild(item); + }); + } + + async function loadObjectVersions(row, { force = false } = {}) { + if (!versionPanel || !versionList || !versioningEnabled) { + versionPanel?.classList.add('d-none'); + return; + } + if (!row) { + versionPanel.classList.add('d-none'); + return; + } + const endpoint = row.dataset.versionsEndpoint; + if (!endpoint) { + versionPanel.classList.add('d-none'); + return; + } + versionPanel.classList.remove('d-none'); + if (!force && versionsCache.has(endpoint)) { + renderVersionEntries(versionsCache.get(endpoint), row); + return; + } + versionList.innerHTML = '
Loading versions…
'; + try { + const response = await fetch(endpoint); + let data = {}; + try { + data = await response.json(); + } catch { + data = {}; + } + if (!response.ok) { + throw new Error(data.error || 'Unable to load versions'); + } + const entries = Array.isArray(data.versions) ? data.versions : []; + versionsCache.set(endpoint, entries); + renderVersionEntries(entries, row); + } catch (error) { + versionList.innerHTML = `

${(error && error.message) || 'Unable to load versions'}

`; + } + } + + renderMetadata(null); + const deleteModalEl = document.getElementById('deleteObjectModal'); + const deleteModal = deleteModalEl ? new bootstrap.Modal(deleteModalEl) : null; + const deleteObjectForm = document.getElementById('deleteObjectForm'); + const deleteObjectKey = document.getElementById('deleteObjectKey'); + + if (deleteObjectForm) { + deleteObjectForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const submitBtn = deleteObjectForm.querySelector('[type="submit"]'); + const originalHtml = submitBtn ? submitBtn.innerHTML : ''; + try { + if (submitBtn) { + submitBtn.disabled = true; + submitBtn.innerHTML = 'Deleting...'; + } + const formData = new FormData(deleteObjectForm); + const csrfToken = formData.get('csrf_token') || (window.getCsrfToken ? window.getCsrfToken() : ''); + const formAction = deleteObjectForm.getAttribute('action'); + const response = await fetch(formAction, { + method: 'POST', + headers: { + 'X-CSRFToken': csrfToken, + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + }, + body: formData + }); + const contentType = response.headers.get('content-type') || ''; + if (!contentType.includes('application/json')) { + throw new Error('Server returned an unexpected response. Please try again.'); + } + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || 'Unable to delete object'); + } + if (deleteModal) deleteModal.hide(); + showMessage({ title: 'Object deleted', body: data.message || 'The object has been deleted.', variant: 'success' }); + previewEmpty.classList.remove('d-none'); + previewPanel.classList.add('d-none'); + activeRow = null; + loadObjects(false); + } catch (err) { + if (deleteModal) deleteModal.hide(); + showMessage({ title: 'Delete failed', body: err.message || 'Unable to delete object', variant: 'danger' }); + } finally { + if (submitBtn) { + submitBtn.disabled = false; + submitBtn.innerHTML = originalHtml; + } + } + }); + } + + const resetPreviewMedia = () => { + [previewImage, previewVideo, previewIframe].forEach((el) => { + el.classList.add('d-none'); + if (el.tagName === 'VIDEO') { + el.pause(); + el.removeAttribute('src'); + } + if (el.tagName === 'IFRAME') { + el.setAttribute('src', 'about:blank'); + } + }); + previewPlaceholder.classList.remove('d-none'); + }; + + function metadataFromRow(row) { + if (!row || !row.dataset.metadata) { + return null; + } + try { + const parsed = JSON.parse(row.dataset.metadata); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed; + } + } catch (err) { + console.warn('Failed to parse metadata for row', err); + } + return null; + } + + function selectRow(row) { + document.querySelectorAll('[data-object-row]').forEach((r) => r.classList.remove('table-active')); + row.classList.add('table-active'); + previewEmpty.classList.add('d-none'); + previewPanel.classList.remove('d-none'); + activeRow = row; + renderMetadata(metadataFromRow(row)); + + previewKey.textContent = row.dataset.key; + previewSize.textContent = formatBytes(Number(row.dataset.size)); + previewModified.textContent = row.dataset.lastModified; + previewEtag.textContent = row.dataset.etag; + downloadButton.href = row.dataset.downloadUrl; + downloadButton.classList.remove('disabled'); + if (presignButton) { + presignButton.dataset.endpoint = row.dataset.presignEndpoint; + presignButton.disabled = false; + } + if (generatePresignButton) { + generatePresignButton.disabled = false; + } + updateGeneratePresignState(); + if (versioningEnabled) { + loadObjectVersions(row); + } + + resetPreviewMedia(); + const previewUrl = row.dataset.previewUrl; + const lower = row.dataset.key.toLowerCase(); + if (lower.match(/\.(png|jpg|jpeg|gif|webp|svg)$/)) { + previewImage.src = previewUrl; + previewImage.classList.remove('d-none'); + previewPlaceholder.classList.add('d-none'); + } else if (lower.match(/\.(mp4|webm|ogg)$/)) { + previewVideo.src = previewUrl; + previewVideo.classList.remove('d-none'); + previewPlaceholder.classList.add('d-none'); + } else if (lower.match(/\.(txt|log|json|md|csv)$/)) { + previewIframe.src = previewUrl; + previewIframe.classList.remove('d-none'); + previewPlaceholder.classList.add('d-none'); + } + } + + updateBulkDeleteState(); + + function initFolderNavigation() { + if (hasFolders()) { + renderBreadcrumb(currentPrefix); + renderObjectsView(); + } + if (typeof updateFolderViewStatus === 'function') { + updateFolderViewStatus(); + } + if (typeof updateFilterWarning === 'function') { + updateFilterWarning(); + } + } + + bulkDeleteButton?.addEventListener('click', () => openBulkDeleteModal()); + bulkDeleteConfirm?.addEventListener('click', () => performBulkDelete()); + + const filterWarning = document.getElementById('filter-warning'); + const filterWarningText = document.getElementById('filter-warning-text'); + const folderViewStatus = document.getElementById('folder-view-status'); + + const updateFilterWarning = () => { + if (!filterWarning) return; + const isFiltering = currentFilterTerm.length > 0; + if (isFiltering && hasMoreObjects) { + filterWarning.classList.remove('d-none'); + } else { + filterWarning.classList.add('d-none'); + } + }; + + document.getElementById('object-search')?.addEventListener('input', (event) => { + currentFilterTerm = event.target.value.toLowerCase(); + updateFilterWarning(); + refreshVirtualList(); + }); + + refreshVersionsButton?.addEventListener('click', () => { + if (!activeRow) { + versionList.innerHTML = '

Select an object to view versions.

'; + return; + } + const endpoint = activeRow.dataset.versionsEndpoint; + if (endpoint) { + versionsCache.delete(endpoint); + } + loadObjectVersions(activeRow, { force: true }); + }); + + presignButton?.addEventListener('click', () => { + if (!activeRow) { + showMessage({ title: 'Select an object', body: 'Choose an object before generating a presigned URL.', variant: 'warning' }); + return; + } + presignLink.value = ''; + presignModal?.show(); + requestPresignedUrl(); + }); + + generatePresignButton?.addEventListener('click', () => { + requestPresignedUrl(); + }); + + copyPresignLink?.addEventListener('click', async () => { + if (!presignLink?.value) { + return; + } + + const fallbackCopy = (text) => { + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.left = '-999999px'; + textArea.style.top = '-999999px'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + let success = false; + try { + success = document.execCommand('copy'); + } catch (err) { + success = false; + } + textArea.remove(); + return success; + }; + + let copied = false; + + if (navigator.clipboard && window.isSecureContext) { + try { + await navigator.clipboard.writeText(presignLink.value); + copied = true; + } catch (error) { + + } + } + + if (!copied) { + copied = fallbackCopy(presignLink.value); + } + + if (copied) { + copyPresignLink.textContent = 'Copied!'; + window.setTimeout(() => { + copyPresignLink.textContent = copyPresignDefaultLabel; + }, 1500); + } else { + showMessage({ title: 'Copy Failed', body: 'Unable to copy link to clipboard. Please select the link and copy manually.', variant: 'warning' }); + } + }); + + if (uploadForm && uploadFileInput) { + const uploadSubmitBtn = document.getElementById('uploadSubmitBtn'); + const uploadCancelBtn = document.getElementById('uploadCancelBtn'); + const uploadBtnText = document.getElementById('uploadBtnText'); + const bulkUploadProgress = document.getElementById('bulkUploadProgress'); + const bulkUploadStatus = document.getElementById('bulkUploadStatus'); + const bulkUploadCounter = document.getElementById('bulkUploadCounter'); + const bulkUploadProgressBar = document.getElementById('bulkUploadProgressBar'); + const bulkUploadCurrentFile = document.getElementById('bulkUploadCurrentFile'); + const bulkUploadResults = document.getElementById('bulkUploadResults'); + const bulkUploadSuccessAlert = document.getElementById('bulkUploadSuccessAlert'); + const bulkUploadErrorAlert = document.getElementById('bulkUploadErrorAlert'); + const bulkUploadSuccessCount = document.getElementById('bulkUploadSuccessCount'); + const bulkUploadErrorCount = document.getElementById('bulkUploadErrorCount'); + const bulkUploadErrorList = document.getElementById('bulkUploadErrorList'); + const uploadKeyPrefix = document.getElementById('uploadKeyPrefix'); + const singleFileOptions = document.getElementById('singleFileOptions'); + const floatingProgress = document.getElementById('floatingUploadProgress'); + const floatingProgressBar = document.getElementById('floatingUploadProgressBar'); + const floatingProgressStatus = document.getElementById('floatingUploadStatus'); + const floatingProgressTitle = document.getElementById('floatingUploadTitle'); + const floatingProgressExpand = document.getElementById('floatingUploadExpand'); + const floatingProgressCancel = document.getElementById('floatingUploadCancel'); + const uploadQueueContainer = document.getElementById('uploadQueueContainer'); + const uploadQueueList = document.getElementById('uploadQueueList'); + const uploadQueueCount = document.getElementById('uploadQueueCount'); + const clearUploadQueueBtn = document.getElementById('clearUploadQueueBtn'); + let isUploading = false; + let uploadQueue = []; + let activeXHRs = []; + let activeMultipartUpload = null; + let uploadCancelled = false; + let uploadStats = { + totalFiles: 0, + completedFiles: 0, + totalBytes: 0, + uploadedBytes: 0, + currentFileBytes: 0, + currentFileLoaded: 0, + currentFileName: '' + }; + + window.addEventListener('beforeunload', (e) => { + if (isUploading) { + e.preventDefault(); + e.returnValue = 'Upload in progress. Are you sure you want to leave?'; + return e.returnValue; + } + }); + + const showFloatingProgress = () => { + if (floatingProgress) { + floatingProgress.classList.remove('d-none'); + } + }; + + const hideFloatingProgress = () => { + if (floatingProgress) { + floatingProgress.classList.add('d-none'); + } + }; + + const updateFloatingProgress = () => { + const { totalFiles, completedFiles, totalBytes, uploadedBytes, currentFileLoaded, currentFileName } = uploadStats; + const effectiveUploaded = uploadedBytes + currentFileLoaded; + + if (floatingProgressBar && totalBytes > 0) { + const percent = Math.round((effectiveUploaded / totalBytes) * 100); + floatingProgressBar.style.width = `${percent}%`; + } + if (floatingProgressStatus) { + const bytesText = `${formatBytes(effectiveUploaded)} / ${formatBytes(totalBytes)}`; + const queuedCount = uploadQueue.length; + let statusText = `${completedFiles}/${totalFiles} files`; + if (queuedCount > 0) { + statusText += ` (+${queuedCount} queued)`; + } + statusText += ` • ${bytesText}`; + floatingProgressStatus.textContent = statusText; + } + if (floatingProgressTitle) { + const remaining = totalFiles - completedFiles; + const queuedCount = uploadQueue.length; + let title = `Uploading ${remaining} file${remaining !== 1 ? 's' : ''}`; + if (queuedCount > 0) { + title += ` (+${queuedCount} queued)`; + } + floatingProgressTitle.textContent = title + '...'; + } + }; + + floatingProgressExpand?.addEventListener('click', () => { + if (uploadModal) { + uploadModal.show(); + } + }); + + const cancelAllUploads = async () => { + uploadCancelled = true; + + activeXHRs.forEach(xhr => { + try { xhr.abort(); } catch {} + }); + activeXHRs = []; + + if (activeMultipartUpload) { + const { abortUrl } = activeMultipartUpload; + const csrfToken = document.querySelector('input[name="csrf_token"]')?.value; + try { + await fetch(abortUrl, { method: 'DELETE', headers: { 'X-CSRFToken': csrfToken || '' } }); + } catch {} + activeMultipartUpload = null; + } + + uploadQueue = []; + isProcessingQueue = false; + isUploading = false; + setUploadLockState(false); + hideFloatingProgress(); + resetUploadUI(); + + showMessage({ title: 'Upload cancelled', body: 'All uploads have been cancelled.', variant: 'info' }); + loadObjects(false); + }; + + floatingProgressCancel?.addEventListener('click', () => { + cancelAllUploads(); + }); + + const refreshUploadDropLabel = () => { + if (!uploadDropZoneLabel) return; + if (isUploading) { + uploadDropZoneLabel.textContent = 'Drop files here to add to queue'; + if (singleFileOptions) singleFileOptions.classList.add('d-none'); + return; + } + const files = uploadFileInput.files; + if (!files || files.length === 0) { + uploadDropZoneLabel.textContent = 'No file selected'; + if (singleFileOptions) singleFileOptions.classList.remove('d-none'); + return; + } + uploadDropZoneLabel.textContent = files.length === 1 ? files[0].name : `${files.length} files selected`; + + if (singleFileOptions) { + singleFileOptions.classList.toggle('d-none', files.length > 1); + } + }; + + const updateUploadBtnText = () => { + if (!uploadBtnText) return; + if (isUploading) { + const files = uploadFileInput.files; + if (files && files.length > 0) { + uploadBtnText.textContent = `Add ${files.length} to queue`; + if (uploadSubmitBtn) uploadSubmitBtn.disabled = false; + } else { + uploadBtnText.textContent = 'Uploading...'; + } + return; + } + const files = uploadFileInput.files; + if (!files || files.length <= 1) { + uploadBtnText.textContent = 'Upload'; + } else { + uploadBtnText.textContent = `Upload ${files.length} files`; + } + }; + + const resetUploadUI = () => { + if (bulkUploadProgress) bulkUploadProgress.classList.add('d-none'); + if (bulkUploadResults) bulkUploadResults.classList.add('d-none'); + if (bulkUploadSuccessAlert) bulkUploadSuccessAlert.classList.remove('d-none'); + if (bulkUploadErrorAlert) bulkUploadErrorAlert.classList.add('d-none'); + if (bulkUploadErrorList) bulkUploadErrorList.innerHTML = ''; + if (uploadSubmitBtn) uploadSubmitBtn.disabled = false; + if (uploadFileInput) uploadFileInput.disabled = false; + const progressStack = document.querySelector('[data-upload-progress]'); + if (progressStack) progressStack.innerHTML = ''; + if (uploadDropZone) { + uploadDropZone.classList.remove('upload-locked'); + uploadDropZone.style.pointerEvents = ''; + } + isUploading = false; + hideFloatingProgress(); + }; + + const MULTIPART_THRESHOLD = 8 * 1024 * 1024; + const CHUNK_SIZE = 8 * 1024 * 1024; + const uploadProgressStack = document.querySelector('[data-upload-progress]'); + const multipartInitUrl = uploadForm.dataset.multipartInitUrl; + const multipartPartTemplate = uploadForm.dataset.multipartPartTemplate; + const multipartCompleteTemplate = uploadForm.dataset.multipartCompleteTemplate; + const multipartAbortTemplate = uploadForm.dataset.multipartAbortTemplate; + + const createProgressItem = (file) => { + const item = document.createElement('div'); + item.className = 'upload-progress-item'; + item.dataset.state = 'uploading'; + item.innerHTML = ` +
+
+
${escapeHtml(file.name)}
+
${formatBytes(file.size)}
+
+
Preparing...
+
+
+
+
+
+
+ 0 B + 0% +
+
+ `; + return item; + }; + + const updateProgressItem = (item, { loaded, total, status, state, error }) => { + if (state) item.dataset.state = state; + const statusEl = item.querySelector('.upload-status'); + const progressBar = item.querySelector('.progress-bar'); + const progressLoaded = item.querySelector('.progress-loaded'); + const progressPercent = item.querySelector('.progress-percent'); + + if (status) { + statusEl.textContent = status; + statusEl.className = 'upload-status text-end ms-2'; + if (state === 'success') statusEl.classList.add('success'); + if (state === 'error') statusEl.classList.add('error'); + } + if (typeof loaded === 'number' && typeof total === 'number' && total > 0) { + const percent = Math.round((loaded / total) * 100); + progressBar.style.width = `${percent}%`; + progressLoaded.textContent = `${formatBytes(loaded)} / ${formatBytes(total)}`; + progressPercent.textContent = `${percent}%`; + } + if (error) { + const progressContainer = item.querySelector('.progress-container'); + if (progressContainer) { + progressContainer.innerHTML = `
${escapeHtml(error)}
`; + } + } + }; + + const uploadMultipart = async (file, objectKey, metadata, progressItem) => { + const csrfToken = document.querySelector('input[name="csrf_token"]')?.value; + + if (uploadCancelled) throw new Error('Upload cancelled'); + + updateProgressItem(progressItem, { status: 'Initiating...', loaded: 0, total: file.size }); + const initResp = await fetch(multipartInitUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken || '' }, + body: JSON.stringify({ object_key: objectKey, metadata }) + }); + if (!initResp.ok) { + const err = await initResp.json().catch(() => ({})); + throw new Error(err.error || 'Failed to initiate upload'); + } + const { upload_id } = await initResp.json(); + + const partUrl = multipartPartTemplate.replace('UPLOAD_ID_PLACEHOLDER', upload_id); + const completeUrl = multipartCompleteTemplate.replace('UPLOAD_ID_PLACEHOLDER', upload_id); + const abortUrl = multipartAbortTemplate.replace('UPLOAD_ID_PLACEHOLDER', upload_id); + + activeMultipartUpload = { upload_id, abortUrl }; + + const parts = []; + const totalParts = Math.ceil(file.size / CHUNK_SIZE); + let uploadedBytes = 0; + + try { + for (let partNumber = 1; partNumber <= totalParts; partNumber++) { + if (uploadCancelled) throw new Error('Upload cancelled'); + + const start = (partNumber - 1) * CHUNK_SIZE; + const end = Math.min(start + CHUNK_SIZE, file.size); + const chunk = file.slice(start, end); + + updateProgressItem(progressItem, { + status: `Part ${partNumber}/${totalParts}`, + loaded: uploadedBytes, + total: file.size + }); + uploadStats.currentFileLoaded = uploadedBytes; + updateFloatingProgress(); + + const partResp = await fetch(`${partUrl}?partNumber=${partNumber}`, { + method: 'PUT', + headers: { + 'X-CSRFToken': csrfToken || '', + 'Content-Type': 'application/octet-stream' + }, + body: chunk + }); + + if (uploadCancelled) throw new Error('Upload cancelled'); + + if (!partResp.ok) { + const err = await partResp.json().catch(() => ({})); + throw new Error(err.error || `Part ${partNumber} failed`); + } + + const partData = await partResp.json(); + parts.push({ part_number: partNumber, etag: partData.etag }); + uploadedBytes += chunk.size; + + updateProgressItem(progressItem, { + loaded: uploadedBytes, + total: file.size + }); + uploadStats.currentFileLoaded = uploadedBytes; + updateFloatingProgress(); + } + + updateProgressItem(progressItem, { status: 'Completing...', loaded: file.size, total: file.size }); + const completeResp = await fetch(completeUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken || '' }, + body: JSON.stringify({ parts }) + }); + + if (!completeResp.ok) { + const err = await completeResp.json().catch(() => ({})); + throw new Error(err.error || 'Failed to complete upload'); + } + + activeMultipartUpload = null; + return await completeResp.json(); + } catch (err) { + if (!uploadCancelled) { + try { + await fetch(abortUrl, { method: 'DELETE', headers: { 'X-CSRFToken': csrfToken || '' } }); + } catch {} + } + activeMultipartUpload = null; + throw err; + } + }; + + const uploadRegular = async (file, objectKey, metadata, progressItem) => { + return new Promise((resolve, reject) => { + const formData = new FormData(); + formData.append('object', file); + formData.append('object_key', objectKey); + if (metadata) formData.append('metadata', JSON.stringify(metadata)); + const csrfToken = document.querySelector('input[name="csrf_token"]')?.value; + if (csrfToken) formData.append('csrf_token', csrfToken); + + const xhr = new XMLHttpRequest(); + activeXHRs.push(xhr); + xhr.open('POST', uploadForm.action, true); + xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); + + const removeXHR = () => { + const idx = activeXHRs.indexOf(xhr); + if (idx > -1) activeXHRs.splice(idx, 1); + }; + + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable) { + updateProgressItem(progressItem, { + status: 'Uploading...', + loaded: e.loaded, + total: e.total + }); + uploadStats.currentFileLoaded = e.loaded; + updateFloatingProgress(); + } + }); + + xhr.addEventListener('load', () => { + removeXHR(); + if (xhr.status >= 200 && xhr.status < 300) { + try { + const data = JSON.parse(xhr.responseText); + if (data.status === 'error') { + reject(new Error(data.message || 'Upload failed')); + } else { + resolve(data); + } + } catch { + resolve({}); + } + } else { + try { + const data = JSON.parse(xhr.responseText); + reject(new Error(data.message || `Upload failed (${xhr.status})`)); + } catch { + reject(new Error(`Upload failed (${xhr.status})`)); + } + } + }); + + xhr.addEventListener('error', () => { removeXHR(); reject(new Error('Network error')); }); + xhr.addEventListener('abort', () => { removeXHR(); reject(new Error('Upload cancelled')); }); + + xhr.send(formData); + }); + }; + + const uploadSingleFile = async (file, keyPrefix = '', metadata = null, progressItem = null) => { + const objectKey = keyPrefix ? `${keyPrefix}${file.name}` : file.name; + const shouldUseMultipart = file.size >= MULTIPART_THRESHOLD && multipartInitUrl; + + if (!progressItem && uploadProgressStack) { + progressItem = createProgressItem(file); + uploadProgressStack.appendChild(progressItem); + } + + try { + let result; + if (shouldUseMultipart) { + updateProgressItem(progressItem, { status: 'Multipart upload...', loaded: 0, total: file.size }); + result = await uploadMultipart(file, objectKey, metadata, progressItem); + } else { + updateProgressItem(progressItem, { status: 'Uploading...', loaded: 0, total: file.size }); + result = await uploadRegular(file, objectKey, metadata, progressItem); + } + updateProgressItem(progressItem, { state: 'success', status: 'Complete', loaded: file.size, total: file.size }); + return result; + } catch (err) { + updateProgressItem(progressItem, { state: 'error', status: 'Failed', error: err.message }); + throw err; + } + }; + + const setUploadLockState = (locked) => { + if (uploadDropZone) { + uploadDropZone.classList.toggle('upload-locked', locked); + } + }; + + let uploadSuccessFiles = []; + let uploadErrorFiles = []; + let isProcessingQueue = false; + + const updateQueueListDisplay = () => { + if (!uploadQueueList || !uploadQueueContainer || !uploadQueueCount) return; + if (uploadQueue.length === 0) { + uploadQueueContainer.classList.add('d-none'); + return; + } + uploadQueueContainer.classList.remove('d-none'); + uploadQueueCount.textContent = uploadQueue.length; + uploadQueueList.innerHTML = uploadQueue.map((item, idx) => ` +
  • + + + + + ${escapeHtml(item.file.name)} + + ${formatBytes(item.file.size)} +
  • + `).join(''); + }; + + const addFilesToQueue = (files, keyPrefix, metadata) => { + for (const file of files) { + uploadQueue.push({ file, keyPrefix, metadata }); + uploadStats.totalFiles++; + uploadStats.totalBytes += file.size; + } + updateFloatingProgress(); + updateQueueListDisplay(); + }; + + const clearUploadQueue = () => { + const clearedCount = uploadQueue.length; + if (clearedCount === 0) return; + for (const item of uploadQueue) { + uploadStats.totalFiles--; + uploadStats.totalBytes -= item.file.size; + } + uploadQueue.length = 0; + updateFloatingProgress(); + updateQueueListDisplay(); + }; + + if (clearUploadQueueBtn) { + clearUploadQueueBtn.addEventListener('click', clearUploadQueue); + } + + const processUploadQueue = async () => { + if (isProcessingQueue) return; + isProcessingQueue = true; + + while (uploadQueue.length > 0 && !uploadCancelled) { + const item = uploadQueue.shift(); + const { file, keyPrefix, metadata } = item; + updateQueueListDisplay(); + + uploadStats.currentFileName = file.name; + uploadStats.currentFileBytes = file.size; + uploadStats.currentFileLoaded = 0; + + if (bulkUploadCounter) { + const queuedCount = uploadQueue.length; + let counterText = `${uploadStats.completedFiles + 1}/${uploadStats.totalFiles}`; + if (queuedCount > 0) { + counterText += ` (+${queuedCount} queued)`; + } + bulkUploadCounter.textContent = counterText; + } + if (bulkUploadCurrentFile) { + bulkUploadCurrentFile.textContent = `Uploading: ${file.name}`; + } + if (bulkUploadProgressBar) { + const percent = Math.round(((uploadStats.completedFiles + 1) / uploadStats.totalFiles) * 100); + bulkUploadProgressBar.style.width = `${percent}%`; + } + updateFloatingProgress(); + + try { + await uploadSingleFile(file, keyPrefix, metadata); + uploadSuccessFiles.push(file.name); + } catch (error) { + uploadErrorFiles.push({ name: file.name, error: error.message || 'Unknown error' }); + } + + uploadStats.uploadedBytes += file.size; + uploadStats.completedFiles++; + uploadStats.currentFileLoaded = 0; + updateFloatingProgress(); + } + + isProcessingQueue = false; + + if (uploadQueue.length === 0 && !uploadCancelled) { + finishUploadSession(); + } + }; + + const finishUploadSession = () => { + if (bulkUploadProgress) bulkUploadProgress.classList.add('d-none'); + if (bulkUploadResults) bulkUploadResults.classList.remove('d-none'); + hideFloatingProgress(); + + if (bulkUploadSuccessCount) bulkUploadSuccessCount.textContent = uploadSuccessFiles.length; + if (uploadSuccessFiles.length === 0 && bulkUploadSuccessAlert) { + bulkUploadSuccessAlert.classList.add('d-none'); + } + + if (uploadErrorFiles.length > 0) { + if (bulkUploadErrorCount) bulkUploadErrorCount.textContent = uploadErrorFiles.length; + if (bulkUploadErrorAlert) bulkUploadErrorAlert.classList.remove('d-none'); + if (bulkUploadErrorList) { + bulkUploadErrorList.innerHTML = uploadErrorFiles + .map(f => `
  • ${escapeHtml(f.name)}: ${escapeHtml(f.error)}
  • `) + .join(''); + } + } + + isUploading = false; + setUploadLockState(false); + refreshUploadDropLabel(); + updateUploadBtnText(); + updateQueueListDisplay(); + + if (uploadSubmitBtn) uploadSubmitBtn.disabled = false; + if (uploadFileInput) { + uploadFileInput.disabled = false; + uploadFileInput.value = ''; + } + + loadObjects(false); + + const successCount = uploadSuccessFiles.length; + const errorCount = uploadErrorFiles.length; + if (successCount > 0 && errorCount > 0) { + showMessage({ title: 'Upload complete', body: `${successCount} uploaded, ${errorCount} failed.`, variant: 'warning' }); + } else if (successCount > 0) { + showMessage({ title: 'Upload complete', body: `${successCount} object(s) uploaded successfully.`, variant: 'success' }); + } else if (errorCount > 0) { + showMessage({ title: 'Upload failed', body: `${errorCount} file(s) failed to upload.`, variant: 'danger' }); + } + }; + + const performBulkUpload = async (files) => { + if (!files || files.length === 0) return; + + const keyPrefix = (uploadKeyPrefix?.value || '').trim(); + const metadataRaw = uploadForm.querySelector('textarea[name="metadata"]')?.value?.trim(); + let metadata = null; + if (metadataRaw) { + try { + metadata = JSON.parse(metadataRaw); + } catch { + showMessage({ title: 'Invalid metadata', body: 'Metadata must be valid JSON.', variant: 'danger' }); + return; + } + } + + if (!isUploading) { + isUploading = true; + uploadCancelled = false; + uploadSuccessFiles = []; + uploadErrorFiles = []; + uploadStats = { + totalFiles: 0, + completedFiles: 0, + totalBytes: 0, + uploadedBytes: 0, + currentFileBytes: 0, + currentFileLoaded: 0, + currentFileName: '' + }; + + if (bulkUploadProgress) bulkUploadProgress.classList.remove('d-none'); + if (bulkUploadResults) bulkUploadResults.classList.add('d-none'); + if (uploadSubmitBtn) uploadSubmitBtn.disabled = true; + refreshUploadDropLabel(); + updateUploadBtnText(); + + if (uploadModal) uploadModal.hide(); + showFloatingProgress(); + } + + const fileCount = files.length; + addFilesToQueue(Array.from(files), keyPrefix, metadata); + + if (uploadFileInput) { + uploadFileInput.value = ''; + } + refreshUploadDropLabel(); + updateUploadBtnText(); + + processUploadQueue(); + }; + + refreshUploadDropLabel(); + uploadFileInput.addEventListener('change', () => { + refreshUploadDropLabel(); + updateUploadBtnText(); + if (!isUploading) { + resetUploadUI(); + } + }); + uploadDropZone?.addEventListener('click', () => { + uploadFileInput?.click(); + }); + + uploadForm.addEventListener('submit', async (event) => { + const files = uploadFileInput.files; + if (!files || files.length === 0) return; + + const keyPrefix = (uploadKeyPrefix?.value || '').trim(); + + if (files.length === 1 && !keyPrefix) { + const customKey = uploadForm.querySelector('input[name="object_key"]')?.value?.trim(); + if (customKey) { + + if (uploadSubmitBtn) { + uploadSubmitBtn.disabled = true; + if (uploadBtnText) uploadBtnText.textContent = 'Uploading...'; + } + return; + } + } + + event.preventDefault(); + + if (uploadSubmitBtn) { + uploadSubmitBtn.disabled = true; + if (uploadBtnText) uploadBtnText.textContent = 'Uploading...'; + } + + await performBulkUpload(Array.from(files)); + }); + + uploadModalEl?.addEventListener('show.bs.modal', () => { + if (hasFolders() && currentPrefix) { + uploadKeyPrefix.value = currentPrefix; + + const advancedToggle = document.querySelector('[data-bs-target="#advancedUploadOptions"]'); + const advancedCollapse = document.getElementById('advancedUploadOptions'); + if (advancedToggle && advancedCollapse && !advancedCollapse.classList.contains('show')) { + new bootstrap.Collapse(advancedCollapse, { show: true }); + } + } else if (uploadKeyPrefix) { + + uploadKeyPrefix.value = ''; + } + }); + + uploadModalEl?.addEventListener('hide.bs.modal', (event) => { + if (isUploading) { + showFloatingProgress(); + } + }); + + uploadModalEl?.addEventListener('hidden.bs.modal', () => { + if (!isUploading) { + resetUploadUI(); + uploadFileInput.value = ''; + refreshUploadDropLabel(); + updateUploadBtnText(); + } + }); + + uploadModalEl?.addEventListener('show.bs.modal', () => { + if (isUploading) { + hideFloatingProgress(); + } + }); + + const preventDefaults = (event) => { + event.preventDefault(); + event.stopPropagation(); + }; + + const wireDropTarget = (target, { highlightClass = '', autoOpenModal = false } = {}) => { + if (!target) return; + ['dragenter', 'dragover'].forEach((eventName) => { + target.addEventListener(eventName, (event) => { + preventDefaults(event); + if (highlightClass) { + target.classList.add(highlightClass); + } + }); + }); + ['dragleave', 'drop'].forEach((eventName) => { + target.addEventListener(eventName, (event) => { + preventDefaults(event); + if (highlightClass) { + target.classList.remove(highlightClass); + } + }); + }); + target.addEventListener('drop', (event) => { + if (!event.dataTransfer?.files?.length) { + return; + } + if (isUploading) { + performBulkUpload(event.dataTransfer.files); + } else { + if (uploadFileInput) { + uploadFileInput.files = event.dataTransfer.files; + uploadFileInput.dispatchEvent(new Event('change', { bubbles: true })); + } + if (autoOpenModal && uploadModal) { + uploadModal.show(); + } + } + }); + }; + + if (uploadDropZone) { + wireDropTarget(uploadDropZone, { highlightClass: 'is-dragover' }); + } + + if (objectsContainer) { + wireDropTarget(objectsContainer, { highlightClass: 'drag-over', autoOpenModal: true }); + } + } + + const bulkDownloadButton = document.querySelector('[data-bulk-download-trigger]'); + const bulkDownloadEndpoint = document.getElementById('objects-drop-zone')?.dataset.bulkDownloadEndpoint; + + const updateBulkDownloadState = () => { + if (!bulkDownloadButton) return; + const selectedCount = document.querySelectorAll('[data-object-select]:checked').length; + bulkDownloadButton.disabled = selectedCount === 0; + }; + + selectAllCheckbox?.addEventListener('change', (event) => { + const shouldSelect = Boolean(event.target?.checked); + + const filesInView = visibleItems.filter(item => item.type === 'file'); + + filesInView.forEach(item => { + if (shouldSelect) { + selectedRows.set(item.data.key, item.data); + } else { + selectedRows.delete(item.data.key); + } + }); + + document.querySelectorAll('[data-folder-select]').forEach(cb => { + cb.checked = shouldSelect; + }); + + document.querySelectorAll('[data-object-row]').forEach((row) => { + const checkbox = row.querySelector('[data-object-select]'); + if (checkbox) { + checkbox.checked = shouldSelect; + } + }); + + updateBulkDeleteState(); + setTimeout(updateBulkDownloadState, 0); + }); + + bulkDownloadButton?.addEventListener('click', async () => { + if (!bulkDownloadEndpoint) return; + const selected = Array.from(selectedRows.keys()); + if (selected.length === 0) return; + + bulkDownloadButton.disabled = true; + const originalHtml = bulkDownloadButton.innerHTML; + bulkDownloadButton.innerHTML = ' Downloading...'; + + try { + const response = await fetch(bulkDownloadEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': window.getCsrfToken ? window.getCsrfToken() : '', + }, + body: JSON.stringify({ keys: selected }), + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.error || 'Download failed'); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${document.getElementById('objects-drop-zone').dataset.bucket}-download.zip`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + a.remove(); + } catch (error) { + showMessage({ title: 'Download Failed', body: error.message, variant: 'danger' }); + } finally { + bulkDownloadButton.disabled = false; + bulkDownloadButton.innerHTML = originalHtml; + } + }); + + const replicationStatsContainer = document.getElementById('replication-stats-cards'); + if (replicationStatsContainer) { + const statusEndpoint = replicationStatsContainer.dataset.statusEndpoint; + const syncedEl = replicationStatsContainer.querySelector('[data-stat="synced"]'); + const pendingEl = replicationStatsContainer.querySelector('[data-stat="pending"]'); + const orphanedEl = replicationStatsContainer.querySelector('[data-stat="orphaned"]'); + const bytesEl = replicationStatsContainer.querySelector('[data-stat="bytes"]'); + const lastSyncEl = document.getElementById('replication-last-sync'); + const lastSyncTimeEl = document.querySelector('[data-stat="last-sync-time"]'); + const lastSyncKeyEl = document.querySelector('[data-stat="last-sync-key"]'); + const endpointWarning = document.getElementById('replication-endpoint-warning'); + const endpointErrorEl = document.getElementById('replication-endpoint-error'); + const statusAlert = document.getElementById('replication-status-alert'); + const statusBadge = document.getElementById('replication-status-badge'); + const statusText = document.getElementById('replication-status-text'); + const pauseForm = document.getElementById('pause-replication-form'); + + const loadReplicationStats = async () => { + try { + const resp = await fetch(statusEndpoint); + if (!resp.ok) throw new Error('Failed to fetch stats'); + const data = await resp.json(); + + // Handle endpoint health status + if (data.endpoint_healthy === false) { + // Show warning and hide success alert + if (endpointWarning) { + endpointWarning.classList.remove('d-none'); + if (endpointErrorEl && data.endpoint_error) { + endpointErrorEl.textContent = data.endpoint_error + '. Replication is paused until the endpoint is available.'; + } + } + if (statusAlert) statusAlert.classList.add('d-none'); + + // Update status badge to show "Paused" with warning styling + if (statusBadge) { + statusBadge.className = 'badge bg-warning-subtle text-warning px-3 py-2'; + statusBadge.innerHTML = ` + + + + Paused (Endpoint Unavailable)`; + } + + // Hide the pause button since replication is effectively already paused + if (pauseForm) pauseForm.classList.add('d-none'); + } else { + // Hide warning and show success alert + if (endpointWarning) endpointWarning.classList.add('d-none'); + if (statusAlert) statusAlert.classList.remove('d-none'); + + // Restore status badge to show "Enabled" + if (statusBadge) { + statusBadge.className = 'badge bg-success-subtle text-success px-3 py-2'; + statusBadge.innerHTML = ` + + + + Enabled`; + } + + // Show the pause button + if (pauseForm) pauseForm.classList.remove('d-none'); + } + + if (syncedEl) syncedEl.textContent = data.objects_synced; + if (pendingEl) { + pendingEl.textContent = data.objects_pending; + if (data.objects_pending > 0) pendingEl.classList.add('text-warning'); + } + if (orphanedEl) orphanedEl.textContent = data.objects_orphaned; + if (bytesEl) bytesEl.textContent = formatBytes(data.bytes_synced); + + if (data.last_sync_at && lastSyncEl) { + lastSyncEl.style.display = ''; + const date = new Date(data.last_sync_at * 1000); + if (lastSyncTimeEl) lastSyncTimeEl.textContent = date.toLocaleString(); + if (lastSyncKeyEl && data.last_sync_key) { + lastSyncKeyEl.innerHTML = ' — ' + escapeHtml(data.last_sync_key) + ''; + } + } + } catch (err) { + console.error('Failed to load replication stats:', err); + if (syncedEl) syncedEl.textContent = '—'; + if (pendingEl) pendingEl.textContent = '—'; + if (orphanedEl) orphanedEl.textContent = '—'; + if (bytesEl) bytesEl.textContent = '—'; + } + }; + + loadReplicationStats(); + + if (window.pollingManager) { + window.pollingManager.start('replication', loadReplicationStats); + } + + const refreshBtn = document.querySelector('[data-refresh-replication]'); + refreshBtn?.addEventListener('click', () => { + + if (syncedEl) syncedEl.innerHTML = ''; + if (pendingEl) pendingEl.innerHTML = ''; + if (orphanedEl) orphanedEl.innerHTML = ''; + if (bytesEl) bytesEl.innerHTML = ''; + loadReplicationStats(); + loadReplicationFailures(); + }); + + const failuresCard = document.getElementById('replication-failures-card'); + const failuresBody = document.getElementById('replication-failures-body'); + const failureCountBadge = document.getElementById('replication-failure-count'); + const retryAllBtn = document.getElementById('retry-all-failures-btn'); + const clearFailuresBtn = document.getElementById('clear-failures-btn'); + const showMoreFailuresBtn = document.getElementById('show-more-failures'); + const failuresPagination = document.getElementById('replication-failures-pagination'); + const failuresShownCount = document.getElementById('failures-shown-count'); + const clearFailuresModal = document.getElementById('clearFailuresModal'); + const confirmClearFailuresBtn = document.getElementById('confirmClearFailuresBtn'); + const clearFailuresModalInstance = clearFailuresModal ? new bootstrap.Modal(clearFailuresModal) : null; + + let failuresExpanded = false; + let currentFailures = []; + + const loadReplicationFailures = async () => { + if (!failuresCard) return; + + const endpoint = failuresCard.dataset.failuresEndpoint; + const limit = failuresExpanded ? 50 : 5; + + try { + const resp = await fetch(`${endpoint}?limit=${limit}`); + if (!resp.ok) throw new Error('Failed to fetch failures'); + const data = await resp.json(); + + currentFailures = data.failures; + const total = data.total; + + if (total > 0) { + failuresCard.style.display = ''; + failureCountBadge.textContent = total; + renderFailures(currentFailures); + + if (total > 5 && !failuresExpanded) { + failuresPagination.style.display = ''; + failuresShownCount.textContent = `Showing ${Math.min(5, total)} of ${total}`; + } else { + failuresPagination.style.display = 'none'; + } + } else { + failuresCard.style.display = 'none'; + } + } catch (err) { + console.error('Failed to load replication failures:', err); + } + }; + + const renderFailures = (failures) => { + if (!failuresBody) return; + failuresBody.innerHTML = failures.map(f => ` + + + ${escapeHtml(f.object_key)} + + + ${escapeHtml(f.error_message)} + + ${new Date(f.timestamp * 1000).toLocaleString()} + ${f.failure_count} + + + + + + `).join(''); + }; + + window.retryFailure = async (btn, objectKey) => { + const originalHtml = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = ''; + const endpoint = failuresCard.dataset.retryEndpoint.replace('__KEY__', encodeURIComponent(objectKey)); + try { + const resp = await fetch(endpoint, { method: 'POST' }); + if (resp.ok) { + loadReplicationFailures(); + } + } catch (err) { + console.error('Failed to retry:', err); + btn.disabled = false; + btn.innerHTML = originalHtml; + } + }; + + window.dismissFailure = async (btn, objectKey) => { + const originalHtml = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = ''; + const endpoint = failuresCard.dataset.dismissEndpoint.replace('__KEY__', encodeURIComponent(objectKey)); + try { + const resp = await fetch(endpoint, { method: 'DELETE' }); + if (resp.ok) { + loadReplicationFailures(); + } + } catch (err) { + console.error('Failed to dismiss:', err); + btn.disabled = false; + btn.innerHTML = originalHtml; + } + }; + + retryAllBtn?.addEventListener('click', async () => { + const btn = retryAllBtn; + const originalHtml = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = 'Retrying...'; + const endpoint = failuresCard.dataset.retryAllEndpoint; + try { + const resp = await fetch(endpoint, { method: 'POST' }); + if (resp.ok) { + loadReplicationFailures(); + } + } catch (err) { + console.error('Failed to retry all:', err); + } finally { + btn.disabled = false; + btn.innerHTML = originalHtml; + } + }); + + clearFailuresBtn?.addEventListener('click', () => { + clearFailuresModalInstance?.show(); + }); + + confirmClearFailuresBtn?.addEventListener('click', async () => { + const btn = confirmClearFailuresBtn; + const originalHtml = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = 'Clearing...'; + const endpoint = failuresCard.dataset.clearEndpoint; + try { + const resp = await fetch(endpoint, { method: 'DELETE' }); + if (resp.ok) { + clearFailuresModalInstance?.hide(); + loadReplicationFailures(); + } + } catch (err) { + console.error('Failed to clear failures:', err); + } finally { + btn.disabled = false; + btn.innerHTML = originalHtml; + } + }); + + showMoreFailuresBtn?.addEventListener('click', () => { + failuresExpanded = !failuresExpanded; + showMoreFailuresBtn.textContent = failuresExpanded ? 'Show less' : 'Show more...'; + loadReplicationFailures(); + }); + + loadReplicationFailures(); + } + + const algoAes256Radio = document.getElementById('algo_aes256'); + const algoKmsRadio = document.getElementById('algo_kms'); + const kmsKeySection = document.getElementById('kmsKeySection'); + const encryptionForm = document.getElementById('encryptionForm'); + const encryptionAction = document.getElementById('encryptionAction'); + const disableEncryptionBtn = document.getElementById('disableEncryptionBtn'); + + const updateKmsKeyVisibility = () => { + if (!kmsKeySection) return; + const showKms = algoKmsRadio?.checked; + kmsKeySection.style.display = showKms ? '' : 'none'; + }; + + algoAes256Radio?.addEventListener('change', updateKmsKeyVisibility); + algoKmsRadio?.addEventListener('change', updateKmsKeyVisibility); + + disableEncryptionBtn?.addEventListener('click', () => { + if (encryptionAction && encryptionForm) { + if (confirm('Are you sure you want to disable default encryption? New objects will not be encrypted automatically.')) { + encryptionAction.value = 'disable'; + encryptionForm.submit(); + } + } + }); + + const targetBucketInput = document.getElementById('target_bucket'); + const targetBucketFeedback = document.getElementById('target_bucket_feedback'); + + const validateBucketName = (name) => { + if (!name) return { valid: false, error: 'Bucket name is required' }; + if (name.length < 3) return { valid: false, error: 'Bucket name must be at least 3 characters' }; + if (name.length > 63) return { valid: false, error: 'Bucket name must be 63 characters or less' }; + if (!/^[a-z0-9]/.test(name)) return { valid: false, error: 'Bucket name must start with a lowercase letter or number' }; + if (!/[a-z0-9]$/.test(name)) return { valid: false, error: 'Bucket name must end with a lowercase letter or number' }; + if (/[A-Z]/.test(name)) return { valid: false, error: 'Bucket name must not contain uppercase letters' }; + if (/_/.test(name)) return { valid: false, error: 'Bucket name must not contain underscores' }; + if (/\.\.|--/.test(name)) return { valid: false, error: 'Bucket name must not contain consecutive periods or hyphens' }; + if (/^\d+\.\d+\.\d+\.\d+$/.test(name)) return { valid: false, error: 'Bucket name must not be formatted as an IP address' }; + if (!/^[a-z0-9][a-z0-9.-]*[a-z0-9]$/.test(name) && name.length > 2) return { valid: false, error: 'Bucket name contains invalid characters. Use only lowercase letters, numbers, hyphens, and periods.' }; + return { valid: true, error: null }; + }; + + const updateBucketNameValidation = () => { + if (!targetBucketInput || !targetBucketFeedback) return; + const name = targetBucketInput.value.trim(); + if (!name) { + targetBucketInput.classList.remove('is-valid', 'is-invalid'); + targetBucketFeedback.textContent = ''; + return; + } + const result = validateBucketName(name); + targetBucketInput.classList.toggle('is-valid', result.valid); + targetBucketInput.classList.toggle('is-invalid', !result.valid); + targetBucketFeedback.textContent = result.error || ''; + }; + + targetBucketInput?.addEventListener('input', updateBucketNameValidation); + targetBucketInput?.addEventListener('blur', updateBucketNameValidation); + + const replicationForm = targetBucketInput?.closest('form'); + replicationForm?.addEventListener('submit', (e) => { + const name = targetBucketInput.value.trim(); + const result = validateBucketName(name); + if (!result.valid) { + e.preventDefault(); + updateBucketNameValidation(); + targetBucketInput.focus(); + return false; + } + }); + + const formatPolicyBtn = document.getElementById('formatPolicyBtn'); + const policyValidationStatus = document.getElementById('policyValidationStatus'); + const policyValidBadge = document.getElementById('policyValidBadge'); + const policyInvalidBadge = document.getElementById('policyInvalidBadge'); + const policyErrorDetail = document.getElementById('policyErrorDetail'); + + const validatePolicyJson = () => { + if (!policyTextarea || !policyValidationStatus) return; + const value = policyTextarea.value.trim(); + if (!value) { + policyValidationStatus.classList.add('d-none'); + policyErrorDetail?.classList.add('d-none'); + return; + } + policyValidationStatus.classList.remove('d-none'); + try { + JSON.parse(value); + policyValidBadge?.classList.remove('d-none'); + policyInvalidBadge?.classList.add('d-none'); + policyErrorDetail?.classList.add('d-none'); + } catch (err) { + policyValidBadge?.classList.add('d-none'); + policyInvalidBadge?.classList.remove('d-none'); + if (policyErrorDetail) { + policyErrorDetail.textContent = err.message; + policyErrorDetail.classList.remove('d-none'); + } + } + }; + + policyTextarea?.addEventListener('input', validatePolicyJson); + policyTextarea?.addEventListener('blur', validatePolicyJson); + + formatPolicyBtn?.addEventListener('click', () => { + if (!policyTextarea) return; + const value = policyTextarea.value.trim(); + if (!value) return; + try { + const parsed = JSON.parse(value); + policyTextarea.value = JSON.stringify(parsed, null, 2); + validatePolicyJson(); + } catch (err) { + validatePolicyJson(); + } + }); + + if (policyTextarea && policyPreset?.value === 'custom') { + validatePolicyJson(); + } + + const lifecycleCard = document.getElementById('lifecycle-rules-card'); + const lifecycleUrl = lifecycleCard?.dataset.lifecycleUrl; + const lifecycleRulesBody = document.getElementById('lifecycle-rules-body'); + const addLifecycleRuleModalEl = document.getElementById('addLifecycleRuleModal'); + const addLifecycleRuleModal = addLifecycleRuleModalEl ? new bootstrap.Modal(addLifecycleRuleModalEl) : null; + let lifecycleRules = []; + + const loadLifecycleRules = async () => { + if (!lifecycleUrl || !lifecycleRulesBody) return; + lifecycleRulesBody.innerHTML = '
    Loading...'; + try { + const resp = await fetch(lifecycleUrl); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || 'Failed to load lifecycle rules'); + lifecycleRules = data.rules || []; + renderLifecycleRules(); + } catch (err) { + lifecycleRulesBody.innerHTML = `${escapeHtml(err.message)}`; + } + }; + + const renderLifecycleRules = () => { + if (!lifecycleRulesBody) return; + if (lifecycleRules.length === 0) { + lifecycleRulesBody.innerHTML = 'No lifecycle rules configured'; + return; + } + lifecycleRulesBody.innerHTML = lifecycleRules.map((rule, idx) => { + const expiration = rule.Expiration?.Days ? `${rule.Expiration.Days}d` : '-'; + const noncurrent = rule.NoncurrentVersionExpiration?.NoncurrentDays ? `${rule.NoncurrentVersionExpiration.NoncurrentDays}d` : '-'; + const statusClass = rule.Status === 'Enabled' ? 'bg-success' : 'bg-secondary'; + return ` + ${escapeHtml(rule.ID || '')} + ${escapeHtml(rule.Filter?.Prefix || '*')} + ${escapeHtml(rule.Status)} + ${expiration} + ${noncurrent} + +
    + + +
    + + `; + }).join(''); + }; + + window.editLifecycleRule = (idx) => { + const rule = lifecycleRules[idx]; + if (!rule) return; + document.getElementById('lifecycleRuleId').value = rule.ID || ''; + document.getElementById('lifecycleRuleStatus').value = rule.Status || 'Enabled'; + document.getElementById('lifecycleRulePrefix').value = rule.Filter?.Prefix || ''; + document.getElementById('lifecycleExpirationDays').value = rule.Expiration?.Days || ''; + document.getElementById('lifecycleNoncurrentDays').value = rule.NoncurrentVersionExpiration?.NoncurrentDays || ''; + document.getElementById('lifecycleAbortMpuDays').value = rule.AbortIncompleteMultipartUpload?.DaysAfterInitiation || ''; + window.editingLifecycleIdx = idx; + addLifecycleRuleModal?.show(); + }; + + window.editingLifecycleIdx = null; + + window.deleteLifecycleRule = async (idx) => { + lifecycleRules.splice(idx, 1); + await saveLifecycleRules(); + }; + + const saveLifecycleRules = async () => { + if (!lifecycleUrl) return; + try { + const resp = await fetch(lifecycleUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRFToken': window.getCsrfToken ? window.getCsrfToken() : '' }, + body: JSON.stringify({ rules: lifecycleRules }) + }); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || 'Failed to save'); + showMessage({ title: 'Lifecycle rules saved', body: 'Configuration updated successfully.', variant: 'success' }); + renderLifecycleRules(); + } catch (err) { + showMessage({ title: 'Save failed', body: err.message, variant: 'danger' }); + } + }; + + document.getElementById('addLifecycleRuleConfirm')?.addEventListener('click', async () => { + const ruleId = document.getElementById('lifecycleRuleId')?.value?.trim(); + const status = document.getElementById('lifecycleRuleStatus')?.value || 'Enabled'; + const prefix = document.getElementById('lifecycleRulePrefix')?.value?.trim() || ''; + const expDays = parseInt(document.getElementById('lifecycleExpirationDays')?.value) || 0; + const ncDays = parseInt(document.getElementById('lifecycleNoncurrentDays')?.value) || 0; + const abortDays = parseInt(document.getElementById('lifecycleAbortMpuDays')?.value) || 0; + if (!ruleId) { showMessage({ title: 'Validation error', body: 'Rule ID is required', variant: 'warning' }); return; } + if (expDays === 0 && ncDays === 0 && abortDays === 0) { showMessage({ title: 'Validation error', body: 'At least one action is required', variant: 'warning' }); return; } + const rule = { ID: ruleId, Status: status, Filter: { Prefix: prefix } }; + if (expDays > 0) rule.Expiration = { Days: expDays }; + if (ncDays > 0) rule.NoncurrentVersionExpiration = { NoncurrentDays: ncDays }; + if (abortDays > 0) rule.AbortIncompleteMultipartUpload = { DaysAfterInitiation: abortDays }; + if (typeof window.editingLifecycleIdx === 'number' && window.editingLifecycleIdx !== null) { + lifecycleRules[window.editingLifecycleIdx] = rule; + window.editingLifecycleIdx = null; + } else { + lifecycleRules.push(rule); + } + await saveLifecycleRules(); + addLifecycleRuleModal?.hide(); + document.getElementById('lifecycleRuleId').value = ''; + document.getElementById('lifecycleRulePrefix').value = ''; + document.getElementById('lifecycleExpirationDays').value = ''; + document.getElementById('lifecycleNoncurrentDays').value = ''; + document.getElementById('lifecycleAbortMpuDays').value = ''; + document.getElementById('lifecycleRuleStatus').value = 'Enabled'; + }); + + const corsCard = document.getElementById('cors-rules-card'); + const corsUrl = corsCard?.dataset.corsUrl; + const corsRulesBody = document.getElementById('cors-rules-body'); + const addCorsRuleModalEl = document.getElementById('addCorsRuleModal'); + const addCorsRuleModal = addCorsRuleModalEl ? new bootstrap.Modal(addCorsRuleModalEl) : null; + let corsRules = []; + + const loadCorsRules = async () => { + if (!corsUrl || !corsRulesBody) return; + corsRulesBody.innerHTML = '
    Loading...'; + try { + const resp = await fetch(corsUrl); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || 'Failed to load CORS rules'); + corsRules = data.rules || []; + renderCorsRules(); + } catch (err) { + corsRulesBody.innerHTML = `${escapeHtml(err.message)}`; + } + }; + + const renderCorsRules = () => { + if (!corsRulesBody) return; + if (corsRules.length === 0) { + corsRulesBody.innerHTML = 'No CORS rules configured'; + return; + } + corsRulesBody.innerHTML = corsRules.map((rule, idx) => { + const origins = (rule.AllowedOrigins || []).map(o => `${escapeHtml(o)}`).join(', '); + const methods = (rule.AllowedMethods || []).map(m => `${escapeHtml(m)}`).join(' '); + const headers = (rule.AllowedHeaders || []).slice(0, 3).map(h => `${escapeHtml(h)}`).join(', '); + return ` + ${origins || 'None'} + ${methods || 'None'} + ${headers || '*'} + ${rule.MaxAgeSeconds || '-'} + +
    + + +
    + + `; + }).join(''); + }; + + window.editCorsRule = (idx) => { + const rule = corsRules[idx]; + if (!rule) return; + document.getElementById('corsAllowedOrigins').value = (rule.AllowedOrigins || []).join('\n'); + document.getElementById('corsAllowedHeaders').value = (rule.AllowedHeaders || []).join('\n'); + document.getElementById('corsExposeHeaders').value = (rule.ExposeHeaders || []).join('\n'); + document.getElementById('corsMaxAge').value = rule.MaxAgeSeconds || ''; + document.getElementById('corsMethodGet').checked = (rule.AllowedMethods || []).includes('GET'); + document.getElementById('corsMethodPut').checked = (rule.AllowedMethods || []).includes('PUT'); + document.getElementById('corsMethodPost').checked = (rule.AllowedMethods || []).includes('POST'); + document.getElementById('corsMethodDelete').checked = (rule.AllowedMethods || []).includes('DELETE'); + document.getElementById('corsMethodHead').checked = (rule.AllowedMethods || []).includes('HEAD'); + window.editingCorsIdx = idx; + addCorsRuleModal?.show(); + }; + + window.editingCorsIdx = null; + + window.deleteCorsRule = async (idx) => { + corsRules.splice(idx, 1); + await saveCorsRules(); + }; + + const saveCorsRules = async () => { + if (!corsUrl) return; + try { + const resp = await fetch(corsUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRFToken': window.getCsrfToken ? window.getCsrfToken() : '' }, + body: JSON.stringify({ rules: corsRules }) + }); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || 'Failed to save'); + showMessage({ title: 'CORS rules saved', body: 'Configuration updated successfully.', variant: 'success' }); + renderCorsRules(); + } catch (err) { + showMessage({ title: 'Save failed', body: err.message, variant: 'danger' }); + } + }; + + document.getElementById('addCorsRuleConfirm')?.addEventListener('click', async () => { + const originsRaw = document.getElementById('corsAllowedOrigins')?.value?.trim() || ''; + const origins = originsRaw.split('\n').map(s => s.trim()).filter(Boolean); + const methods = []; + if (document.getElementById('corsMethodGet')?.checked) methods.push('GET'); + if (document.getElementById('corsMethodPut')?.checked) methods.push('PUT'); + if (document.getElementById('corsMethodPost')?.checked) methods.push('POST'); + if (document.getElementById('corsMethodDelete')?.checked) methods.push('DELETE'); + if (document.getElementById('corsMethodHead')?.checked) methods.push('HEAD'); + const headersRaw = document.getElementById('corsAllowedHeaders')?.value?.trim() || ''; + const headers = headersRaw.split('\n').map(s => s.trim()).filter(Boolean); + const exposeRaw = document.getElementById('corsExposeHeaders')?.value?.trim() || ''; + const expose = exposeRaw.split('\n').map(s => s.trim()).filter(Boolean); + const maxAge = parseInt(document.getElementById('corsMaxAge')?.value) || 0; + if (origins.length === 0) { showMessage({ title: 'Validation error', body: 'At least one origin is required', variant: 'warning' }); return; } + if (methods.length === 0) { showMessage({ title: 'Validation error', body: 'At least one method is required', variant: 'warning' }); return; } + const rule = { AllowedOrigins: origins, AllowedMethods: methods }; + if (headers.length > 0) rule.AllowedHeaders = headers; + if (expose.length > 0) rule.ExposeHeaders = expose; + if (maxAge > 0) rule.MaxAgeSeconds = maxAge; + if (typeof window.editingCorsIdx === 'number' && window.editingCorsIdx !== null) { + corsRules[window.editingCorsIdx] = rule; + window.editingCorsIdx = null; + } else { + corsRules.push(rule); + } + await saveCorsRules(); + addCorsRuleModal?.hide(); + document.getElementById('corsAllowedOrigins').value = ''; + document.getElementById('corsAllowedHeaders').value = ''; + document.getElementById('corsExposeHeaders').value = ''; + document.getElementById('corsMaxAge').value = ''; + document.getElementById('corsMethodGet').checked = false; + document.getElementById('corsMethodPut').checked = false; + document.getElementById('corsMethodPost').checked = false; + document.getElementById('corsMethodDelete').checked = false; + document.getElementById('corsMethodHead').checked = false; + }); + + const aclCard = document.getElementById('bucket-acl-card'); + const aclUrl = aclCard?.dataset.aclUrl; + const aclOwnerEl = document.getElementById('acl-owner'); + const aclGrantsList = document.getElementById('acl-grants-list'); + const aclLoading = document.getElementById('acl-loading'); + const aclContent = document.getElementById('acl-content'); + const cannedAclSelect = document.getElementById('cannedAclSelect'); + + const loadAcl = async () => { + if (!aclUrl) return; + try { + const resp = await fetch(aclUrl); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || 'Failed to load ACL'); + if (aclOwnerEl) aclOwnerEl.textContent = data.owner || '-'; + if (aclGrantsList) { + const grants = data.grants || []; + if (grants.length === 0) { + aclGrantsList.innerHTML = '
    No grants
    '; + } else { + aclGrantsList.innerHTML = grants.map(g => `
    ${escapeHtml(g.grantee)}${escapeHtml(g.permission)}
    `).join(''); + } + } + if (aclLoading) aclLoading.classList.add('d-none'); + if (aclContent) aclContent.classList.remove('d-none'); + } catch (err) { + if (aclLoading) aclLoading.classList.add('d-none'); + if (aclContent) aclContent.classList.remove('d-none'); + if (aclGrantsList) aclGrantsList.innerHTML = `
    ${escapeHtml(err.message)}
    `; + } + }; + + cannedAclSelect?.addEventListener('change', async () => { + const canned = cannedAclSelect.value; + if (!canned || !aclUrl) return; + try { + const resp = await fetch(aclUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRFToken': window.getCsrfToken ? window.getCsrfToken() : '' }, + body: JSON.stringify({ canned_acl: canned }) + }); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || 'Failed to set ACL'); + showMessage({ title: 'ACL updated', body: `Bucket ACL set to "${canned}"`, variant: 'success' }); + await loadAcl(); + } catch (err) { + showMessage({ title: 'ACL update failed', body: err.message, variant: 'danger' }); + } + }); + + document.querySelectorAll('[data-set-acl]').forEach(btn => { + btn.addEventListener('click', async () => { + const canned = btn.dataset.setAcl; + if (!canned || !aclUrl) return; + btn.disabled = true; + const originalText = btn.innerHTML; + btn.innerHTML = ''; + try { + const resp = await fetch(aclUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRFToken': window.getCsrfToken ? window.getCsrfToken() : '' }, + body: JSON.stringify({ canned_acl: canned }) + }); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || 'Failed to set ACL'); + showMessage({ title: 'ACL updated', body: `Bucket ACL set to "${canned}"`, variant: 'success' }); + await loadAcl(); + } catch (err) { + showMessage({ title: 'ACL update failed', body: err.message, variant: 'danger' }); + } finally { + btn.innerHTML = originalText; + btn.disabled = false; + } + }); + }); + + document.getElementById('objects-table')?.addEventListener('show.bs.dropdown', function(e) { + const dropdown = e.target.closest('.dropdown'); + const menu = dropdown?.querySelector('.dropdown-menu'); + const btn = e.target; + if (!menu || !btn) return; + const btnRect = btn.getBoundingClientRect(); + menu.style.position = 'fixed'; + menu.style.top = (btnRect.bottom + 4) + 'px'; + menu.style.left = 'auto'; + menu.style.right = (window.innerWidth - btnRect.right) + 'px'; + menu.style.transform = 'none'; + }); + + const previewTagsPanel = document.getElementById('preview-tags'); + const previewTagsList = document.getElementById('preview-tags-list'); + const previewTagsEmpty = document.getElementById('preview-tags-empty'); + const previewTagsCount = document.getElementById('preview-tags-count'); + const previewTagsEditor = document.getElementById('preview-tags-editor'); + const previewTagsInputs = document.getElementById('preview-tags-inputs'); + const editTagsButton = document.getElementById('editTagsButton'); + const addTagRow = document.getElementById('addTagRow'); + const saveTagsButton = document.getElementById('saveTagsButton'); + const cancelTagsButton = document.getElementById('cancelTagsButton'); + let currentObjectTags = []; + let isEditingTags = false; + + const loadObjectTags = async (row) => { + if (!row || !previewTagsPanel) return; + const tagsUrl = row.dataset.tagsUrl; + if (!tagsUrl) { + previewTagsPanel.classList.add('d-none'); + return; + } + previewTagsPanel.classList.remove('d-none'); + try { + const resp = await fetch(tagsUrl); + const data = await resp.json(); + currentObjectTags = data.tags || []; + renderObjectTags(); + } catch (err) { + currentObjectTags = []; + renderObjectTags(); + } + }; + + const renderObjectTags = () => { + if (!previewTagsList || !previewTagsEmpty || !previewTagsCount) return; + previewTagsCount.textContent = currentObjectTags.length; + if (currentObjectTags.length === 0) { + previewTagsList.innerHTML = ''; + previewTagsEmpty.classList.remove('d-none'); + } else { + previewTagsEmpty.classList.add('d-none'); + previewTagsList.innerHTML = currentObjectTags.map(t => `${escapeHtml(t.Key)}=${escapeHtml(t.Value)}`).join(''); + } + }; + + const renderTagEditor = () => { + if (!previewTagsInputs) return; + previewTagsInputs.innerHTML = currentObjectTags.map((t, idx) => ` +
    + + + +
    + `).join(''); + }; + + window.removeTagRow = (idx) => { + currentObjectTags.splice(idx, 1); + renderTagEditor(); + }; + + editTagsButton?.addEventListener('click', () => { + isEditingTags = true; + previewTagsList.classList.add('d-none'); + previewTagsEmpty.classList.add('d-none'); + previewTagsEditor?.classList.remove('d-none'); + renderTagEditor(); + }); + + cancelTagsButton?.addEventListener('click', () => { + isEditingTags = false; + previewTagsEditor?.classList.add('d-none'); + previewTagsList.classList.remove('d-none'); + renderObjectTags(); + }); + + addTagRow?.addEventListener('click', () => { + if (currentObjectTags.length >= 10) { + showMessage({ title: 'Limit reached', body: 'Maximum 10 tags allowed per object.', variant: 'warning' }); + return; + } + currentObjectTags.push({ Key: '', Value: '' }); + renderTagEditor(); + }); + + saveTagsButton?.addEventListener('click', async () => { + if (!activeRow) return; + const tagsUrl = activeRow.dataset.tagsUrl; + if (!tagsUrl) return; + const inputs = previewTagsInputs?.querySelectorAll('.input-group'); + const newTags = []; + inputs?.forEach((group, idx) => { + const key = group.querySelector(`[data-tag-key="${idx}"]`)?.value?.trim() || ''; + const value = group.querySelector(`[data-tag-value="${idx}"]`)?.value?.trim() || ''; + if (key) newTags.push({ Key: key, Value: value }); + }); + try { + const resp = await fetch(tagsUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRFToken': window.getCsrfToken ? window.getCsrfToken() : '' }, + body: JSON.stringify({ tags: newTags }) + }); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || 'Failed to save tags'); + currentObjectTags = newTags; + isEditingTags = false; + previewTagsEditor?.classList.add('d-none'); + previewTagsList.classList.remove('d-none'); + renderObjectTags(); + showMessage({ title: 'Tags saved', body: 'Object tags updated successfully.', variant: 'success' }); + } catch (err) { + showMessage({ title: 'Save failed', body: err.message, variant: 'danger' }); + } + }); + + const copyMoveModalEl = document.getElementById('copyMoveModal'); + const copyMoveModal = copyMoveModalEl ? new bootstrap.Modal(copyMoveModalEl) : null; + const copyMoveActionLabel = document.getElementById('copyMoveActionLabel'); + const copyMoveConfirmLabel = document.getElementById('copyMoveConfirmLabel'); + const copyMoveSource = document.getElementById('copyMoveSource'); + const copyMoveDestBucket = document.getElementById('copyMoveDestBucket'); + const copyMoveDestKey = document.getElementById('copyMoveDestKey'); + const copyMoveConfirm = document.getElementById('copyMoveConfirm'); + const bucketsForCopyUrl = objectsContainer?.dataset.bucketsForCopyUrl; + let copyMoveAction = 'copy'; + let copyMoveSourceKey = ''; + + window.openCopyMoveModal = async (action, key) => { + copyMoveAction = action; + copyMoveSourceKey = key; + if (copyMoveActionLabel) copyMoveActionLabel.textContent = action === 'move' ? 'Move' : 'Copy'; + if (copyMoveConfirmLabel) copyMoveConfirmLabel.textContent = action === 'move' ? 'Move' : 'Copy'; + if (copyMoveSource) copyMoveSource.textContent = key; + if (copyMoveDestKey) copyMoveDestKey.value = key; + if (copyMoveDestBucket) { + copyMoveDestBucket.innerHTML = ''; + try { + const resp = await fetch(bucketsForCopyUrl); + const data = await resp.json(); + const buckets = data.buckets || []; + copyMoveDestBucket.innerHTML = buckets.map(b => ``).join(''); + } catch { + copyMoveDestBucket.innerHTML = ''; + } + } + copyMoveModal?.show(); + }; + + copyMoveConfirm?.addEventListener('click', async () => { + const destBucket = copyMoveDestBucket?.value; + const destKey = copyMoveDestKey?.value?.trim(); + if (!destBucket || !destKey) { showMessage({ title: 'Validation error', body: 'Destination bucket and key are required', variant: 'warning' }); return; } + const actionUrl = copyMoveAction === 'move' + ? urlTemplates?.move?.replace('KEY_PLACEHOLDER', encodeURIComponent(copyMoveSourceKey).replace(/%2F/g, '/')) + : urlTemplates?.copy?.replace('KEY_PLACEHOLDER', encodeURIComponent(copyMoveSourceKey).replace(/%2F/g, '/')); + if (!actionUrl) { showMessage({ title: 'Error', body: 'Copy/move URL not configured', variant: 'danger' }); return; } + try { + const resp = await fetch(actionUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRFToken': window.getCsrfToken ? window.getCsrfToken() : '' }, + body: JSON.stringify({ dest_bucket: destBucket, dest_key: destKey }) + }); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || `Failed to ${copyMoveAction} object`); + showMessage({ title: `Object ${copyMoveAction === 'move' ? 'moved' : 'copied'}`, body: `Successfully ${copyMoveAction === 'move' ? 'moved' : 'copied'} to ${destBucket}/${destKey}`, variant: 'success' }); + copyMoveModal?.hide(); + if (copyMoveAction === 'move') { + previewEmpty.classList.remove('d-none'); + previewPanel.classList.add('d-none'); + activeRow = null; + loadObjects(false); + } + } catch (err) { + showMessage({ title: `${copyMoveAction === 'move' ? 'Move' : 'Copy'} failed`, body: err.message, variant: 'danger' }); + } + }); + + const originalSelectRow = selectRow; + selectRow = (row) => { + originalSelectRow(row); + loadObjectTags(row); + }; + + if (lifecycleCard) loadLifecycleRules(); + + const lifecycleHistoryCard = document.getElementById('lifecycle-history-card'); + const lifecycleHistoryBody = document.getElementById('lifecycle-history-body'); + const lifecycleHistoryPagination = document.getElementById('lifecycle-history-pagination'); + const showMoreHistoryBtn = document.getElementById('show-more-history'); + const historyShownCount = document.getElementById('history-shown-count'); + let historyExpanded = false; + + const loadLifecycleHistory = async () => { + if (!lifecycleHistoryCard || !lifecycleHistoryBody) return; + + const endpoint = lifecycleHistoryCard.dataset.historyEndpoint; + const limit = historyExpanded ? 50 : 5; + + lifecycleHistoryBody.innerHTML = '
    Loading...'; + + try { + const resp = await fetch(`${endpoint}?limit=${limit}`); + if (!resp.ok) throw new Error('Failed to fetch history'); + const data = await resp.json(); + + if (!data.enabled) { + lifecycleHistoryBody.innerHTML = 'Lifecycle enforcement is not enabled'; + return; + } + + const executions = data.executions || []; + const total = data.total || 0; + + if (executions.length === 0) { + lifecycleHistoryBody.innerHTML = 'No executions recorded yet'; + lifecycleHistoryPagination.style.display = 'none'; + return; + } + + lifecycleHistoryBody.innerHTML = executions.map(e => { + const date = new Date(e.timestamp * 1000); + const hasErrors = e.errors && e.errors.length > 0; + const hasActivity = e.objects_deleted > 0 || e.versions_deleted > 0 || e.uploads_aborted > 0; + let statusBadge; + if (hasErrors) { + statusBadge = 'Errors'; + } else if (hasActivity) { + statusBadge = 'Success'; + } else { + statusBadge = 'No action'; + } + const errorTooltip = hasErrors ? ` title="${escapeHtml(e.errors.join('; '))}"` : ''; + return ` + ${date.toLocaleString()} + ${e.objects_deleted} + ${e.versions_deleted} + ${e.uploads_aborted} + ${statusBadge} + `; + }).join(''); + + if (total > 5 && !historyExpanded) { + lifecycleHistoryPagination.style.display = ''; + historyShownCount.textContent = `Showing ${Math.min(5, total)} of ${total}`; + } else { + lifecycleHistoryPagination.style.display = 'none'; + } + } catch (err) { + console.error('Failed to load lifecycle history:', err); + lifecycleHistoryBody.innerHTML = 'Failed to load history'; + } + }; + + showMoreHistoryBtn?.addEventListener('click', () => { + historyExpanded = !historyExpanded; + showMoreHistoryBtn.textContent = historyExpanded ? 'Show less' : 'Show more...'; + loadLifecycleHistory(); + }); + + if (lifecycleHistoryCard) { + loadLifecycleHistory(); + if (window.pollingManager) { + window.pollingManager.start('lifecycle', loadLifecycleHistory); + } + } + + if (corsCard) loadCorsRules(); + if (aclCard) loadAcl(); + + function updateVersioningBadge(enabled) { + var badge = document.querySelector('.badge.rounded-pill'); + if (!badge) return; + badge.classList.remove('text-bg-success', 'text-bg-secondary'); + badge.classList.add(enabled ? 'text-bg-success' : 'text-bg-secondary'); + var icon = '' + + '' + + ''; + badge.innerHTML = icon + (enabled ? 'Versioning On' : 'Versioning Off'); + versioningEnabled = enabled; + } + + function interceptForm(formId, options) { + var form = document.getElementById(formId); + if (!form) return; + + form.addEventListener('submit', function(e) { + e.preventDefault(); + window.UICore.submitFormAjax(form, { + successMessage: options.successMessage || 'Operation completed', + onSuccess: function(data) { + if (options.onSuccess) options.onSuccess(data); + if (options.closeModal) { + var modal = bootstrap.Modal.getInstance(document.getElementById(options.closeModal)); + if (modal) modal.hide(); + } + if (options.reload) { + setTimeout(function() { location.reload(); }, 500); + } + } + }); + }); + } + + function updateVersioningCard(enabled) { + var card = document.getElementById('bucket-versioning-card'); + if (!card) return; + var cardBody = card.querySelector('.card-body'); + if (!cardBody) return; + + var enabledHtml = '' + + ''; + + var disabledHtml = '' + + '
    ' + + '' + + '' + + '
    '; + + cardBody.innerHTML = enabled ? enabledHtml : disabledHtml; + + var archivedCardEl = document.getElementById('archived-objects-card'); + if (archivedCardEl) { + archivedCardEl.style.display = enabled ? '' : 'none'; + } + + var dropZone = document.getElementById('objects-drop-zone'); + if (dropZone) { + dropZone.setAttribute('data-versioning', enabled ? 'true' : 'false'); + } + + if (!enabled) { + var newForm = document.getElementById('enableVersioningForm'); + if (newForm) { + newForm.setAttribute('action', window.BucketDetailConfig?.endpoints?.versioning || ''); + newForm.addEventListener('submit', function(e) { + e.preventDefault(); + window.UICore.submitFormAjax(newForm, { + successMessage: 'Versioning enabled', + onSuccess: function() { + updateVersioningBadge(true); + updateVersioningCard(true); + } + }); + }); + } + } + } + + function updateEncryptionCard(enabled, algorithm) { + var encCard = document.getElementById('bucket-encryption-card'); + if (!encCard) return; + var alertContainer = encCard.querySelector('.alert'); + if (alertContainer) { + if (enabled) { + alertContainer.className = 'alert alert-success d-flex align-items-start mb-4'; + var algoText = algorithm === 'aws:kms' ? 'KMS' : 'AES-256'; + alertContainer.innerHTML = '' + + '' + + '' + + '
    Default encryption enabled (' + algoText + ')' + + '

    All new objects uploaded to this bucket will be automatically encrypted.

    '; + } else { + alertContainer.className = 'alert alert-secondary d-flex align-items-start mb-4'; + alertContainer.innerHTML = '' + + '' + + '
    Default encryption disabled' + + '

    Objects are stored without default encryption. You can enable server-side encryption below.

    '; + } + } + var disableBtn = document.getElementById('disableEncryptionBtn'); + if (disableBtn) { + disableBtn.style.display = enabled ? '' : 'none'; + } + } + + function updateQuotaCard(hasQuota, maxBytes, maxObjects) { + var quotaCard = document.getElementById('bucket-quota-card'); + if (!quotaCard) return; + var alertContainer = quotaCard.querySelector('.alert'); + if (alertContainer) { + if (hasQuota) { + alertContainer.className = 'alert alert-info d-flex align-items-start mb-4'; + var quotaParts = []; + if (maxBytes) quotaParts.push(formatBytes(maxBytes) + ' storage'); + if (maxObjects) quotaParts.push(maxObjects.toLocaleString() + ' objects'); + alertContainer.innerHTML = '' + + '' + + '
    Storage quota active' + + '

    This bucket is limited to ' + quotaParts.join(' and ') + '.

    '; + } else { + alertContainer.className = 'alert alert-secondary d-flex align-items-start mb-4'; + alertContainer.innerHTML = '' + + '' + + '' + + '
    No storage quota' + + '

    This bucket has no storage or object count limits. Set limits below to control usage.

    '; + } + } + var removeBtn = document.getElementById('removeQuotaBtn'); + if (removeBtn) { + removeBtn.style.display = hasQuota ? '' : 'none'; + } + var maxMbInput = document.getElementById('max_mb'); + var maxObjInput = document.getElementById('max_objects'); + if (maxMbInput) maxMbInput.value = maxBytes ? Math.floor(maxBytes / 1048576) : ''; + if (maxObjInput) maxObjInput.value = maxObjects || ''; + } + + function updatePolicyCard(hasPolicy, preset) { + var policyCard = document.querySelector('#permissions-pane .card'); + if (!policyCard) return; + var alertContainer = policyCard.querySelector('.alert'); + if (alertContainer) { + if (hasPolicy) { + alertContainer.className = 'alert alert-info d-flex align-items-start mb-4'; + alertContainer.innerHTML = '' + + '' + + '
    Policy attached' + + '

    A bucket policy is attached to this bucket. Access is granted via both IAM and bucket policy rules.

    '; + } else { + alertContainer.className = 'alert alert-secondary d-flex align-items-start mb-4'; + alertContainer.innerHTML = '' + + '' + + '' + + '
    IAM only' + + '

    No bucket policy is attached. Access is controlled by IAM policies only.

    '; + } + } + document.querySelectorAll('.preset-btn').forEach(function(btn) { + btn.classList.remove('active'); + if (btn.dataset.preset === preset) btn.classList.add('active'); + }); + var presetInputEl = document.getElementById('policyPreset'); + if (presetInputEl) presetInputEl.value = preset; + var deletePolicyBtn = document.getElementById('deletePolicyBtn'); + if (deletePolicyBtn) { + deletePolicyBtn.style.display = hasPolicy ? '' : 'none'; + } + } + + interceptForm('enableVersioningForm', { + successMessage: 'Versioning enabled', + onSuccess: function(data) { + updateVersioningBadge(true); + updateVersioningCard(true); + } + }); + + interceptForm('suspendVersioningForm', { + successMessage: 'Versioning suspended', + closeModal: 'suspendVersioningModal', + onSuccess: function(data) { + updateVersioningBadge(false); + updateVersioningCard(false); + } + }); + + interceptForm('encryptionForm', { + successMessage: 'Encryption settings saved', + onSuccess: function(data) { + updateEncryptionCard(data.enabled !== false, data.algorithm || 'AES256'); + } + }); + + interceptForm('quotaForm', { + successMessage: 'Quota settings saved', + onSuccess: function(data) { + updateQuotaCard(data.has_quota, data.max_bytes, data.max_objects); + } + }); + + interceptForm('bucketPolicyForm', { + successMessage: 'Bucket policy saved', + onSuccess: function(data) { + var policyModeEl = document.getElementById('policyMode'); + var policyPresetEl = document.getElementById('policyPreset'); + var preset = policyModeEl && policyModeEl.value === 'delete' ? 'private' : + (policyPresetEl?.value || 'custom'); + updatePolicyCard(preset !== 'private', preset); + } + }); + + var deletePolicyForm = document.getElementById('deletePolicyForm'); + if (deletePolicyForm) { + deletePolicyForm.addEventListener('submit', function(e) { + e.preventDefault(); + window.UICore.submitFormAjax(deletePolicyForm, { + successMessage: 'Bucket policy deleted', + onSuccess: function(data) { + var modal = bootstrap.Modal.getInstance(document.getElementById('deletePolicyModal')); + if (modal) modal.hide(); + updatePolicyCard(false, 'private'); + var policyTextarea = document.getElementById('policyDocument'); + if (policyTextarea) policyTextarea.value = ''; + } + }); + }); + } + + var disableEncBtn = document.getElementById('disableEncryptionBtn'); + if (disableEncBtn) { + disableEncBtn.addEventListener('click', function() { + var form = document.getElementById('encryptionForm'); + if (!form) return; + document.getElementById('encryptionAction').value = 'disable'; + window.UICore.submitFormAjax(form, { + successMessage: 'Encryption disabled', + onSuccess: function(data) { + document.getElementById('encryptionAction').value = 'enable'; + updateEncryptionCard(false, null); + } + }); + }); + } + + var removeQuotaBtn = document.getElementById('removeQuotaBtn'); + if (removeQuotaBtn) { + removeQuotaBtn.addEventListener('click', function() { + var form = document.getElementById('quotaForm'); + if (!form) return; + document.getElementById('quotaAction').value = 'remove'; + window.UICore.submitFormAjax(form, { + successMessage: 'Quota removed', + onSuccess: function(data) { + document.getElementById('quotaAction').value = 'set'; + updateQuotaCard(false, null, null); + } + }); + }); + } + + function reloadReplicationPane() { + var replicationPane = document.getElementById('replication-pane'); + if (!replicationPane) return; + fetch(window.location.pathname + '?tab=replication', { + headers: { 'X-Requested-With': 'XMLHttpRequest' } + }) + .then(function(resp) { return resp.text(); }) + .then(function(html) { + var parser = new DOMParser(); + var doc = parser.parseFromString(html, 'text/html'); + var newPane = doc.getElementById('replication-pane'); + if (newPane) { + replicationPane.innerHTML = newPane.innerHTML; + initReplicationForms(); + initReplicationStats(); + } + }) + .catch(function(err) { + console.error('Failed to reload replication pane:', err); + }); + } + + function initReplicationForms() { + document.querySelectorAll('form[action*="replication"]').forEach(function(form) { + if (form.dataset.ajaxBound) return; + form.dataset.ajaxBound = 'true'; + var actionInput = form.querySelector('input[name="action"]'); + if (!actionInput) return; + var action = actionInput.value; + + form.addEventListener('submit', function(e) { + e.preventDefault(); + var msg = action === 'pause' ? 'Replication paused' : + action === 'resume' ? 'Replication resumed' : + action === 'delete' ? 'Replication disabled' : + action === 'create' ? 'Replication configured' : 'Operation completed'; + window.UICore.submitFormAjax(form, { + successMessage: msg, + onSuccess: function(data) { + var modal = bootstrap.Modal.getInstance(document.getElementById('disableReplicationModal')); + if (modal) modal.hide(); + reloadReplicationPane(); + } + }); + }); + }); + } + + function initReplicationStats() { + var statsContainer = document.getElementById('replication-stats-cards'); + if (!statsContainer) return; + var statusEndpoint = statsContainer.dataset.statusEndpoint; + if (!statusEndpoint) return; + + var syncedEl = statsContainer.querySelector('[data-stat="synced"]'); + var pendingEl = statsContainer.querySelector('[data-stat="pending"]'); + var orphanedEl = statsContainer.querySelector('[data-stat="orphaned"]'); + var bytesEl = statsContainer.querySelector('[data-stat="bytes"]'); + + fetch(statusEndpoint) + .then(function(resp) { return resp.json(); }) + .then(function(data) { + if (syncedEl) syncedEl.textContent = data.objects_synced || 0; + if (pendingEl) pendingEl.textContent = data.objects_pending || 0; + if (orphanedEl) orphanedEl.textContent = data.objects_orphaned || 0; + if (bytesEl) bytesEl.textContent = formatBytes(data.bytes_synced || 0); + }) + .catch(function(err) { + console.error('Failed to load replication stats:', err); + }); + } + + initReplicationForms(); + initReplicationStats(); + + var deleteBucketForm = document.getElementById('deleteBucketForm'); + if (deleteBucketForm) { + deleteBucketForm.addEventListener('submit', function(e) { + e.preventDefault(); + window.UICore.submitFormAjax(deleteBucketForm, { + onSuccess: function() { + sessionStorage.setItem('flashMessage', JSON.stringify({ title: 'Bucket deleted', variant: 'success' })); + window.location.href = window.BucketDetailConfig?.endpoints?.bucketsOverview || '/ui/buckets'; + } + }); + }); + } + + window.BucketDetailConfig = window.BucketDetailConfig || {}; + +})(); diff --git a/static/js/connections-management.js b/static/js/connections-management.js new file mode 100644 index 0000000..e58b0be --- /dev/null +++ b/static/js/connections-management.js @@ -0,0 +1,344 @@ +window.ConnectionsManagement = (function() { + 'use strict'; + + var endpoints = {}; + var csrfToken = ''; + + function init(config) { + endpoints = config.endpoints || {}; + csrfToken = config.csrfToken || ''; + + setupEventListeners(); + checkAllConnectionHealth(); + } + + function togglePassword(id) { + var input = document.getElementById(id); + if (input) { + input.type = input.type === 'password' ? 'text' : 'password'; + } + } + + async function testConnection(formId, resultId) { + var form = document.getElementById(formId); + var resultDiv = document.getElementById(resultId); + if (!form || !resultDiv) return; + + var formData = new FormData(form); + var data = {}; + formData.forEach(function(value, key) { + if (key !== 'csrf_token') { + data[key] = value; + } + }); + + resultDiv.innerHTML = '
    Testing connection...
    '; + + var controller = new AbortController(); + var timeoutId = setTimeout(function() { controller.abort(); }, 20000); + + try { + var response = await fetch(endpoints.test, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: JSON.stringify(data), + signal: controller.signal + }); + clearTimeout(timeoutId); + + var result = await response.json(); + if (response.ok) { + resultDiv.innerHTML = '
    ' + + '' + + '' + + '' + window.UICore.escapeHtml(result.message) + '
    '; + } else { + resultDiv.innerHTML = '
    ' + + '' + + '' + + '' + window.UICore.escapeHtml(result.message) + '
    '; + } + } catch (error) { + clearTimeout(timeoutId); + var message = error.name === 'AbortError' + ? 'Connection test timed out - endpoint may be unreachable' + : 'Connection failed: Network error'; + resultDiv.innerHTML = '
    ' + + '' + + '' + + '' + message + '
    '; + } + } + + async function checkConnectionHealth(connectionId, statusEl) { + if (!statusEl) return; + + try { + var controller = new AbortController(); + var timeoutId = setTimeout(function() { controller.abort(); }, 15000); + + var response = await fetch(endpoints.healthTemplate.replace('CONNECTION_ID', connectionId), { + signal: controller.signal + }); + clearTimeout(timeoutId); + + var data = await response.json(); + if (data.healthy) { + statusEl.innerHTML = '' + + ''; + statusEl.setAttribute('data-status', 'healthy'); + statusEl.setAttribute('title', 'Connected'); + } else { + statusEl.innerHTML = '' + + ''; + statusEl.setAttribute('data-status', 'unhealthy'); + statusEl.setAttribute('title', data.error || 'Unreachable'); + } + } catch (error) { + statusEl.innerHTML = '' + + ''; + statusEl.setAttribute('data-status', 'unknown'); + statusEl.setAttribute('title', 'Could not check status'); + } + } + + function checkAllConnectionHealth() { + var rows = document.querySelectorAll('tr[data-connection-id]'); + rows.forEach(function(row, index) { + var connectionId = row.getAttribute('data-connection-id'); + var statusEl = row.querySelector('.connection-status'); + if (statusEl) { + setTimeout(function() { + checkConnectionHealth(connectionId, statusEl); + }, index * 200); + } + }); + } + + function updateConnectionCount() { + var countBadge = document.querySelector('.badge.bg-primary.bg-opacity-10.text-primary.fs-6'); + if (countBadge) { + var remaining = document.querySelectorAll('tr[data-connection-id]').length; + countBadge.textContent = remaining + ' connection' + (remaining !== 1 ? 's' : ''); + } + } + + function createConnectionRowHtml(conn) { + var ak = conn.access_key || ''; + var maskedKey = ak.length > 12 ? ak.slice(0, 8) + '...' + ak.slice(-4) : ak; + + return '' + + '' + + '' + + '' + + '' + + '
    ' + + '
    ' + + '
    ' + + '' + window.UICore.escapeHtml(conn.name) + '' + + '
    ' + + '' + window.UICore.escapeHtml(conn.endpoint_url) + '' + + '' + window.UICore.escapeHtml(conn.region) + '' + + '' + window.UICore.escapeHtml(maskedKey) + '' + + '
    ' + + '' + + '' + + '
    '; + } + + function setupEventListeners() { + var testBtn = document.getElementById('testConnectionBtn'); + if (testBtn) { + testBtn.addEventListener('click', function() { + testConnection('createConnectionForm', 'testResult'); + }); + } + + var editTestBtn = document.getElementById('editTestConnectionBtn'); + if (editTestBtn) { + editTestBtn.addEventListener('click', function() { + testConnection('editConnectionForm', 'editTestResult'); + }); + } + + var editModal = document.getElementById('editConnectionModal'); + if (editModal) { + editModal.addEventListener('show.bs.modal', function(event) { + var button = event.relatedTarget; + if (!button) return; + + var id = button.getAttribute('data-id'); + + document.getElementById('edit_name').value = button.getAttribute('data-name') || ''; + document.getElementById('edit_endpoint_url').value = button.getAttribute('data-endpoint') || ''; + document.getElementById('edit_region').value = button.getAttribute('data-region') || ''; + document.getElementById('edit_access_key').value = button.getAttribute('data-access') || ''; + document.getElementById('edit_secret_key').value = button.getAttribute('data-secret') || ''; + document.getElementById('editTestResult').innerHTML = ''; + + var form = document.getElementById('editConnectionForm'); + form.action = endpoints.updateTemplate.replace('CONNECTION_ID', id); + }); + } + + var deleteModal = document.getElementById('deleteConnectionModal'); + if (deleteModal) { + deleteModal.addEventListener('show.bs.modal', function(event) { + var button = event.relatedTarget; + if (!button) return; + + var id = button.getAttribute('data-id'); + var name = button.getAttribute('data-name'); + + document.getElementById('deleteConnectionName').textContent = name; + var form = document.getElementById('deleteConnectionForm'); + form.action = endpoints.deleteTemplate.replace('CONNECTION_ID', id); + }); + } + + var createForm = document.getElementById('createConnectionForm'); + if (createForm) { + createForm.addEventListener('submit', function(e) { + e.preventDefault(); + window.UICore.submitFormAjax(createForm, { + successMessage: 'Connection created', + onSuccess: function(data) { + createForm.reset(); + document.getElementById('testResult').innerHTML = ''; + + if (data.connection) { + var emptyState = document.querySelector('.empty-state'); + if (emptyState) { + var cardBody = emptyState.closest('.card-body'); + if (cardBody) { + cardBody.innerHTML = '
    ' + + '' + + '' + + '' + + '' + + '' + + '
    StatusNameEndpointRegionAccess KeyActions
    '; + } + } + + var tbody = document.querySelector('table tbody'); + if (tbody) { + tbody.insertAdjacentHTML('beforeend', createConnectionRowHtml(data.connection)); + var newRow = tbody.lastElementChild; + var statusEl = newRow.querySelector('.connection-status'); + if (statusEl) { + checkConnectionHealth(data.connection.id, statusEl); + } + } + updateConnectionCount(); + } else { + location.reload(); + } + } + }); + }); + } + + var editForm = document.getElementById('editConnectionForm'); + if (editForm) { + editForm.addEventListener('submit', function(e) { + e.preventDefault(); + window.UICore.submitFormAjax(editForm, { + successMessage: 'Connection updated', + onSuccess: function(data) { + var modal = bootstrap.Modal.getInstance(document.getElementById('editConnectionModal')); + if (modal) modal.hide(); + + var connId = editForm.action.split('/').slice(-2)[0]; + var row = document.querySelector('tr[data-connection-id="' + connId + '"]'); + if (row && data.connection) { + var nameCell = row.querySelector('.fw-medium'); + if (nameCell) nameCell.textContent = data.connection.name; + + var endpointCell = row.querySelector('.text-truncate'); + if (endpointCell) { + endpointCell.textContent = data.connection.endpoint_url; + endpointCell.title = data.connection.endpoint_url; + } + + var regionBadge = row.querySelector('.badge.bg-primary'); + if (regionBadge) regionBadge.textContent = data.connection.region; + + var accessCode = row.querySelector('code.small'); + if (accessCode && data.connection.access_key) { + var ak = data.connection.access_key; + accessCode.textContent = ak.slice(0, 8) + '...' + ak.slice(-4); + } + + var editBtn = row.querySelector('[data-bs-target="#editConnectionModal"]'); + if (editBtn) { + editBtn.setAttribute('data-name', data.connection.name); + editBtn.setAttribute('data-endpoint', data.connection.endpoint_url); + editBtn.setAttribute('data-region', data.connection.region); + editBtn.setAttribute('data-access', data.connection.access_key); + if (data.connection.secret_key) { + editBtn.setAttribute('data-secret', data.connection.secret_key); + } + } + + var deleteBtn = row.querySelector('[data-bs-target="#deleteConnectionModal"]'); + if (deleteBtn) { + deleteBtn.setAttribute('data-name', data.connection.name); + } + + var statusEl = row.querySelector('.connection-status'); + if (statusEl) { + checkConnectionHealth(connId, statusEl); + } + } + } + }); + }); + } + + var deleteForm = document.getElementById('deleteConnectionForm'); + if (deleteForm) { + deleteForm.addEventListener('submit', function(e) { + e.preventDefault(); + window.UICore.submitFormAjax(deleteForm, { + successMessage: 'Connection deleted', + onSuccess: function(data) { + var modal = bootstrap.Modal.getInstance(document.getElementById('deleteConnectionModal')); + if (modal) modal.hide(); + + var connId = deleteForm.action.split('/').slice(-2)[0]; + var row = document.querySelector('tr[data-connection-id="' + connId + '"]'); + if (row) { + row.remove(); + } + + updateConnectionCount(); + + if (document.querySelectorAll('tr[data-connection-id]').length === 0) { + location.reload(); + } + } + }); + }); + } + } + + return { + init: init, + togglePassword: togglePassword, + testConnection: testConnection, + checkConnectionHealth: checkConnectionHealth + }; +})(); diff --git a/static/js/iam-management.js b/static/js/iam-management.js new file mode 100644 index 0000000..11b41fb --- /dev/null +++ b/static/js/iam-management.js @@ -0,0 +1,545 @@ +window.IAMManagement = (function() { + 'use strict'; + + var users = []; + var currentUserKey = null; + var endpoints = {}; + var csrfToken = ''; + var iamLocked = false; + + var policyModal = null; + var editUserModal = null; + var deleteUserModal = null; + var rotateSecretModal = null; + var currentRotateKey = null; + var currentEditKey = null; + var currentDeleteKey = null; + + var policyTemplates = { + full: [{ bucket: '*', actions: ['list', 'read', 'write', 'delete', 'share', 'policy', 'replication', 'iam:list_users', 'iam:*'] }], + readonly: [{ bucket: '*', actions: ['list', 'read'] }], + writer: [{ bucket: '*', actions: ['list', 'read', 'write'] }] + }; + + function init(config) { + users = config.users || []; + currentUserKey = config.currentUserKey || null; + endpoints = config.endpoints || {}; + csrfToken = config.csrfToken || ''; + iamLocked = config.iamLocked || false; + + if (iamLocked) return; + + initModals(); + setupJsonAutoIndent(); + setupCopyButtons(); + setupPolicyEditor(); + setupCreateUserModal(); + setupEditUserModal(); + setupDeleteUserModal(); + setupRotateSecretModal(); + setupFormHandlers(); + } + + function initModals() { + var policyModalEl = document.getElementById('policyEditorModal'); + var editModalEl = document.getElementById('editUserModal'); + var deleteModalEl = document.getElementById('deleteUserModal'); + var rotateModalEl = document.getElementById('rotateSecretModal'); + + if (policyModalEl) policyModal = new bootstrap.Modal(policyModalEl); + if (editModalEl) editUserModal = new bootstrap.Modal(editModalEl); + if (deleteModalEl) deleteUserModal = new bootstrap.Modal(deleteModalEl); + if (rotateModalEl) rotateSecretModal = new bootstrap.Modal(rotateModalEl); + } + + function setupJsonAutoIndent() { + window.UICore.setupJsonAutoIndent(document.getElementById('policyEditorDocument')); + window.UICore.setupJsonAutoIndent(document.getElementById('createUserPolicies')); + } + + function setupCopyButtons() { + document.querySelectorAll('.config-copy').forEach(function(button) { + button.addEventListener('click', async function() { + var targetId = button.dataset.copyTarget; + var target = document.getElementById(targetId); + if (!target) return; + await window.UICore.copyToClipboard(target.innerText, button, 'Copy JSON'); + }); + }); + + var secretCopyButton = document.querySelector('[data-secret-copy]'); + if (secretCopyButton) { + secretCopyButton.addEventListener('click', async function() { + var secretInput = document.getElementById('disclosedSecretValue'); + if (!secretInput) return; + await window.UICore.copyToClipboard(secretInput.value, secretCopyButton, 'Copy'); + }); + } + } + + function getUserPolicies(accessKey) { + var user = users.find(function(u) { return u.access_key === accessKey; }); + return user ? JSON.stringify(user.policies, null, 2) : ''; + } + + function applyPolicyTemplate(name, textareaEl) { + if (policyTemplates[name] && textareaEl) { + textareaEl.value = JSON.stringify(policyTemplates[name], null, 2); + } + } + + function setupPolicyEditor() { + var userLabelEl = document.getElementById('policyEditorUserLabel'); + var userInputEl = document.getElementById('policyEditorUser'); + var textareaEl = document.getElementById('policyEditorDocument'); + + document.querySelectorAll('[data-policy-template]').forEach(function(button) { + button.addEventListener('click', function() { + applyPolicyTemplate(button.dataset.policyTemplate, textareaEl); + }); + }); + + document.querySelectorAll('[data-policy-editor]').forEach(function(button) { + button.addEventListener('click', function() { + var key = button.getAttribute('data-access-key'); + if (!key) return; + + userLabelEl.textContent = key; + userInputEl.value = key; + textareaEl.value = getUserPolicies(key); + + policyModal.show(); + }); + }); + } + + function setupCreateUserModal() { + var createUserPoliciesEl = document.getElementById('createUserPolicies'); + + document.querySelectorAll('[data-create-policy-template]').forEach(function(button) { + button.addEventListener('click', function() { + applyPolicyTemplate(button.dataset.createPolicyTemplate, createUserPoliciesEl); + }); + }); + } + + function setupEditUserModal() { + var editUserForm = document.getElementById('editUserForm'); + var editUserDisplayName = document.getElementById('editUserDisplayName'); + + document.querySelectorAll('[data-edit-user]').forEach(function(btn) { + btn.addEventListener('click', function() { + var key = btn.dataset.editUser; + var name = btn.dataset.displayName; + currentEditKey = key; + editUserDisplayName.value = name; + editUserForm.action = endpoints.updateUser.replace('ACCESS_KEY', key); + editUserModal.show(); + }); + }); + } + + function setupDeleteUserModal() { + var deleteUserForm = document.getElementById('deleteUserForm'); + var deleteUserLabel = document.getElementById('deleteUserLabel'); + var deleteSelfWarning = document.getElementById('deleteSelfWarning'); + + document.querySelectorAll('[data-delete-user]').forEach(function(btn) { + btn.addEventListener('click', function() { + var key = btn.dataset.deleteUser; + currentDeleteKey = key; + deleteUserLabel.textContent = key; + deleteUserForm.action = endpoints.deleteUser.replace('ACCESS_KEY', key); + + if (key === currentUserKey) { + deleteSelfWarning.classList.remove('d-none'); + } else { + deleteSelfWarning.classList.add('d-none'); + } + + deleteUserModal.show(); + }); + }); + } + + function setupRotateSecretModal() { + var rotateUserLabel = document.getElementById('rotateUserLabel'); + var confirmRotateBtn = document.getElementById('confirmRotateBtn'); + var rotateCancelBtn = document.getElementById('rotateCancelBtn'); + var rotateDoneBtn = document.getElementById('rotateDoneBtn'); + var rotateSecretConfirm = document.getElementById('rotateSecretConfirm'); + var rotateSecretResult = document.getElementById('rotateSecretResult'); + var newSecretKeyInput = document.getElementById('newSecretKey'); + var copyNewSecretBtn = document.getElementById('copyNewSecret'); + + document.querySelectorAll('[data-rotate-user]').forEach(function(btn) { + btn.addEventListener('click', function() { + currentRotateKey = btn.dataset.rotateUser; + rotateUserLabel.textContent = currentRotateKey; + + rotateSecretConfirm.classList.remove('d-none'); + rotateSecretResult.classList.add('d-none'); + confirmRotateBtn.classList.remove('d-none'); + rotateCancelBtn.classList.remove('d-none'); + rotateDoneBtn.classList.add('d-none'); + + rotateSecretModal.show(); + }); + }); + + if (confirmRotateBtn) { + confirmRotateBtn.addEventListener('click', async function() { + if (!currentRotateKey) return; + + window.UICore.setButtonLoading(confirmRotateBtn, true, 'Rotating...'); + + try { + var url = endpoints.rotateSecret.replace('ACCESS_KEY', currentRotateKey); + var response = await fetch(url, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'X-CSRFToken': csrfToken + } + }); + + if (!response.ok) { + var data = await response.json(); + throw new Error(data.error || 'Failed to rotate secret'); + } + + var data = await response.json(); + newSecretKeyInput.value = data.secret_key; + + rotateSecretConfirm.classList.add('d-none'); + rotateSecretResult.classList.remove('d-none'); + confirmRotateBtn.classList.add('d-none'); + rotateCancelBtn.classList.add('d-none'); + rotateDoneBtn.classList.remove('d-none'); + + } catch (err) { + if (window.showToast) { + window.showToast(err.message, 'Error', 'danger'); + } + rotateSecretModal.hide(); + } finally { + window.UICore.setButtonLoading(confirmRotateBtn, false); + } + }); + } + + if (copyNewSecretBtn) { + copyNewSecretBtn.addEventListener('click', async function() { + await window.UICore.copyToClipboard(newSecretKeyInput.value, copyNewSecretBtn, 'Copy'); + }); + } + + if (rotateDoneBtn) { + rotateDoneBtn.addEventListener('click', function() { + window.location.reload(); + }); + } + } + + function createUserCardHtml(accessKey, displayName, policies) { + var policyBadges = ''; + if (policies && policies.length > 0) { + policyBadges = policies.map(function(p) { + var actionText = p.actions && p.actions.includes('*') ? 'full' : (p.actions ? p.actions.length : 0); + return '' + + '' + + '' + + '' + window.UICore.escapeHtml(p.bucket) + + '(' + actionText + ')'; + }).join(''); + } else { + policyBadges = 'No policies'; + } + + return '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '' + + '' + + '
    ' + + '
    ' + + '
    ' + window.UICore.escapeHtml(displayName) + '
    ' + + '' + window.UICore.escapeHtml(accessKey) + '' + + '
    ' + + '
    ' + + '
    ' + + '
    Bucket Permissions
    ' + + '
    ' + policyBadges + '
    ' + + '' + + '
    '; + } + + function attachUserCardHandlers(cardElement, accessKey, displayName) { + var editBtn = cardElement.querySelector('[data-edit-user]'); + if (editBtn) { + editBtn.addEventListener('click', function() { + currentEditKey = accessKey; + document.getElementById('editUserDisplayName').value = displayName; + document.getElementById('editUserForm').action = endpoints.updateUser.replace('ACCESS_KEY', accessKey); + editUserModal.show(); + }); + } + + var deleteBtn = cardElement.querySelector('[data-delete-user]'); + if (deleteBtn) { + deleteBtn.addEventListener('click', function() { + currentDeleteKey = accessKey; + document.getElementById('deleteUserLabel').textContent = accessKey; + document.getElementById('deleteUserForm').action = endpoints.deleteUser.replace('ACCESS_KEY', accessKey); + var deleteSelfWarning = document.getElementById('deleteSelfWarning'); + if (accessKey === currentUserKey) { + deleteSelfWarning.classList.remove('d-none'); + } else { + deleteSelfWarning.classList.add('d-none'); + } + deleteUserModal.show(); + }); + } + + var rotateBtn = cardElement.querySelector('[data-rotate-user]'); + if (rotateBtn) { + rotateBtn.addEventListener('click', function() { + currentRotateKey = accessKey; + document.getElementById('rotateUserLabel').textContent = accessKey; + document.getElementById('rotateSecretConfirm').classList.remove('d-none'); + document.getElementById('rotateSecretResult').classList.add('d-none'); + document.getElementById('confirmRotateBtn').classList.remove('d-none'); + document.getElementById('rotateCancelBtn').classList.remove('d-none'); + document.getElementById('rotateDoneBtn').classList.add('d-none'); + rotateSecretModal.show(); + }); + } + + var policyBtn = cardElement.querySelector('[data-policy-editor]'); + if (policyBtn) { + policyBtn.addEventListener('click', function() { + document.getElementById('policyEditorUserLabel').textContent = accessKey; + document.getElementById('policyEditorUser').value = accessKey; + document.getElementById('policyEditorDocument').value = getUserPolicies(accessKey); + policyModal.show(); + }); + } + } + + function updateUserCount() { + var countEl = document.querySelector('.card-header .text-muted.small'); + if (countEl) { + var count = document.querySelectorAll('.iam-user-card').length; + countEl.textContent = count + ' user' + (count !== 1 ? 's' : '') + ' configured'; + } + } + + function setupFormHandlers() { + var createUserForm = document.querySelector('#createUserModal form'); + if (createUserForm) { + createUserForm.addEventListener('submit', function(e) { + e.preventDefault(); + window.UICore.submitFormAjax(createUserForm, { + successMessage: 'User created', + onSuccess: function(data) { + var modal = bootstrap.Modal.getInstance(document.getElementById('createUserModal')); + if (modal) modal.hide(); + createUserForm.reset(); + + var existingAlert = document.querySelector('.alert.alert-info.border-0.shadow-sm'); + if (existingAlert) existingAlert.remove(); + + if (data.secret_key) { + var alertHtml = ''; + var container = document.querySelector('.page-header'); + if (container) { + container.insertAdjacentHTML('afterend', alertHtml); + document.getElementById('copyNewUserSecret').addEventListener('click', async function() { + await window.UICore.copyToClipboard(data.secret_key, this, 'Copy'); + }); + } + } + + var usersGrid = document.querySelector('.row.g-3'); + var emptyState = document.querySelector('.empty-state'); + if (emptyState) { + var emptyCol = emptyState.closest('.col-12'); + if (emptyCol) emptyCol.remove(); + if (!usersGrid) { + var cardBody = document.querySelector('.card-body.px-4.pb-4'); + if (cardBody) { + cardBody.innerHTML = '
    '; + usersGrid = cardBody.querySelector('.row.g-3'); + } + } + } + + if (usersGrid) { + var cardHtml = createUserCardHtml(data.access_key, data.display_name, data.policies); + usersGrid.insertAdjacentHTML('beforeend', cardHtml); + var newCard = usersGrid.lastElementChild; + attachUserCardHandlers(newCard, data.access_key, data.display_name); + users.push({ + access_key: data.access_key, + display_name: data.display_name, + policies: data.policies || [] + }); + updateUserCount(); + } + } + }); + }); + } + + var policyEditorForm = document.getElementById('policyEditorForm'); + if (policyEditorForm) { + policyEditorForm.addEventListener('submit', function(e) { + e.preventDefault(); + var userInputEl = document.getElementById('policyEditorUser'); + var key = userInputEl.value; + if (!key) return; + + var template = policyEditorForm.dataset.actionTemplate; + policyEditorForm.action = template.replace('ACCESS_KEY_PLACEHOLDER', key); + + window.UICore.submitFormAjax(policyEditorForm, { + successMessage: 'Policies updated', + onSuccess: function(data) { + policyModal.hide(); + + var userCard = document.querySelector('[data-access-key="' + key + '"]'); + if (userCard) { + var badgeContainer = userCard.closest('.iam-user-card').querySelector('.d-flex.flex-wrap.gap-1'); + if (badgeContainer && data.policies) { + var badges = data.policies.map(function(p) { + return '' + + '' + + '' + + '' + window.UICore.escapeHtml(p.bucket) + + '(' + (p.actions.includes('*') ? 'full' : p.actions.length) + ')'; + }).join(''); + badgeContainer.innerHTML = badges || 'No policies'; + } + } + + var userIndex = users.findIndex(function(u) { return u.access_key === key; }); + if (userIndex >= 0 && data.policies) { + users[userIndex].policies = data.policies; + } + } + }); + }); + } + + var editUserForm = document.getElementById('editUserForm'); + if (editUserForm) { + editUserForm.addEventListener('submit', function(e) { + e.preventDefault(); + var key = currentEditKey; + window.UICore.submitFormAjax(editUserForm, { + successMessage: 'User updated', + onSuccess: function(data) { + editUserModal.hide(); + + var newName = data.display_name || document.getElementById('editUserDisplayName').value; + var editBtn = document.querySelector('[data-edit-user="' + key + '"]'); + if (editBtn) { + editBtn.setAttribute('data-display-name', newName); + var card = editBtn.closest('.iam-user-card'); + if (card) { + var nameEl = card.querySelector('h6'); + if (nameEl) { + nameEl.textContent = newName; + nameEl.title = newName; + } + } + } + + var userIndex = users.findIndex(function(u) { return u.access_key === key; }); + if (userIndex >= 0) { + users[userIndex].display_name = newName; + } + + if (key === currentUserKey) { + document.querySelectorAll('.sidebar-user .user-name').forEach(function(el) { + var truncated = newName.length > 16 ? newName.substring(0, 16) + '...' : newName; + el.textContent = truncated; + el.title = newName; + }); + document.querySelectorAll('.sidebar-user[data-username]').forEach(function(el) { + el.setAttribute('data-username', newName); + }); + } + } + }); + }); + } + + var deleteUserForm = document.getElementById('deleteUserForm'); + if (deleteUserForm) { + deleteUserForm.addEventListener('submit', function(e) { + e.preventDefault(); + var key = currentDeleteKey; + window.UICore.submitFormAjax(deleteUserForm, { + successMessage: 'User deleted', + onSuccess: function(data) { + deleteUserModal.hide(); + + if (key === currentUserKey) { + window.location.href = '/ui/'; + return; + } + + var deleteBtn = document.querySelector('[data-delete-user="' + key + '"]'); + if (deleteBtn) { + var cardCol = deleteBtn.closest('[class*="col-"]'); + if (cardCol) { + cardCol.remove(); + } + } + + users = users.filter(function(u) { return u.access_key !== key; }); + updateUserCount(); + } + }); + }); + } + } + + return { + init: init + }; +})(); diff --git a/static/js/ui-core.js b/static/js/ui-core.js new file mode 100644 index 0000000..a69d7ef --- /dev/null +++ b/static/js/ui-core.js @@ -0,0 +1,324 @@ +window.UICore = (function() { + 'use strict'; + + function getCsrfToken() { + const meta = document.querySelector('meta[name="csrf-token"]'); + return meta ? meta.getAttribute('content') : ''; + } + + function formatBytes(bytes) { + if (!Number.isFinite(bytes)) return bytes + ' bytes'; + const units = ['bytes', 'KB', 'MB', 'GB', 'TB']; + let i = 0; + let size = bytes; + while (size >= 1024 && i < units.length - 1) { + size /= 1024; + i++; + } + return size.toFixed(i === 0 ? 0 : 1) + ' ' + units[i]; + } + + function escapeHtml(value) { + if (value === null || value === undefined) return ''; + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + async function submitFormAjax(form, options) { + options = options || {}; + var onSuccess = options.onSuccess || function() {}; + var onError = options.onError || function() {}; + var successMessage = options.successMessage || 'Operation completed'; + + var formData = new FormData(form); + var csrfToken = getCsrfToken(); + var submitBtn = form.querySelector('[type="submit"]'); + var originalHtml = submitBtn ? submitBtn.innerHTML : ''; + + try { + if (submitBtn) { + submitBtn.disabled = true; + submitBtn.innerHTML = 'Saving...'; + } + + var formAction = form.getAttribute('action') || form.action; + var response = await fetch(formAction, { + method: form.getAttribute('method') || 'POST', + headers: { + 'X-CSRFToken': csrfToken, + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + }, + body: formData, + redirect: 'follow' + }); + + var contentType = response.headers.get('content-type') || ''; + if (!contentType.includes('application/json')) { + throw new Error('Server returned an unexpected response. Please try again.'); + } + + var data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'HTTP ' + response.status); + } + + window.showToast(data.message || successMessage, 'Success', 'success'); + onSuccess(data); + + } catch (err) { + window.showToast(err.message, 'Error', 'error'); + onError(err); + } finally { + if (submitBtn) { + submitBtn.disabled = false; + submitBtn.innerHTML = originalHtml; + } + } + } + + function PollingManager() { + this.intervals = {}; + this.callbacks = {}; + this.timers = {}; + this.defaults = { + replication: 30000, + lifecycle: 60000, + connectionHealth: 60000, + bucketStats: 120000 + }; + this._loadSettings(); + } + + PollingManager.prototype._loadSettings = function() { + try { + var stored = localStorage.getItem('myfsio-polling-intervals'); + if (stored) { + var settings = JSON.parse(stored); + for (var key in settings) { + if (settings.hasOwnProperty(key)) { + this.defaults[key] = settings[key]; + } + } + } + } catch (e) { + console.warn('Failed to load polling settings:', e); + } + }; + + PollingManager.prototype.saveSettings = function(settings) { + try { + for (var key in settings) { + if (settings.hasOwnProperty(key)) { + this.defaults[key] = settings[key]; + } + } + localStorage.setItem('myfsio-polling-intervals', JSON.stringify(this.defaults)); + } catch (e) { + console.warn('Failed to save polling settings:', e); + } + }; + + PollingManager.prototype.start = function(key, callback, interval) { + this.stop(key); + var ms = interval !== undefined ? interval : (this.defaults[key] || 30000); + if (ms <= 0) return; + + this.callbacks[key] = callback; + this.intervals[key] = ms; + + callback(); + + var self = this; + this.timers[key] = setInterval(function() { + if (!document.hidden) { + callback(); + } + }, ms); + }; + + PollingManager.prototype.stop = function(key) { + if (this.timers[key]) { + clearInterval(this.timers[key]); + delete this.timers[key]; + } + }; + + PollingManager.prototype.stopAll = function() { + for (var key in this.timers) { + if (this.timers.hasOwnProperty(key)) { + clearInterval(this.timers[key]); + } + } + this.timers = {}; + }; + + PollingManager.prototype.updateInterval = function(key, newInterval) { + var callback = this.callbacks[key]; + this.defaults[key] = newInterval; + this.saveSettings(this.defaults); + if (callback) { + this.start(key, callback, newInterval); + } + }; + + PollingManager.prototype.getSettings = function() { + var result = {}; + for (var key in this.defaults) { + if (this.defaults.hasOwnProperty(key)) { + result[key] = this.defaults[key]; + } + } + return result; + }; + + var pollingManager = new PollingManager(); + + document.addEventListener('visibilitychange', function() { + if (document.hidden) { + pollingManager.stopAll(); + } else { + for (var key in pollingManager.callbacks) { + if (pollingManager.callbacks.hasOwnProperty(key)) { + pollingManager.start(key, pollingManager.callbacks[key], pollingManager.intervals[key]); + } + } + } + }); + + return { + getCsrfToken: getCsrfToken, + formatBytes: formatBytes, + escapeHtml: escapeHtml, + submitFormAjax: submitFormAjax, + PollingManager: PollingManager, + pollingManager: pollingManager + }; +})(); + +window.pollingManager = window.UICore.pollingManager; + +window.UICore.copyToClipboard = async function(text, button, originalText) { + try { + await navigator.clipboard.writeText(text); + if (button) { + var prevText = button.textContent; + button.textContent = 'Copied!'; + setTimeout(function() { + button.textContent = originalText || prevText; + }, 1500); + } + return true; + } catch (err) { + console.error('Copy failed:', err); + return false; + } +}; + +window.UICore.setButtonLoading = function(button, isLoading, loadingText) { + if (!button) return; + if (isLoading) { + button._originalHtml = button.innerHTML; + button._originalDisabled = button.disabled; + button.disabled = true; + button.innerHTML = '' + (loadingText || 'Loading...'); + } else { + button.disabled = button._originalDisabled || false; + button.innerHTML = button._originalHtml || button.innerHTML; + } +}; + +window.UICore.updateBadgeCount = function(selector, count, singular, plural) { + var badge = document.querySelector(selector); + if (badge) { + var label = count === 1 ? (singular || '') : (plural || 's'); + badge.textContent = count + ' ' + label; + } +}; + +window.UICore.setupJsonAutoIndent = function(textarea) { + if (!textarea) return; + + textarea.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + e.preventDefault(); + + var start = this.selectionStart; + var end = this.selectionEnd; + var value = this.value; + + var lineStart = value.lastIndexOf('\n', start - 1) + 1; + var currentLine = value.substring(lineStart, start); + + var indentMatch = currentLine.match(/^(\s*)/); + var indent = indentMatch ? indentMatch[1] : ''; + + var trimmedLine = currentLine.trim(); + var lastChar = trimmedLine.slice(-1); + + var newIndent = indent; + var insertAfter = ''; + + if (lastChar === '{' || lastChar === '[') { + newIndent = indent + ' '; + + var charAfterCursor = value.substring(start, start + 1).trim(); + if ((lastChar === '{' && charAfterCursor === '}') || + (lastChar === '[' && charAfterCursor === ']')) { + insertAfter = '\n' + indent; + } + } else if (lastChar === ',' || lastChar === ':') { + newIndent = indent; + } + + var insertion = '\n' + newIndent + insertAfter; + var newValue = value.substring(0, start) + insertion + value.substring(end); + + this.value = newValue; + + var newCursorPos = start + 1 + newIndent.length; + this.selectionStart = this.selectionEnd = newCursorPos; + + this.dispatchEvent(new Event('input', { bubbles: true })); + } + + if (e.key === 'Tab') { + e.preventDefault(); + var start = this.selectionStart; + var end = this.selectionEnd; + + if (e.shiftKey) { + var lineStart = this.value.lastIndexOf('\n', start - 1) + 1; + var lineContent = this.value.substring(lineStart, start); + if (lineContent.startsWith(' ')) { + this.value = this.value.substring(0, lineStart) + + this.value.substring(lineStart + 2); + this.selectionStart = this.selectionEnd = Math.max(lineStart, start - 2); + } + } else { + this.value = this.value.substring(0, start) + ' ' + this.value.substring(end); + this.selectionStart = this.selectionEnd = start + 2; + } + + this.dispatchEvent(new Event('input', { bubbles: true })); + } + }); +}; + +document.addEventListener('DOMContentLoaded', function() { + var flashMessage = sessionStorage.getItem('flashMessage'); + if (flashMessage) { + sessionStorage.removeItem('flashMessage'); + try { + var msg = JSON.parse(flashMessage); + if (window.showToast) { + window.showToast(msg.body || msg.title, msg.title, msg.variant || 'info'); + } + } catch (e) {} + } +}); diff --git a/templates/base.html b/templates/base.html index c3363d1..146acc7 100644 --- a/templates/base.html +++ b/templates/base.html @@ -393,6 +393,8 @@ {% endwith %} })(); + {% block extra_scripts %}{% endblock %} + diff --git a/templates/bucket_detail.html b/templates/bucket_detail.html index be0121f..765476b 100644 --- a/templates/bucket_detail.html +++ b/templates/bucket_detail.html @@ -146,6 +146,7 @@ data-bucket="{{ bucket_name }}" data-versioning="{{ 'true' if versioning_enabled else 'false' }}" data-objects-api="{{ objects_api_url }}" + data-objects-stream="{{ objects_stream_url }}" data-bulk-delete-endpoint="{{ url_for('ui.bulk_delete_objects', bucket_name=bucket_name) }}" data-bulk-download-endpoint="{{ url_for('ui.bulk_download_objects', bucket_name=bucket_name) }}" data-folders-url="{{ folders_url }}" @@ -473,15 +474,13 @@ Save Policy - {% if bucket_policy %} - - {% endif %} {% else %} @@ -636,7 +635,7 @@ Suspend Versioning {% else %} -
    + - {% if has_quota %} - - {% endif %}
    {% else %} @@ -1536,9 +1534,20 @@ {% if can_edit_policy %}
    + {% if not lifecycle_enabled %} + + {% endif %}
    -
    +
    @@ -1547,7 +1556,7 @@ Lifecycle Rules
    -
    - +
    + + +
    @@ -2020,7 +2042,7 @@
    - + @@ -81,13 +79,13 @@
    -

    {{ disk.percent }}%

    +

    {{ disk.percent }}%

    -
    +
    - {{ disk.free }} free - {{ disk.total }} total + {{ disk.free }} free + {{ disk.total }} total
    @@ -104,15 +102,15 @@ -

    {{ app.storage_used }}

    +

    {{ app.storage_used }}

    -
    {{ app.buckets }}
    +
    {{ app.buckets }}
    Buckets
    -
    {{ app.objects }}
    +
    {{ app.objects }}
    Objects
    @@ -270,3 +268,109 @@ {% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/tests/test_ui_pagination.py b/tests/test_ui_pagination.py index 683f527..5d5bee0 100644 --- a/tests/test_ui_pagination.py +++ b/tests/test_ui_pagination.py @@ -184,5 +184,5 @@ class TestPaginatedObjectListing: assert resp.status_code == 200 html = resp.data.decode("utf-8") - # Should have the JavaScript loading infrastructure - assert "loadObjects" in html or "objectsApiUrl" in html + # Should have the JavaScript loading infrastructure (external JS file) + assert "bucket-detail-main.js" in html