commit e91b9f3dc30342ce9978472499c941b56de3aa72 Author: Joe Rozner Date: Wed May 13 12:13:59 2020 -0700 Prepare open source release diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..85b8e82 --- /dev/null +++ b/.clang-format @@ -0,0 +1,7 @@ +BasedOnStyle: Google +ColumnLimit: 100 +# Not sure how to get BreakBeforeBraces: BS_Stroustrup to work -- this should hopefully be roughly equivalent +BreakBeforeBraces: Custom +BraceWrapping: + BeforeCatch: true + BeforeElse: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..792aaff --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# IDE Projects/data +*.pro.user +.vscode diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..4dc52a9 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "tools/UGlobalHotkey"] + path = tools/UGlobalHotkey + url = git@github.com:JoelAtDeluxe/UGlobalHotkey.git diff --git a/Code-of-Conduct.md b/Code-of-Conduct.md new file mode 100644 index 0000000..0061f8e --- /dev/null +++ b/Code-of-Conduct.md @@ -0,0 +1,52 @@ +# Verizon Media Open Source Code of Conduct + +## Summary +This Code of Conduct is our way to encourage good behavior and discourage bad behavior in our open source projects. We invite participation from many people to bring different perspectives to our projects. We will do our part to foster a welcoming and professional environment free of harassment. We expect participants to communicate professionally and thoughtfully during their involvement with this project. + +Participants may lose their good standing by engaging in misconduct. For example: insulting, threatening, or conveying unwelcome sexual content. We ask participants who observe conduct issues to report the incident directly to the project's Response Team at opensource-conduct@verizonmedia.com. Verizon Media will assign a respondent to address the issue. We may remove harassers from this project. + +This code does not replace the terms of service or acceptable use policies of the websites used to support this project. We acknowledge that participants may be subject to additional conduct terms based on their employment which may govern their online expressions. + +## Details +This Code of Conduct makes our expectations of participants in this community explicit. +* We forbid harassment and abusive speech within this community. +* We request participants to report misconduct to the project’s Response Team. +* We urge participants to refrain from using discussion forums to play out a fight. + +### Expected Behaviors +We expect participants in this community to conduct themselves professionally. Since our primary mode of communication is text on an online forum (e.g. issues, pull requests, comments, emails, or chats) devoid of vocal tone, gestures, or other context that is often vital to understanding, it is important that participants are attentive to their interaction style. + +* **Assume positive intent.** We ask community members to assume positive intent on the part of other people’s communications. We may disagree on details, but we expect all suggestions to be supportive of the community goals. +* **Respect participants.** We expect occasional disagreements. Open Source projects are learning experiences. Ask, explore, challenge, and then _respectfully_ state if you agree or disagree. If your idea is rejected, be more persuasive not bitter. +* **Welcoming to new members.** New members bring new perspectives. Some ask questions that have been addressed before. _Kindly_ point to existing discussions. Everyone is new to every project once. +* **Be kind to beginners.** Beginners use open source projects to get experience. They might not be talented coders yet, and projects should not accept poor quality code. But we were all beginners once, and we need to engage kindly. +* **Consider your impact on others.** Your work will be used by others, and you depend on the work of others. We expect community members to be considerate and establish a balance their self-interest with communal interest. +* **Use words carefully.** We may not understand intent when you say something ironic. Often, people will misinterpret sarcasm in online communications. We ask community members to communicate plainly. +* **Leave with class.** When you wish to resign from participating in this project for any reason, you are free to fork the code and create a competitive project. Open Source explicitly allows this. Your exit should not be dramatic or bitter. + +### Unacceptable Behaviors +Participants remain in good standing when they do not engage in misconduct or harassment (some examples follow). We do not list all forms of harassment, nor imply some forms of harassment are not worthy of action. Any participant who *feels* harassed or *observes* harassment, should report the incident to the Response Team. +* **Don't be a bigot.** Calling out project members by their identity or background in a negative or insulting manner. This includes, but is not limited to, slurs or insinuations related to protected or suspect classes e.g. race, color, citizenship, national origin, political belief, religion, sexual orientation, gender identity and expression, age, size, culture, ethnicity, genetic features, language, profession, national minority status, mental or physical ability. +* **Don't insult.** Insulting remarks about a person’s lifestyle practices. +* **Don't dox.** Revealing private information about other participants without explicit permission. +* **Don't intimidate.** Threats of violence or intimidation of any project member. +* **Don't creep.** Unwanted sexual attention or content unsuited for the subject of this project. +* **Don't inflame.** We ask that victim of harassment not address their grievances in the public forum, as this often intensifies the problem. Report it, and let us address it off-line. +* **Don't disrupt.** Sustained disruptions in a discussion. + +### Reporting Issues +If you experience or witness misconduct, or have any other concerns about the conduct of members of this project, please report it by contacting our Response Team at opensource-conduct@verizonmedia.com who will handle your report with discretion. Your report should include: +* Your preferred contact information. We cannot process anonymous reports. +* Names (real or usernames) of those involved in the incident. +* Your account of what occurred, and if the incident is ongoing. Please provide links to or transcripts of the publicly available records (e.g. a mailing list archive or a public IRC logger), so that we can review it. +* Any additional information that may be helpful to achieve resolution. + +After filing a report, a representative will contact you directly to review the incident and ask additional questions. If a member of the Verizon Media Response Team is named in an incident report, that member will be recused from handling your incident. If the complaint originates from a member of the Response Team, it will be addressed by a different member of the Response Team. We will consider reports to be confidential for the purpose of protecting victims of abuse. + +### Scope +Verizon Media will assign a Response Team member with admin rights on the project and legal rights on the project copyright. The Response Team is empowered to restrict some privileges to the project as needed. Since this project is governed by an open source license, any participant may fork the code under the terms of the project license. The Response Team’s goal is to preserve the project if possible, and will restrict or remove participation from those who disrupt the project. + +This code does not replace the terms of service or acceptable use policies that are provided by the websites used to support this community. Nor does this code apply to communications or actions that take place outside of the context of this community. Many participants in this project are also subject to codes of conduct based on their employment. This code is a social-contract that informs participants of our social expectations. It is not a terms of service or legal contract. + +## License and Acknowledgment. +This text is shared under the [CC-BY-4.0 license](https://creativecommons.org/licenses/by/4.0/). This code is based on a study conducted by the [TODO Group](https://todogroup.org/) of many codes used in the open source community. If you have feedback about this code, contact our Response Team at the address listed above. diff --git a/Contributing.md b/Contributing.md new file mode 100644 index 0000000..645cf3f --- /dev/null +++ b/Contributing.md @@ -0,0 +1,26 @@ +# How to contribute +First, thanks for taking the time to contribute to our project! There are many ways you can help out. + +### Questions + +If you have a question that needs an answer, [create an issue](https://help.github.com/articles/creating-an-issue/), and label it as a question. + +### Issues for bugs or feature requests + +If you encounter any bugs in the code, or want to request a new feature or enhancement, please [create an issue](https://help.github.com/articles/creating-an-issue/) to report it. Kindly add a label to indicate what type of issue it is. + +### Contribute Code +We welcome your pull requests for bug fixes. To implement something new, please create an issue first so we can discuss it together. + +***Creating a Pull Request*** +Please follow [best practices](https://github.com/trein/dev-best-practices/wiki/Git-Commit-Best-Practices) for creating git commits. + +When your code is ready to be submitted, [submit a pull request](https://help.github.com/articles/creating-a-pull-request/) to begin the code review process. + +We only seek to accept code that you are authorized to contribute to the project. We have added a pull request template on our projects so that your contributions are made with the following confirmation: + +> I confirm that this contribution is made under the terms of the license found in the root directory of this repository's source tree and that I have the authority necessary to make this contribution on behalf of its copyright owner. + +## Code of Conduct + +We encourage inclusive and professional interactions on our project. We welcome everyone to open an issue, improve the documentation, report bug or ssubmit a pull request. By participating in this project, you agree to abide by the [Verizon Media Code of Conduct](Code-of-Conduct.md). If you feel there is a conduct issue related to this project, please raise it per the Code of Conduct process and we will address it. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ce6a4c0 --- /dev/null +++ b/README.md @@ -0,0 +1,163 @@ +# AScreen + +A Qt tray-type application that allows for capturing (via user-selectable area or entire window) screenshots, or codeblocks associated with a particular ASHIRT instance. + +## Table of Contents + +- [Background](#background) +- [Install](#install) +- [Configuration](#configuration) +- [Contribute](#contribute) +- [License](#license) + +## Background + +This application allows users to connect to a remote ASHIRT backend and create and submit new evidence. Screenshots are taken using a custom, user-defined key, or alternately by selecting the appropriate action in the tray menu. Codeblocks can be added via an action in the tray. Both can be managed from within the application. + +## Install + +Official releases for Mac and Linux will be provided via the releases tab in GitHub along with source code for users to build themselves if desired. + +## Non-tray OSes + +Current Status: Non-functional + +Some OSes/desktops do not support a tray (e.g. ice3 window manager). Currently, in these cases, the application will not work, and simply exit instead. Eventually, a simple CLI will be set up to continue to interact with this application. + +## Getting Started + +On the first launch, the user must first set up an appropriate configuration. When the tray displays, open the tray and select `Settings`. From here, you will be presented with some options: + +| Field | Meaning | +| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| Evidence Repository | Where evidence is stored. Note that this is a jumping off point. Files are stored in a subdirectory using the operation name | +| Access Key | The (shorter) base-64 key given by the AShirt front end (look for this in Account Settings) | +| Secret Key | The (longer) base-64 key given by the AShirt front end | +| Host Path | The http location to the AShirt server | +| Capture Area Command | The CLI command to take a screenshot of an arbitrary area and save to a file. More on this below | +| [Capture Area Command] Shortcut | The key combination used (at a system level) to trigger the capture area command | +| Capture Window Command | The CLI command to take of a given window, and save to a file | +| [Capture Area Command] Shortcut | The key combination used (at a system level) to trigger the capture window command | + +Once the above is configured, save the settings and you can now select an operation. Open the tray, and under `Select Operation`, choose an operation to start using the application. (Note: you may need to choose `Refresh Operations` in the submenu) + +## Screenshot Commands + +This application requires taking screenshots from the command line. The application _must_: + +1. Allow for saving the screenshot to a named file. +2. Create the file _must_ before the application exits. + +Theorectically, any application that satisfies this requirement will work. For Mac, the system command to do this is pre-populated, since this is a standard feature. For Linux, there are a number of screenshot commands, and so none are provided. For Windows, a 3rd party application must be used, as there is currently no way to save a screenshot to a named file. + +This tool will replace the above filename with `%file` as noted below: + +| OS/DE/App | Capture Window | Capture Area | Notes | +| ----------- | ---------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| Linux/Gnome | gnome-screenshot -w -f %file | gnome-screenshot -a -f %file | Capture window captures the focused window, rather than allowing a selection; adding the `--delay` flag can help mitigate choosing the wrong window | +| MacOS X | screencapture -w %file | screencapture -s %file | | + +Note: this application expects a _single, basic command_. While piping output to another command _may_ work, it is not guaranteed. Likewise, providing multiple commands on the same "line" _may_ work, but is also not guaranteed. Offically, both of these techniques are unsupported. + +### Shortcuts + +Global shortcut keys can be registered with your computer, depending on the exact operating system. These shortcuts may conflict with shortcuts for a given application, where it is unclear which shortcut will trigger. All this is to say that this feature, while supported, may not work perfectly every time. That said, here is how you configure shortcuts: + +Within `Settings` next to each capture command is a small text box to provide the shortcut. Each shortcut should add in one or more modifier keys (e.g. `ctrl`) in order to provide less of a chance to interfere with other system/application commands. These modifier keys have reserved names for shortcuts, noted in the below table: + +| Key | Name | Alternate Names | +| ------------------------------ | --------- | --------------- | +| Shift | `shift` | `shft` | +| Control | `control` | `ctrl` | +| Alt | `alt` | -- | +| Windows/Meta/MacOS Command key | `meta` | `win` | + +To specify a shortcut pattern, simply decide on what set of modifier keys you want, plus a single alphanumeric key (or F- key), and separate these by `+`. + +E.g. `Ctrl+Shift+p` + +## Switching and Pausing Operations + +Evidence is tied to an operation, and when an operation is paused, or inactive, then the hotkey commands will not work. It may be useful to pause an operation (within this application) once you know you will not be taking additional screenshots. Simply navigate to `Select Operation` > `Pause Operation` to pause an operation, and go to the same place (now called `Enable Operation`) to resume this application. Note: pausing an operation a really more of a pause of this application. You can still use the AShirt website. However, when switching operations, if you were previously in a paused state, you will remain in a paused state. + +To change operations, similarly to pause operation, navigate to `Select Operation` and choose one of the operations exposed in the list. If the operation you are looking for is not in the list, try pressing the `Refresh Operations`, or check with the operation owner to ensure that you have write access to that operation. + +## Managing Evidence + +Previous evidence can be reviewed by navigating to `View Accumulated Evidence`, which will present a screen showing evidence for the current operation. Selecting a row in the evidence list will show: + +- A preview of the evidence (Images can be scaled by changing the window size, or my shrinking the description box -- mouse over the divider separating the description from the image) +- The description of the evidence +- Any (active) tags associated with the evidence. + +From here you can submit the evidence, if not already submitted. Or, you may delete the file (even if previously submitted -- doing so will remove the file locally, but keep the website copy) + +### Filtering Evidence + +Filtering can be done by specifying items in `key:value` format. Multiple filters can be added by adding a space between each filter. Keys and values are case insensitive. + +| Action | Key | Values | Alias(es) | Notes | +| ----------------------------------------- | ----------- | -------------------------------------------------- | ------------------------- | ------------------------------------------------------------------ | +| Show submit errors | `err` | `t`/`f`, or `y`/`n` | `error`, `fail`, `failed` | Also works with `true`/`false` `yes`/`no` | +| Show evidence for operations | `op` | operation slug | `operation` | Pre-populated with current operation, when reset button is pressed | +| Show evidence taken _before_ a given date | `before` | `today`, `yesterday` or date in yyyy-MM-dd format, | `to`, `til`, `until` | Starts at midnight of the given day | +| Show evidence taken _after_ a given date | `after` | `today`, `yesterday` or date in yyyy-MM-dd format, | `from` | Start just before midnight of the _next_ given day | +| Show evidence taken _on_ a given date | `on` | `today`, `yesterday` or date in yyyy-MM-dd format, | -- | | +| Show evidence that has not been submitted | `submitted` | `t`/`f`, or `y`/`n` | -- | Also works with `true`/`false`, `yes`/`no` | + +#### Date filtering + +When trying to apply both a "before" date and "after" date filter, the system will adjust the times so that the "before" date is always _after_ the "after" date. Meaning, the timespan must be inclusive. For example, a range of "before March" and "after May" (excluding March and April) is not valid, and will be revised to "After March, Before May" + +When applying only one date, the range is unbounded on the other end. That is, dates are implicily "from the start of time" to "until the end of time" + +## Local Files + +You should never need to access these files outside of the application, however, for clarity, the following files are generate and maintained by this application: + +| File type | Path | Notes | +| -------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| Screenshots | `$eviRepo/$operationSlug/ashirt_screenshot_$randomCharacters.png` | Presently, random (english) characters tacked on to the end of a screenshot, to add uniqueness and prevent overwriting | +| Codeblocks | `$eviRepo/$operationSlug/ashirt_codeblock_$randomCharacters.json` | Presently, random (english) characters tacked on to the end of the codeblock filename, to add uniqueness and prevent overwriting | +| Configuration | `$userSettingsDirectory/ashirt/screenshot.json` | Manages connection info / configuration in "settings" menu | +| Local Database | `$userSettingsDirectory/ashirt/screenshot.sqlite` | | +| Settings | `$userSettingsDirectory/Verizon Media Group/AShirt Screenshot.conf` | Manages state info -- e.g. last used operation ; Managed by Qt | + +### Variable locations + +The above paths reference some variables. Some of these values change depending on what operating system is being used (or in how it is configured). The exact paths are unknown, but this may help you find these files: + +| Path Variable | Notes | +| ------------------------ | -------------------------------------------------------------------------------------------------------------- | +| `$userSettingsDirectory` | Where user-specific configuration/settings files are stored. | +| [For Linux] | On the command line, run `echo $XDG_CONFIG_HOME` | +| [For Mac OSX] | Check `/Users/(username)/Library/Preferences` | +| [For windows] | This may be in the System Registry | +| `$eviRepo` | The Evidence Repository value in the "settings" window | +| `$operationSlug` | The operation slug for a given operation. This is a unique representation of an operation name | +| `$randomCharacters` | Six random english characters, case-insensitive (for those operating systems that support this). e.g. `fTaNpS` | + +## Developer Notes + +Interested in contributing? See the [developer notes](Readme_Developer.md) for style guide, organization, etc + +## Configuration + +All configuration options are managed through the application UI. + +## Contribute + +Please refer to [the contributing.md file](Contributing.md) for information about how to get involved. We welcome issues, questions, and pull requests. + +## License + +GPL-3.0-or-later + +## Credits / Contributors / Thanks + +- Joel Smith +- Alex David + +## Maintainers + +- Joe Rozner joe.rozer@verizonmedia.com diff --git a/Readme_Developer.md b/Readme_Developer.md new file mode 100644 index 0000000..af55c4a --- /dev/null +++ b/Readme_Developer.md @@ -0,0 +1,60 @@ +# Developer Notes + +## Build Requirements + +This application is built off of Qt 5, and utilizes's Qt's networking and Sql featuresets. To build, your specific system may need the following: + +1. Qt 5, `qmake`, and possibly Qt Creator IDE. + 1. Binaries located [here](https://www.qt.io/download-qt-installer?hsCtaTracking=99d9dd4f-5681-48d2-b096-470725510d34%7C074ddad0-fdef-4e53-8aa8-5e8a876d6ab4). You may need to alter which downloader you install. +2. SQLite C driver (for SQLite version 3) + 1. On Fedora, this can be installed with `yum install sqlite-devel` + 2. On Arch systems, this can be installed with `pacman -S sqlite-doc` + +## Adding a db migration + +Updating the database schema is slightly complicated, and the following rules must be followed: + +1. Use the helper script `bin/create-migration.sh` + * This will create file in the `migrations` folder with the indicated name and a timestamp + * This should also add this migration to the qrc file. However, if this is not done, you can do this manually by editing the `rs_migrations.qrc` file. +2. Inside the new migration file, add the necessary sql to apply the db change under `-- +migrate Up` +3. Inside the new migration file, add the necessary sql to _undo_ the db change under `-- +migrate Down` +4. Only one statement is allowed under each heading. If multiple statements need to be applied, they should done as multiple migration files + * This is a sqlite3/Qt limitation. + +## Adding a new Evidence Filter + +Evidence filters require modification in a few files and functions. Here is a rough checklist: + +| | File | Function | Notes | +| ------------------------ | ---------------------- | ---------------------- | ------------------------------------------------------------------- | +| | evidencefilter.h | | Need to add `FILTER_KEY_` and `FILTER_KEYS_` values | +| | evidencefilter.cpp | standardizeFilterKey | Needed to map filter key alias to the one true filter key | +| | evidencefilter.cpp | toString | Need to represent a filter key/value as a string | +| | evidencefilter.cpp | parseFilter | Need to be able to read filter key/value from a string | +| | databaseconnection.cpp | getEvidenceWithFilters | Need to translate the filter key/value to an appropriate sql clause | + +Currently, there is already built-in support for adding filters of type: + +* String (use the Operation filter as a guide) +* Boolean/Tri (Tris represent Yes/No/Any here, use Error filter as a guide) +* Date Range (use To/From filters as a guide) + +## Formatting + +This application adopts a modified [Google code style](https://google.github.io/styleguide/cppguide.html), applied via `clang-format`. Note that while formatting style is adhered to, other parts may not be followed, due to not starting with this style in mind. + +Google style changes: +* Line limit to 100 characters + * This is simply easier to read when dealing with long lines +* Line breaks before `else` and `catch`. + * This is mostly because I find these easier to read. + +To apply code formatting (Linux/Bash), run `find src/ -iname "*.cpp" -o -iname "*.h" | xargs clang-format -i` + +## Known Issues + +1. Evidence manager columns aren't ideally sized +2. No CLI for non-qt/non-tray OSes +3. Remove dock icon on mac? + 1. Possibly useful: https://github.com/keepassxreboot/keepassxc/commit/45344bb2acea61806d5e7f5f9eadfa779ca536ae#diff-a9e708931297992b08350ff7122fcb91R157 diff --git a/ascreen.pro b/ascreen.pro new file mode 100644 index 0000000..a6e0040 --- /dev/null +++ b/ascreen.pro @@ -0,0 +1,115 @@ +QT += core gui network sql + +greaterThan(QT_MAJOR_VERSION, 4): QT += widgets + +CONFIG += c++11 + +# The following define makes your compiler emit warnings if you use +# any Qt feature that has been marked deprecated (the exact warnings +# depend on your compiler). Please consult the documentation of the +# deprecated API in order to know how to port your code away from it. +DEFINES += QT_DEPRECATED_WARNINGS + +# You can also make your code fail to compile if it uses deprecated APIs. +# In order to do so, uncomment the following line. +# You can also select to disable deprecated APIs only up to a certain version of Qt. +#DEFINES += QT_DISABLE_DEPRECATED_BEFORE=0x060000 # disables all the APIs deprecated before Qt 6.0.0 + +INCLUDEPATH += src + +SOURCES += \ + src/components/aspectratio_pixmap_label/aspectratiopixmaplabel.cpp \ + src/components/aspectratio_pixmap_label/imageview.cpp \ + src/components/code_editor/codeblockview.cpp \ + src/components/code_editor/codeeditor.cpp \ + src/components/error_view/errorview.cpp \ + src/components/evidence_editor/evidenceeditor.cpp \ + src/components/evidencepreview.cpp \ + src/components/loading/qprogressindicator.cpp \ + src/components/loading_button/loadingbutton.cpp \ + src/components/tag_editor/tageditor.cpp \ + src/db/databaseconnection.cpp \ + src/forms/evidence_filter/evidencefilter.cpp \ + src/forms/evidence_filter/evidencefilterform.cpp \ + src/forms/getinfo/getinfo.cpp \ + src/helpers/clipboard/clipboardhelper.cpp \ + src/models/codeblock.cpp \ + src/helpers/multipartparser.cpp \ + src/hotkeymanager.cpp \ + src/main.cpp \ + src/traymanager.cpp \ + src/helpers/screenshot.cpp \ + src/helpers/stopreply.cpp \ + src/forms/buttonboxform.cpp \ + src/forms/credits/credits.cpp \ + src/forms/evidence/evidencemanager.cpp \ + src/forms/settings/settings.cpp + +HEADERS += \ + src/components/aspectratio_pixmap_label/aspectratiopixmaplabel.h \ + src/components/aspectratio_pixmap_label/imageview.h \ + src/components/code_editor/codeblockview.h \ + src/components/code_editor/codeeditor.h \ + src/components/error_view/errorview.h \ + src/components/evidence_editor/deleteevidenceresponse.h \ + src/components/evidence_editor/evidenceeditor.h \ + src/components/evidence_editor/saveevidenceresponse.h \ + src/components/evidencepreview.h \ + src/components/loading/qprogressindicator.h \ + src/components/loading_button/loadingbutton.h \ + src/components/tag_editor/tageditor.h \ + src/db/databaseconnection.h \ + src/exceptions/databaseerr.h \ + src/exceptions/fileerror.h \ + src/forms/evidence_filter/evidencefilter.h \ + src/forms/evidence_filter/evidencefilterform.h \ + src/forms/getinfo/getinfo.h \ + src/helpers/clipboard/clipboardhelper.h \ + src/helpers/ui_helpers.h \ + src/models/codeblock.h \ + src/helpers/file_helpers.h \ + src/helpers/http_status.h \ + src/hotkeymanager.h \ + src/models/evidence.h \ + src/models/tag.h \ + src/traymanager.h \ + src/appconfig.h \ + src/appsettings.h \ + src/helpers/jsonhelpers.h \ + src/helpers/multipartparser.h \ + src/helpers/netman.h \ + src/helpers/pathseparator.h \ + src/helpers/screenshot.h \ + src/helpers/stopreply.h \ + src/dtos/tag.h \ + src/dtos/operation.h \ + src/forms/buttonboxform.h \ + src/forms/credits/credits.h \ + src/forms/evidence/evidencemanager.h \ + src/forms/settings/settings.h + +FORMS += \ + src/forms/credits/credits.ui \ + src/forms/evidence/evidencemanager.ui \ + src/forms/evidence_filter/evidencefilterform.ui \ + src/forms/getinfo/getinfo.ui \ + src/forms/settings/settings.ui + +include(tools/UGlobalHotkey/uglobalhotkey.pri) + +macx { + ICON = icons/ascreen.icns +} + +# Default rules for deployment. +qnx: target.path = /tmp/$${TARGET}/bin +else: unix:!android: target.path = /opt/$${TARGET}/bin +!isEmpty(target.path): INSTALLS += target + +DISTFILES += \ + bin/update_migration_resource.py \ + sadbear.png + +RESOURCES += \ + res_migrations.qrc \ + res_icons.qrc diff --git a/bin/create-migration.sh b/bin/create-migration.sh new file mode 100755 index 0000000..5f86772 --- /dev/null +++ b/bin/create-migration.sh @@ -0,0 +1,33 @@ +#! /usr/bin/env bash + +# exit on error +set -e + +# set cwd to project root +cd "$(dirname "$0")/.." + +now=$(date -u +%Y%m%d%H%M%S) + +desc="$*" + +if [ -z "$desc" ]; then + read -p 'Migration name: ' desc +fi + +# sanitize description (spaces -> dashes) +desc=${desc// /-} + +migrationsPath="./migrations" + +filename="$now-$desc.sql" +filepath="$migrationsPath/$filename" + + +touch $filepath +echo "-- +migrate Up" >> $filepath +echo "" >> $filepath +echo "-- +migrate Down" >> $filepath + +resourceFile="$(pwd)/res_migrations.qrc" + +./bin/update_migration_resource.py "$resourceFile" "migrations/$filename" diff --git a/bin/update_migration_resource.py b/bin/update_migration_resource.py new file mode 100755 index 0000000..ba4ea58 --- /dev/null +++ b/bin/update_migration_resource.py @@ -0,0 +1,31 @@ +#! /usr/bin/env python + +import xml.etree.ElementTree as ET +import sys + + +def main(): + if len(sys.argv) < 3: + print("Project root not provided.") + return + + migration_file = sys.argv[1] + new_filename = sys.argv[2] + tree = ET.parse(migration_file) + root = tree.getroot() + + for child in root: + if child.attrib.get('prefix') == '/': + newFileEntry = ET.SubElement(child, "file") + newFileEntry.text = new_filename + # try to keep pretty -- not really necessary + if len(child) > 1: + child[-2].tail = "\n " + newFileEntry.tail ="\n " + break + + tree.write(migration_file) + + +if __name__ == "__main__": + main() diff --git a/icons/ascreen.icns b/icons/ascreen.icns new file mode 100644 index 0000000..a0596b8 Binary files /dev/null and b/icons/ascreen.icns differ diff --git a/icons/shirt-dark.svg b/icons/shirt-dark.svg new file mode 100644 index 0000000..18a29b8 --- /dev/null +++ b/icons/shirt-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/shirt-light.svg b/icons/shirt-light.svg new file mode 100644 index 0000000..c4c5026 --- /dev/null +++ b/icons/shirt-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/shirt-red.svg b/icons/shirt-red.svg new file mode 100644 index 0000000..c6d49f7 --- /dev/null +++ b/icons/shirt-red.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/libs/libUGlobalHotkey.so b/libs/libUGlobalHotkey.so new file mode 120000 index 0000000..bade517 --- /dev/null +++ b/libs/libUGlobalHotkey.so @@ -0,0 +1 @@ +libUGlobalHotkey.so.1.0.0 \ No newline at end of file diff --git a/migrations/20200521190124-initial.sql b/migrations/20200521190124-initial.sql new file mode 100644 index 0000000..3516104 --- /dev/null +++ b/migrations/20200521190124-initial.sql @@ -0,0 +1,8 @@ +-- +migrate Up +CREATE TABLE migrations ( + migration_name TEXT NOT NULL, + applied_at TIMESTAMP +); + +-- +migrate Down +DROP TABLE migrations; diff --git a/migrations/20200521210407-add-screenshots-table.sql b/migrations/20200521210407-add-screenshots-table.sql new file mode 100644 index 0000000..4cf2cb5 --- /dev/null +++ b/migrations/20200521210407-add-screenshots-table.sql @@ -0,0 +1,13 @@ +-- +migrate Up +CREATE TABLE screenshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT NOT NULL, + operation_slug TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + error TEXT NOT NULL DEFAULT '', + recorded_date TIMESTAMP NOT NULL, + upload_date TIMESTAMP +); + +-- +migrate Down +DROP TABLE screenshots; diff --git a/migrations/20200521210435-add-tags-table.sql b/migrations/20200521210435-add-tags-table.sql new file mode 100644 index 0000000..2a0d30e --- /dev/null +++ b/migrations/20200521210435-add-tags-table.sql @@ -0,0 +1,10 @@ +-- +migrate Up +CREATE TABLE tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + screenshot_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + name TEXT NOT NULL +); + +-- +migrate Down +DROP TABLE tags; diff --git a/migrations/20200625191727-support-codeblocks-p1.sql b/migrations/20200625191727-support-codeblocks-p1.sql new file mode 100644 index 0000000..52a195e --- /dev/null +++ b/migrations/20200625191727-support-codeblocks-p1.sql @@ -0,0 +1,5 @@ +-- +migrate Up +ALTER TABLE screenshots RENAME TO evidence; + +-- +migrate Down +ALTER TABLE evidence RENAME TO screenshots; diff --git a/migrations/20200625192018-support-codeblocks-p2.sql b/migrations/20200625192018-support-codeblocks-p2.sql new file mode 100644 index 0000000..bb6f9e5 --- /dev/null +++ b/migrations/20200625192018-support-codeblocks-p2.sql @@ -0,0 +1,5 @@ +-- +migrate Up +ALTER TABLE evidence ADD COLUMN content_type TEXT NOT NULL DEFAULT 'image'; + +-- +migrate Down +-- cannot do a proper migrate down (SQLite does not support ALTER TABLE DROP COLUMN) diff --git a/migrations/20200625192444-support-codeblocks-p3.sql b/migrations/20200625192444-support-codeblocks-p3.sql new file mode 100644 index 0000000..3803ad5 --- /dev/null +++ b/migrations/20200625192444-support-codeblocks-p3.sql @@ -0,0 +1,3 @@ +-- +migrate Up +UPDATE evidence SET content_type='image'; +-- +migrate Down diff --git a/migrations/20200625203249-support-codeblocks-p4.sql b/migrations/20200625203249-support-codeblocks-p4.sql new file mode 100644 index 0000000..4630f3b --- /dev/null +++ b/migrations/20200625203249-support-codeblocks-p4.sql @@ -0,0 +1,5 @@ +-- +migrate Up +ALTER TABLE tags RENAME COLUMN screenshot_id TO evidence_id; + +-- +migrate Down +ALTER TABLE tags RENAME COLUMN evidence_id TO screenshot_id; diff --git a/res_icons.qrc b/res_icons.qrc new file mode 100644 index 0000000..9fcbf28 --- /dev/null +++ b/res_icons.qrc @@ -0,0 +1,7 @@ + + + icons/shirt-dark.svg + icons/shirt-light.svg + icons/shirt-red.svg + + diff --git a/res_migrations.qrc b/res_migrations.qrc new file mode 100644 index 0000000..d960e7e --- /dev/null +++ b/res_migrations.qrc @@ -0,0 +1,11 @@ + + + migrations/20200521190124-initial.sql + migrations/20200521210407-add-screenshots-table.sql + migrations/20200521210435-add-tags-table.sql + migrations/20200625191727-support-codeblocks-p1.sql + migrations/20200625192018-support-codeblocks-p2.sql + migrations/20200625192444-support-codeblocks-p3.sql + migrations/20200625203249-support-codeblocks-p4.sql + + \ No newline at end of file diff --git a/src/appconfig.h b/src/appconfig.h new file mode 100644 index 0000000..9acf0ce --- /dev/null +++ b/src/appconfig.h @@ -0,0 +1,140 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#ifndef DATA_H +#define DATA_H + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "exceptions/fileerror.h" + +// AppConfig is a singleton construct for accessing the application's configuration. +// singleton design borrowed from: +// https://stackoverflow.com/questions/1008019/c-singleton-design-pattern +class AppConfig { + public: + static AppConfig &getInstance() { + static AppConfig instance; + return instance; + } + AppConfig(AppConfig const &) = delete; + void operator=(AppConfig const &) = delete; + + QString evidenceRepo = ""; + QString accessKey = ""; + QString secretKey = ""; + QString apiURL = ""; + QString screenshotExec = ""; + QString screenshotShortcutCombo = ""; + QString captureWindowExec = ""; + QString captureWindowShortcut = ""; + + QString errorText = ""; + + private: + AppConfig() noexcept { + try { + readConfig(); + } + catch (std::exception &e) { + errorText = e.what(); + } + } + + QString saveLocation = + QStandardPaths::writableLocation(QStandardPaths::ConfigLocation) + "/ashirt/screenshot.json"; + + void readConfig() { + QFile configFile(saveLocation); + if (!configFile.open(QIODevice::ReadOnly)) { + if (configFile.exists()) { + throw FileError::mkError("Error reading config file", saveLocation.toStdString(), + configFile.error()); + } + try { + writeDefaultConfig(); + } + catch (...) { + // ignoring -- just trying to generate an empty config + } + return; + } + + QByteArray data = configFile.readAll(); + if (configFile.error() != QFile::NoError) { + throw FileError::mkError("Error reading config file", saveLocation.toStdString(), + configFile.error()); + } + + QJsonParseError err; + QJsonDocument doc = QJsonDocument::fromJson(data, &err); + if (err.error != QJsonParseError::NoError) { + // ignoring specific type -- unlikely to occur in practice. + throw std::runtime_error("Unable to parse config file"); + } + + this->evidenceRepo = doc["evidenceRepo"].toString(); + this->accessKey = doc["accessKey"].toString(); + this->secretKey = doc["secretKey"].toString(); + this->apiURL = doc["apiURL"].toString(); + this->screenshotExec = doc["screenshotCommand"].toString(); + this->screenshotShortcutCombo = doc["screenshotShortcut"].toString(); + this->captureWindowExec = doc["captureWindowExec"].toString(); + this->captureWindowShortcut = doc["captureWindowShortcut"].toString(); + } + + void writeDefaultConfig() { + evidenceRepo = QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + "/ashirt"; + +#ifdef Q_OS_MACOS + screenshotExec = "screencapture -s %file"; + captureWindowExec = "screencapture -w %file"; +#endif + + try { + writeConfig(); + } + catch (...) { + // ignoring error -- best effort approach + } + } + + public: + void writeConfig() { + QJsonObject root = QJsonObject(); // QFiles close automatically, so no need for close here. + root["evidenceRepo"] = evidenceRepo; + root["accessKey"] = accessKey; + root["secretKey"] = secretKey; + root["apiURL"] = apiURL; + root["screenshotCommand"] = screenshotExec; + root["screenshotShortcut"] = screenshotShortcutCombo; + root["captureWindowExec"] = captureWindowExec; + root["captureWindowShortcut"] = captureWindowShortcut; + + auto saveRoot = saveLocation.left(saveLocation.lastIndexOf("/")); + QDir().mkpath(saveRoot); + QFile configFile(saveLocation); + if (!configFile.open(QIODevice::WriteOnly)) { + throw FileError::mkError("Error writing config file", saveLocation.toStdString(), + configFile.error()); + } + + QJsonDocument doc(root); + auto written = configFile.write(doc.toJson()); + if (written == -1) { + throw FileError::mkError("Error writing config file", saveLocation.toStdString(), + configFile.error()); + } + + return; + } +}; + +#endif // DATA_H diff --git a/src/appsettings.h b/src/appsettings.h new file mode 100644 index 0000000..8ed27b9 --- /dev/null +++ b/src/appsettings.h @@ -0,0 +1,67 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#ifndef APPSETTINGS_H +#define APPSETTINGS_H + +#include +#include + +// AppSettings is a singleton construct for accessing the application's settings. This is different +// from configuration, as it represents the application's state, rather than how the application +// communicates. +// +// singleton design borrowed from: +// https://stackoverflow.com/questions/1008019/c-singleton-design-pattern +class AppSettings : public QObject { + Q_OBJECT; + + public: + static AppSettings &getInstance() { + static AppSettings instance; + return instance; + } + AppSettings(AppSettings const &) = delete; + void operator=(AppSettings const &) = delete; + + private: + QSettings settings; + + const char *opSlugSetting = "operation/slug"; + const char *opNameSetting = "operation/name"; + const char *opPausedSetting = "operation/paused"; + + AppSettings() : QObject(nullptr) {} + + public: + signals: + void onOperationUpdated(QString operationSlug, QString operationName); + void onOperationStateChanged(bool isPaused); + void onSettingsSynced(); + + public: + void sync() { + settings.sync(); // ignoring the error + emit this->onSettingsSynced(); + } + + void setOperationDetails(QString operationSlug, QString operationName) { + settings.setValue(opSlugSetting, operationSlug); + settings.setValue(opNameSetting, operationName); + + emit onOperationUpdated(operationSlug, operationName); + } + QString operationSlug() { return settings.value(opSlugSetting).toString(); } + QString operationName() { return settings.value(opNameSetting).toString(); } + + void setOperationPaused(bool paused) { + settings.setValue(opPausedSetting, paused); + emit onOperationStateChanged(paused); + } + bool toggleOperationPaused() { + setOperationPaused(!isOperationPaused()); + return isOperationPaused(); + } + bool isOperationPaused() { return settings.value(opPausedSetting).toBool(); } +}; +#endif // APPSETTINGS_H diff --git a/src/components/aspectratio_pixmap_label/aspectratiopixmaplabel.cpp b/src/components/aspectratio_pixmap_label/aspectratiopixmaplabel.cpp new file mode 100644 index 0000000..77e826b --- /dev/null +++ b/src/components/aspectratio_pixmap_label/aspectratiopixmaplabel.cpp @@ -0,0 +1,33 @@ +// Copyright 22014-020, Phyatt, et al +// Licensed under the terms of CC BY-SA 3.0. +// Original Source: https://stackoverflow.com/a/22618496/4262552 + +#include "aspectratiopixmaplabel.h" + +AspectRatioPixmapLabel::AspectRatioPixmapLabel(QWidget *parent) : QLabel(parent) { + setMinimumSize(1, 1); + setScaledContents(false); +} + +void AspectRatioPixmapLabel::setPixmap(const QPixmap &p) { + pix = p; + QLabel::setPixmap(scaledPixmap()); +} + +int AspectRatioPixmapLabel::heightForWidth(int width) const { + return pix.isNull() ? this->height() : ((qreal)pix.height() * width) / pix.width(); +} + +QSize AspectRatioPixmapLabel::sizeHint() const { + int w = this->width(); + return QSize(w, heightForWidth(w)); +} + +QPixmap AspectRatioPixmapLabel::scaledPixmap() const { + return pix.scaled(this->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation); +} + +void AspectRatioPixmapLabel::resizeEvent(QResizeEvent *e) { + Q_UNUSED(e); + if (!pix.isNull()) QLabel::setPixmap(scaledPixmap()); +} diff --git a/src/components/aspectratio_pixmap_label/aspectratiopixmaplabel.h b/src/components/aspectratio_pixmap_label/aspectratiopixmaplabel.h new file mode 100644 index 0000000..3d1a3f8 --- /dev/null +++ b/src/components/aspectratio_pixmap_label/aspectratiopixmaplabel.h @@ -0,0 +1,27 @@ +// Copyright 22014-020, Phyatt, et al +// Licensed under the terms of CC BY-SA 3.0. +// Original Source: https://stackoverflow.com/a/22618496/4262552 + +#ifndef ASPECTRATIOPIXMAPLABEL_H +#define ASPECTRATIOPIXMAPLABEL_H + +#include +#include +#include + +class AspectRatioPixmapLabel : public QLabel { + Q_OBJECT + public: + explicit AspectRatioPixmapLabel(QWidget *parent = 0); + virtual int heightForWidth(int width) const; + virtual QSize sizeHint() const; + QPixmap scaledPixmap() const; + public slots: + void setPixmap(const QPixmap &); + void resizeEvent(QResizeEvent *); + + private: + QPixmap pix; +}; + +#endif // ASPECTRATIOPIXMAPLABEL_H diff --git a/src/components/aspectratio_pixmap_label/imageview.cpp b/src/components/aspectratio_pixmap_label/imageview.cpp new file mode 100644 index 0000000..70bcfab --- /dev/null +++ b/src/components/aspectratio_pixmap_label/imageview.cpp @@ -0,0 +1,52 @@ +#include "imageview.h" + +#include +#include + +ImageView::ImageView(QWidget* parent) : EvidencePreview(parent) { + buildUi(); + wireUi(); +} + +ImageView::~ImageView() { + delete previewImage; + delete gridLayout; +} + +void ImageView::buildUi() { + gridLayout = new QGridLayout(this); + gridLayout->setMargin(0); + + previewImage = new AspectRatioPixmapLabel(this); + previewImage->setSizePolicy(QSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding)); + previewImage->setAlignment(Qt::AlignCenter); + // Layout + /* 0 + +------------+ + 0 | | + | Img Label | + | | + +------------+ + */ + + // row 0 + gridLayout->addWidget(previewImage, 0, 0); +} + +void ImageView::wireUi() {} + +// ---- Parent Overrides + +void ImageView::clearPreview() { previewImage->clear(); } + +void ImageView::loadFromFile(QString filepath) { + QImageReader reader(filepath); + + const QImage img = reader.read(); + if (img.isNull()) { + previewImage->setText("Unable to load preview: " + reader.errorString()); + } + else { + previewImage->setPixmap(QPixmap::fromImage(img)); + } +} diff --git a/src/components/aspectratio_pixmap_label/imageview.h b/src/components/aspectratio_pixmap_label/imageview.h new file mode 100644 index 0000000..215796c --- /dev/null +++ b/src/components/aspectratio_pixmap_label/imageview.h @@ -0,0 +1,40 @@ +#ifndef IMAGEVIEW_H +#define IMAGEVIEW_H + +#include +#include + +#include "aspectratiopixmaplabel.h" +#include "components/evidencepreview.h" + +/** + * @brief The ImageView class is a thinly wrapped AspectRatioPixmapLabel to meet the EvidencePreview + * interface requirements. + */ +class ImageView : public EvidencePreview { + Q_OBJECT + public: + explicit ImageView(QWidget* parent = nullptr); + ~ImageView(); + + private: + /// buildUi constructs the UI, without wiring any connections + void buildUi(); + + /// wireUi connects UI elements together (currently a no-op) + void wireUi(); + + public: + /// loadFromFile attempts to load the indicated image from disk. + /// If this process fails, renders a text message instead. Inherited from EvidencePreview + virtual void loadFromFile(QString filepath) override; + + /// clearPreview clears the rendered image. Inherited from EvidencePreview. + virtual void clearPreview() override; + + private: + QGridLayout* gridLayout; + AspectRatioPixmapLabel* previewImage; +}; + +#endif // IMAGEVIEW_H diff --git a/src/components/code_editor/codeblockview.cpp b/src/components/code_editor/codeblockview.cpp new file mode 100644 index 0000000..5abe586 --- /dev/null +++ b/src/components/code_editor/codeblockview.cpp @@ -0,0 +1,99 @@ +#include "codeblockview.h" + +#include "exceptions/fileerror.h" +#include "helpers/ui_helpers.h" + +CodeBlockView::CodeBlockView(QWidget* parent) : EvidencePreview(parent) { + buildUi(); + wireUi(); +} + +CodeBlockView::~CodeBlockView() { + delete _languageLabel; + delete _sourceLabel; + delete codeEditor; + delete sourceTextBox; + delete languageComboBox; + delete gridLayout; +} + +void CodeBlockView::buildUi() { + gridLayout = new QGridLayout(this); + gridLayout->setMargin(0); + + _languageLabel = new QLabel("Language", this); + _sourceLabel = new QLabel("Source", this); + sourceTextBox = new QLineEdit(this); + languageComboBox = new QComboBox(this); + codeEditor = new CodeEditor(this); + + for (const std::pair& lang : SUPPORTED_LANGUAGES) { + languageComboBox->addItem(lang.first, lang.second); + } + + // Layout + /* 0 1 2 3 + +------------+-------------+----------+----------------+ + 0 | Lang Lab | [ textbox ] | Src Lab | [src textbox] | + +------------+-------------+----------+----------------+ + 1 | | + | Code Block | + | | + +------------+-------------+----------+----------------+ + */ + + // row 0 + gridLayout->addWidget(_languageLabel, 0, 0); + gridLayout->addWidget(languageComboBox, 0, 1); + gridLayout->addWidget(_sourceLabel, 0, 2); + gridLayout->addWidget(sourceTextBox, 0, 3); + + // row 1 + gridLayout->addWidget(codeEditor, 1, 0, 1, gridLayout->columnCount()); +} + +void CodeBlockView::wireUi() {} + +// ------- Parent Overrides + +void CodeBlockView::loadFromFile(QString filepath) { + try { + loadedCodeblock = Codeblock::readCodeblock(filepath); + + codeEditor->setPlainText(loadedCodeblock.content); + sourceTextBox->setText(loadedCodeblock.source); + UiHelpers::setComboBoxValue(languageComboBox, loadedCodeblock.subtype); + } + catch (std::exception& e) { + std::string msg = "Unable to load codeblock. Error: "; + msg += e.what(); + codeEditor->setPlainText(QString(msg.c_str())); + setReadonly(true); + } +} + +void CodeBlockView::saveEvidence() { + loadedCodeblock.source = sourceTextBox->text(); + loadedCodeblock.subtype = languageComboBox->currentData().toString(); + if (loadedCodeblock.filePath() != "") { + try { + Codeblock::saveCodeblock(loadedCodeblock); + } + catch (FileError& e) { + // best effort here -- if we can't save, oh well, they can edit it on the server + } + } +} + +void CodeBlockView::clearPreview() { + codeEditor->setPlainText(""); + sourceTextBox->setText(""); + languageComboBox->setCurrentIndex(0); // should be Plain Text +} + +void CodeBlockView::setReadonly(bool readonly) { + EvidencePreview::setReadonly(readonly); + codeEditor->setReadOnly(readonly); + sourceTextBox->setReadOnly(readonly); + languageComboBox->setEnabled(!readonly); +} diff --git a/src/components/code_editor/codeblockview.h b/src/components/code_editor/codeblockview.h new file mode 100644 index 0000000..5a8b31a --- /dev/null +++ b/src/components/code_editor/codeblockview.h @@ -0,0 +1,118 @@ +#ifndef CODEBLOCKVIEW_H +#define CODEBLOCKVIEW_H + +#include +#include +#include +#include +#include +#include +#include + +#include "codeeditor.h" +#include "components/evidencepreview.h" +#include "models/codeblock.h" + +// matches supported languages on the front end +static std::vector> SUPPORTED_LANGUAGES = { + std::pair("Plain Text", ""), + std::pair("ABAP", "abap"), + std::pair("ActionScript", "actionscript"), + std::pair("Ada", "ada"), + std::pair("C / C++", "c_cpp"), + std::pair("C#", "csharp"), + std::pair("COBOL", "cobol"), + std::pair("D", "d"), + std::pair("Dart", "dart"), + std::pair("Delphi/Object Pascal", "pascal"), + std::pair("Dockerfile", "dockerfile"), + std::pair("Elixir", "elixir"), + std::pair("Elm", "elm"), + std::pair("Erlang", "erlang"), + std::pair("F#", "fsharp"), + std::pair("Fortran", "fortran"), + std::pair("Go", "golang"), + std::pair("Groovy", "groovy"), + std::pair("Haskell", "haskell"), + std::pair("Java", "java"), + std::pair("JavaScript", "javascript"), + std::pair("Julia", "julia"), + std::pair("Kotlin", "kotlin"), + std::pair("Lisp", "lisp"), + std::pair("Lua", "lua"), + std::pair("MATLAB", "matlab"), + std::pair("Markdown", "markdown"), + std::pair("Objective-C", "objectivec"), + std::pair("PHP", "php"), + std::pair("Perl", "perl"), + std::pair("Prolog", "prolog"), + std::pair("Properties", "properties"), + std::pair("Python", "python"), + std::pair("R", "r"), + std::pair("Ruby", "ruby"), + std::pair("Rust", "rust"), + std::pair("SQL", "sql"), + std::pair("Sass", "sass"), + std::pair("Scala", "scala"), + std::pair("Scheme", "scheme"), + std::pair("Shell/Bash", "sh"), + std::pair("Swift", "swift"), + std::pair("Tcl", "tcl"), + std::pair("Terraform", "terraform"), + std::pair("Toml", "toml"), + std::pair("TypeScript", "typescript"), + std::pair("VBScript", "vbscript"), + std::pair("XML", "xml"), +}; + +/** + * @brief The CodeBlockView class provides a wrapped code editor, along with editable + * areas for source and language. Note that even though this is a "view" it's fully editable. + * Set to readonly if you need a proper view. + */ +class CodeBlockView : public EvidencePreview { + Q_OBJECT + public: + explicit CodeBlockView(QWidget* parent = nullptr); + ~CodeBlockView(); + + private: + /// buildUi constructs the UI, without wiring any connections + void buildUi(); + + /// wireUi connects UI elements together (currently a no-op) + void wireUi(); + + public: + /// loadFromFile attempts to load the indicated codeblock from disk. + /// If this process fails, renders a message instead of the codeblock. Inherited from + /// EvidencePreview + virtual void loadFromFile(QString filepath) override; + + /// saveEvidence attempts to write the codeblock back to disk, where it was loaded from. + /// Inherited from EvidencePreview + /// Note: Will silently fail if an error occurs while saving. Implemented as best effort approach. + virtual void saveEvidence() override; + + /// clearPreview removes the content, source, and sets the language to "Plain Text". Inherited + /// from EvidencePreview + virtual void clearPreview() override; + + /// Sets the editable areas to readonly/writable (depending on value passed). + /// Inherited from EvidencePreview + virtual void setReadonly(bool readonly) override; + + private: + Codeblock loadedCodeblock; + + // UI components + QGridLayout* gridLayout; + QLabel* _languageLabel; + QLabel* _sourceLabel; + + CodeEditor* codeEditor; + QLineEdit* sourceTextBox; + QComboBox* languageComboBox; +}; + +#endif // CODEBLOCKVIEW_H diff --git a/src/components/code_editor/codeeditor.cpp b/src/components/code_editor/codeeditor.cpp new file mode 100644 index 0000000..757d58d --- /dev/null +++ b/src/components/code_editor/codeeditor.cpp @@ -0,0 +1,154 @@ +/**************************************************************************** +** +** Copyright (C) 2016 The Qt Company Ltd. +** Contact: https://www.qt.io/licensing/ +** +** This file is part of the examples of the Qt Toolkit. +** +** $QT_BEGIN_LICENSE:BSD$ +** Commercial License Usage +** Licensees holding valid commercial Qt licenses may use this file in +** accordance with the commercial license agreement provided with the +** Software or, alternatively, in accordance with the terms contained in +** a written agreement between you and The Qt Company. For licensing terms +** and conditions see https://www.qt.io/terms-conditions. For further +** information use the contact form at https://www.qt.io/contact-us. +** +** BSD License Usage +** Alternatively, you may use this file under the terms of the BSD license +** as follows: +** +** "Redistribution and use in source and binary forms, with or without +** modification, are permitted provided that the following conditions are +** met: +** * Redistributions of source code must retain the above copyright +** notice, this list of conditions and the following disclaimer. +** * Redistributions in binary form must reproduce the above copyright +** notice, this list of conditions and the following disclaimer in +** the documentation and/or other materials provided with the +** distribution. +** * Neither the name of The Qt Company Ltd nor the names of its +** contributors may be used to endorse or promote products derived +** from this software without specific prior written permission. +** +** +** THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +** "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +** LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +** A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +** OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +** SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +** LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +** DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +** THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +** (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +** OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE." +** +** $QT_END_LICENSE$ +** +****************************************************************************/ + +// Forked edits: +// Minimum column width extended to 2 characters +// Set tab changes focus to false initially +// set line wrap to no-wrap + +#include "codeeditor.h" + +#include +#include + +QColor currentLineHighlightColor = QColor(115, 191, 255); + +CodeEditor::CodeEditor(QWidget *parent) : QPlainTextEdit(parent) { + lineNumberArea = new LineNumberArea(this); + + connect(this, &CodeEditor::blockCountChanged, this, &CodeEditor::updateLineNumberAreaWidth); + connect(this, &CodeEditor::updateRequest, this, &CodeEditor::updateLineNumberArea); + connect(this, &CodeEditor::cursorPositionChanged, this, &CodeEditor::highlightCurrentLine); + + // customize for text/code editing + setLineWrapMode(LineWrapMode::NoWrap); + setTabChangesFocus(false); + QFont font("source code pro"); + font.setStyleHint(QFont::TypeWriter); + setFont(font); + + updateLineNumberAreaWidth(0); + highlightCurrentLine(); +} + +void CodeEditor::keyReleaseEvent(QKeyEvent *e) { QPlainTextEdit::keyReleaseEvent(e); } + +int CodeEditor::lineNumberAreaWidth() { + int digits = 1; + int max = qMax(1, blockCount()); + while (max >= 10) { + max /= 10; + ++digits; + } + + int space = 3 + fontMetrics().horizontalAdvance(QLatin1Char('9')) * qMax(digits, 2); + + return space; +} + +void CodeEditor::updateLineNumberAreaWidth(int /* newBlockCount */) { + setViewportMargins(lineNumberAreaWidth(), 0, 0, 0); +} + +void CodeEditor::updateLineNumberArea(const QRect &rect, int dy) { + if (dy) + lineNumberArea->scroll(0, dy); + else + lineNumberArea->update(0, rect.y(), lineNumberArea->width(), rect.height()); + + if (rect.contains(viewport()->rect())) updateLineNumberAreaWidth(0); +} + +void CodeEditor::resizeEvent(QResizeEvent *e) { + QPlainTextEdit::resizeEvent(e); + + QRect cr = contentsRect(); + lineNumberArea->setGeometry(QRect(cr.left(), cr.top(), lineNumberAreaWidth(), cr.height())); +} + +void CodeEditor::highlightCurrentLine() { + QList extraSelections; + + if (!isReadOnly()) { + QTextEdit::ExtraSelection selection; + + selection.format.setBackground(currentLineHighlightColor); + selection.format.setProperty(QTextFormat::FullWidthSelection, true); + selection.cursor = textCursor(); + selection.cursor.clearSelection(); + extraSelections.append(selection); + } + + setExtraSelections(extraSelections); +} + +void CodeEditor::lineNumberAreaPaintEvent(QPaintEvent *event) { + QPainter painter(lineNumberArea); + painter.fillRect(event->rect(), Qt::lightGray); + + QTextBlock block = firstVisibleBlock(); + int blockNumber = block.blockNumber(); + int top = qRound(blockBoundingGeometry(block).translated(contentOffset()).top()); + int bottom = top + qRound(blockBoundingRect(block).height()); + + while (block.isValid() && top <= event->rect().bottom()) { + if (block.isVisible() && bottom >= event->rect().top()) { + QString number = QString::number(blockNumber + 1); + painter.setPen(Qt::black); + painter.drawText(0, top, lineNumberArea->width(), fontMetrics().height(), Qt::AlignRight, + number); + } + + block = block.next(); + top = bottom; + bottom = top + qRound(blockBoundingRect(block).height()); + ++blockNumber; + } +} diff --git a/src/components/code_editor/codeeditor.h b/src/components/code_editor/codeeditor.h new file mode 100644 index 0000000..02738f6 --- /dev/null +++ b/src/components/code_editor/codeeditor.h @@ -0,0 +1,100 @@ +/**************************************************************************** +** +** Copyright (C) 2016 The Qt Company Ltd. +** Contact: https://www.qt.io/licensing/ +** +** This file is part of the examples of the Qt Toolkit. +** +** $QT_BEGIN_LICENSE:BSD$ +** Commercial License Usage +** Licensees holding valid commercial Qt licenses may use this file in +** accordance with the commercial license agreement provided with the +** Software or, alternatively, in accordance with the terms contained in +** a written agreement between you and The Qt Company. For licensing terms +** and conditions see https://www.qt.io/terms-conditions. For further +** information use the contact form at https://www.qt.io/contact-us. +** +** BSD License Usage +** Alternatively, you may use this file under the terms of the BSD license +** as follows: +** +** "Redistribution and use in source and binary forms, with or without +** modification, are permitted provided that the following conditions are +** met: +** * Redistributions of source code must retain the above copyright +** notice, this list of conditions and the following disclaimer. +** * Redistributions in binary form must reproduce the above copyright +** notice, this list of conditions and the following disclaimer in +** the documentation and/or other materials provided with the +** distribution. +** * Neither the name of The Qt Company Ltd nor the names of its +** contributors may be used to endorse or promote products derived +** from this software without specific prior written permission. +** +** +** THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +** "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +** LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +** A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +** OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +** SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +** LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +** DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +** THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +** (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +** OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE." +** +** $QT_END_LICENSE$ +** +****************************************************************************/ + +#ifndef CODEEDITOR_H +#define CODEEDITOR_H + +#include + +QT_BEGIN_NAMESPACE +class QPaintEvent; +class QResizeEvent; +class QSize; +class QWidget; +QT_END_NAMESPACE + +class LineNumberArea; + +class CodeEditor : public QPlainTextEdit { + Q_OBJECT + + public: + CodeEditor(QWidget *parent = nullptr); + + void lineNumberAreaPaintEvent(QPaintEvent *event); + int lineNumberAreaWidth(); + + protected: + void resizeEvent(QResizeEvent *event) override; + void keyReleaseEvent(QKeyEvent *e) override; + + private slots: + void updateLineNumberAreaWidth(int newBlockCount); + void highlightCurrentLine(); + void updateLineNumberArea(const QRect &rect, int dy); + + private: + QWidget *lineNumberArea; +}; + +class LineNumberArea : public QWidget { + public: + LineNumberArea(CodeEditor *editor) : QWidget(editor), codeEditor(editor) {} + + QSize sizeHint() const override { return QSize(codeEditor->lineNumberAreaWidth(), 0); } + + protected: + void paintEvent(QPaintEvent *event) override { codeEditor->lineNumberAreaPaintEvent(event); } + + private: + CodeEditor *codeEditor; +}; + +#endif diff --git a/src/components/error_view/errorview.cpp b/src/components/error_view/errorview.cpp new file mode 100644 index 0000000..305b480 --- /dev/null +++ b/src/components/error_view/errorview.cpp @@ -0,0 +1,39 @@ +#include "errorview.h" + #include +ErrorView::ErrorView(QString errorText, QWidget* parent) : EvidencePreview(parent) { + this->errorText = std::move(errorText); + buildUi(); + wireUi(); +} + +ErrorView::~ErrorView() { + delete errorLabel; + delete gridLayout; +} + +void ErrorView::buildUi() { + gridLayout = new QGridLayout(this); + gridLayout->setMargin(0); + + errorLabel = new QLabel(errorText, this); + + // Layout + /* 0 + +------------+ + 0 | | + | Err Label | + | | + +------------+ + */ + + // row 0 + gridLayout->addWidget(errorLabel, 0, 0); +} + +void ErrorView::wireUi() {} + +// ---- Parent Overrides + +void ErrorView::clearPreview() {} + +void ErrorView::loadFromFile(QString filepath) { Q_UNUSED(filepath); } diff --git a/src/components/error_view/errorview.h b/src/components/error_view/errorview.h new file mode 100644 index 0000000..729e6a6 --- /dev/null +++ b/src/components/error_view/errorview.h @@ -0,0 +1,43 @@ +#ifndef ERRORVIEW_H +#define ERRORVIEW_H + +#include +#include +#include +#include + +#include "components/evidencepreview.h" + +/** + * @brief The ErrorView class provides a default/error handler for situations when an an + * EvidencePreview is needed, but needs to be displayed with no chance to throw an error. Simply + * renders the provided error text in a label. + */ +class ErrorView : public EvidencePreview { + Q_OBJECT + public: + explicit ErrorView(QString errorText, QWidget* parent = nullptr); + ~ErrorView(); + + private: + /// buildUi constructs the UI, without wiring any connections + void buildUi(); + + /// wireUi connects UI elements together (currently a no-op) + void wireUi(); + + public: + /// loadFromFile is a no-op. No files are loaded. Inherited from EvidencePreview + virtual void loadFromFile(QString filepath) override; + + /// clearPreview is a no-op. The initial text is always displayed. Inherited from EvidencePreview. + virtual void clearPreview() override; + + private: + QGridLayout* gridLayout; + QLabel* errorLabel; + + QString errorText; +}; + +#endif // ERRORVIEW_H diff --git a/src/components/evidence_editor/deleteevidenceresponse.h b/src/components/evidence_editor/deleteevidenceresponse.h new file mode 100644 index 0000000..32ed313 --- /dev/null +++ b/src/components/evidence_editor/deleteevidenceresponse.h @@ -0,0 +1,26 @@ +#ifndef DELETEEVIDENCERESPONSE_H +#define DELETEEVIDENCERESPONSE_H + +#include + +#include "models/evidence.h" + +struct DeleteEvidenceResponse { + DeleteEvidenceResponse(model::Evidence model) { + this->model = model; + this->errorText = ""; + } + DeleteEvidenceResponse(bool fileDeleteSuccess, bool dbDeleteSuccess, QString err, + model::Evidence model) + : DeleteEvidenceResponse(model) { + this->fileDeleteSuccess = fileDeleteSuccess; + this->dbDeleteSuccess = dbDeleteSuccess; + this->errorText = err; + } + bool fileDeleteSuccess; + bool dbDeleteSuccess; + QString errorText; + model::Evidence model; +}; + +#endif // DELETEEVIDENCERESPONSE_H diff --git a/src/components/evidence_editor/evidenceeditor.cpp b/src/components/evidence_editor/evidenceeditor.cpp new file mode 100644 index 0000000..28bedd5 --- /dev/null +++ b/src/components/evidence_editor/evidenceeditor.cpp @@ -0,0 +1,215 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#include "evidenceeditor.h" + +#include + +#include "components/aspectratio_pixmap_label/imageview.h" +#include "components/code_editor/codeblockview.h" +#include "components/error_view/errorview.h" +#include "components/evidence_editor/evidenceeditor.h" +#include "models/codeblock.h" +#include "models/evidence.h" + +EvidenceEditor::EvidenceEditor(qint64 evidenceID, DatabaseConnection *db, QWidget *parent) + : EvidenceEditor(db, parent) { + this->evidenceID = evidenceID; + loadData(); +} + +EvidenceEditor::EvidenceEditor(DatabaseConnection *db, QWidget *parent) : QWidget(parent) { + buildUi(); + this->db = db; + + wireUi(); + setEnabled(false); +} + +EvidenceEditor::~EvidenceEditor() { + delete _descriptionLabel; + delete tagEditor; + delete descriptionTextBox; + delete loadedPreview; + delete splitter; + delete gridLayout; +} + +void EvidenceEditor::buildUi() { + gridLayout = new QGridLayout(this); + gridLayout->setMargin(0); + + splitter = new QSplitter(this); + splitter->setOrientation(Qt::Vertical); + + _descriptionLabel = new QLabel("Description", this); + tagEditor = new TagEditor(this); + descriptionTextBox = new QTextEdit(this); + + // Layout + /* 0 + +----------------------------------------+ + 0 | Desc Label | + +----------------------------------------+ + 1 | +------------Vert. Splitter---------+ | + | | Desc Text box | | + | | | | + | >===================================< | + | | Preview Area | | + | | (reserved) | | + | +-----------------------------------+ | + +----------------------------------------+ + 2 | | + | tagEditor | + | | + +----------------------------------------+ + */ + + // row 0 + gridLayout->addWidget(_descriptionLabel, 0, 0); + + // row 1 + gridLayout->addWidget(splitter, 1, 0); + splitter->addWidget(descriptionTextBox); + + // row 2 + gridLayout->addWidget(tagEditor, 2, 0); + + this->setLayout(gridLayout); +} + +model::Evidence EvidenceEditor::encodeEvidence() { + model::Evidence copy = model::Evidence(originalEvidenceData); + + copy.description = descriptionTextBox->toPlainText(); + copy.tags.clear(); + copy.tags = tagEditor->getIncludedTags(); + + return copy; +} + +void EvidenceEditor::setEnabled(bool enable) { + // if the product is enabled, then we can edit, hence it's not readonly + descriptionTextBox->setReadOnly(!enable); + tagEditor->setEnabled(enable); + if (loadedPreview != nullptr) { + loadedPreview->setReadonly(!enable); + } +} + +void EvidenceEditor::wireUi() { + connect(tagEditor, &TagEditor::tagsLoaded, this, &EvidenceEditor::onTagsLoaded); +} + +void EvidenceEditor::loadData() { + // get local db evidence data + clearEditor(); + delete loadedPreview; + try { + originalEvidenceData = db->getEvidenceDetails(evidenceID); + descriptionTextBox->setText(originalEvidenceData.description); + operationSlug = originalEvidenceData.operationSlug; + + if (originalEvidenceData.contentType == "image") { + loadedPreview = new ImageView(this); + } + else if (originalEvidenceData.contentType == "codeblock") { + loadedPreview = new CodeBlockView(this); + } + else { + loadedPreview = + new ErrorView("Unsupported evidence type: " + originalEvidenceData.contentType, this); + } + loadedPreview->loadFromFile(originalEvidenceData.path); + loadedPreview->setReadonly(readonly); + + // get all remote tags (for op) + std::vector initialTagIDs; + initialTagIDs.reserve(originalEvidenceData.tags.size()); + for (const model::Tag &tag : originalEvidenceData.tags) { + initialTagIDs.push_back(tag.serverTagId); + } + + tagEditor->loadTags(operationSlug, initialTagIDs); + } + catch (QSqlError &e) { + loadedPreview = new ErrorView("Unable to load evidence: " + e.text(), this); + } + splitter->addWidget(loadedPreview); +} + +void EvidenceEditor::updateEvidence(qint64 evidenceID, bool readonly) { + clearEditor(); + setEnabled(false); + this->readonly = readonly; + this->evidenceID = evidenceID; + if (evidenceID > 0) { + loadData(); + } +} + +void EvidenceEditor::clearEditor() { + tagEditor->clear(); + this->descriptionTextBox->setText(""); + if (loadedPreview != nullptr) { + loadedPreview->clearPreview(); + } +} + +void EvidenceEditor::onTagsLoaded(bool success) { + if (!success) { + tagEditor->setEnabled(false); + } + else { + tagEditor->setEnabled(!readonly); + } + emit onWidgetReady(); +} + +// saveEvidence is a helper method to save (to the database) the currently +// loaded evidence, using the editor changes. +SaveEvidenceResponse EvidenceEditor::saveEvidence() { + if (loadedPreview != nullptr) { + loadedPreview->saveEvidence(); + } + auto evi = encodeEvidence(); + auto resp = SaveEvidenceResponse(evi); + try { + db->updateEvidenceDescription(evi.description, evi.id); + db->setEvidenceTags(evi.tags, evi.id); + resp.actionSucceeded = true; + } + catch (QSqlError &e) { + resp.actionSucceeded = false; + resp.errorText = e.text(); + } + return resp; +} + +// deleteEvidence is a helper method to delete both the database record and +// file location of the currently loaded evidence. +DeleteEvidenceResponse EvidenceEditor::deleteEvidence() { + auto evi = encodeEvidence(); + auto resp = DeleteEvidenceResponse(evi); + try { + db->deleteEvidence(evi.id); + resp.dbDeleteSuccess = true; + } + catch (QSqlError &e) { + resp.dbDeleteSuccess = false; + resp.errorText = e.text(); + } + + auto localFile = new QFile(evi.path); + if (!localFile->remove()) { + resp.fileDeleteSuccess = false; + resp.errorText += "\n" + localFile->errorString(); + } + else { + resp.fileDeleteSuccess = true; + } + localFile->deleteLater(); // deletes the pointer, not the file + + resp.errorText = resp.errorText.trimmed(); + return resp; +} diff --git a/src/components/evidence_editor/evidenceeditor.h b/src/components/evidence_editor/evidenceeditor.h new file mode 100644 index 0000000..3c263b5 --- /dev/null +++ b/src/components/evidence_editor/evidenceeditor.h @@ -0,0 +1,67 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#ifndef EVIDENCEEDITOR_H +#define EVIDENCEEDITOR_H + +#include +#include +#include +#include +#include + +#include "components/evidencepreview.h" +#include "components/tag_editor/tageditor.h" +#include "db/databaseconnection.h" +#include "deleteevidenceresponse.h" +#include "saveevidenceresponse.h" + +#include + +class EvidenceEditor : public QWidget { + Q_OBJECT + + public: + explicit EvidenceEditor(qint64 evidenceID, DatabaseConnection* db, QWidget* parent = nullptr); + explicit EvidenceEditor(DatabaseConnection* db, QWidget* parent = nullptr); + ~EvidenceEditor(); + + private: + void buildUi(); + void wireUi(); + void loadData(); + void clearEditor(); + + public: + model::Evidence encodeEvidence(); + void setEnabled(bool enable); + SaveEvidenceResponse saveEvidence(); + DeleteEvidenceResponse deleteEvidence(); + + signals: + void onWidgetReady(); + + public slots: + void updateEvidence(qint64 evidenceID, bool readonly); + + private slots: + void onTagsLoaded(bool success); + + private: + DatabaseConnection* db; + qint64 evidenceID = 0; + QString operationSlug; + bool readonly = false; + + model::Evidence originalEvidenceData; + + // UI components + QGridLayout* gridLayout; + QSplitter* splitter; + QLabel* _descriptionLabel; + QTextEdit* descriptionTextBox; + TagEditor* tagEditor; + EvidencePreview* loadedPreview = nullptr; +}; + +#endif // EVIDENCEEDITOR_H diff --git a/src/components/evidence_editor/saveevidenceresponse.h b/src/components/evidence_editor/saveevidenceresponse.h new file mode 100644 index 0000000..de3353e --- /dev/null +++ b/src/components/evidence_editor/saveevidenceresponse.h @@ -0,0 +1,23 @@ +#ifndef SAVEEVIDENCERESPONSE_H +#define SAVEEVIDENCERESPONSE_H + +#include + +#include "models/evidence.h" + +struct SaveEvidenceResponse { + SaveEvidenceResponse(model::Evidence model) { + this->model = model; + this->errorText = ""; + } + SaveEvidenceResponse(bool success, QString err, model::Evidence model) + : SaveEvidenceResponse(model) { + this->actionSucceeded = success; + this->errorText = err; + } + bool actionSucceeded; + QString errorText; + model::Evidence model; +}; + +#endif // SAVEEVIDENCERESPONSE_H diff --git a/src/components/evidencepreview.cpp b/src/components/evidencepreview.cpp new file mode 100644 index 0000000..54c45f5 --- /dev/null +++ b/src/components/evidencepreview.cpp @@ -0,0 +1,9 @@ +#include "evidencepreview.h" + +EvidencePreview::EvidencePreview(QWidget *parent) : QWidget(parent) {} + +void EvidencePreview::saveEvidence() { + // no-op expected for view-only evidence +} + +void EvidencePreview::setReadonly(bool readonly) { this->readonly = readonly; } diff --git a/src/components/evidencepreview.h b/src/components/evidencepreview.h new file mode 100644 index 0000000..e2093c7 --- /dev/null +++ b/src/components/evidencepreview.h @@ -0,0 +1,44 @@ +#ifndef EVIDENCEPREVIEW_H +#define EVIDENCEPREVIEW_H + +#include + +/** + * @brief The EvidencePreview class is a (non-pure) virtual class that provides a thin wrapper + * around individual evidence previews. This ensures some common, basic functionality across + * evidence types. + */ +class EvidencePreview : public QWidget { + Q_OBJECT + public: + explicit EvidencePreview(QWidget *parent = nullptr); + + public: + /** + * @brief loadFromFile is a pure virtual method allowing each preview to load its data from disk + * (where all evidence types live) + * @param filepath is the full path to the evidence file + */ + virtual void loadFromFile(QString filepath) = 0; + + /// clearPreview is a pure virtual method that requests the preview content to render a + /// default/plain view. No content should be displayed. + virtual void clearPreview() = 0; + + /// saveEvidence allows the underlying evidence to be re-written to disk. The default + /// implementation is a no-op. + virtual void saveEvidence(); + + /// setReadonly marks the evidence preview as read-only, disallowing editing. The default + /// implementation sets the internal flag -- it is the responsibilty of the underlying evidence to + /// act on this data. + virtual void setReadonly(bool readonly); + + /// isReadOnly returns whether the current preview has been marked as readonly. + inline bool isReadOnly() { return readonly; }; + + private: + bool readonly = false; +}; + +#endif // EVIDENCEPREVIEW_H diff --git a/src/components/loading/qprogressindicator.cpp b/src/components/loading/qprogressindicator.cpp new file mode 100644 index 0000000..3689d0e --- /dev/null +++ b/src/components/loading/qprogressindicator.cpp @@ -0,0 +1,153 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2011 Morgan Leborgne + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +// clang-format off +#include "qprogressindicator.h" + +#include + +QProgressIndicator::QProgressIndicator(QWidget* parent) + : QWidget(parent) + , m_angle(0) + , m_timerId(-1) + , m_delay(40) + , m_displayedWhenStopped(false) + , m_color(Qt::black) +{ + setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); + setFocusPolicy(Qt::NoFocus); +} + +bool +QProgressIndicator::isAnimated() const +{ + return (m_timerId != -1); +} + +void +QProgressIndicator::setDisplayedWhenStopped(bool state) +{ + m_displayedWhenStopped = state; + + update(); +} + +bool +QProgressIndicator::isDisplayedWhenStopped() const +{ + return m_displayedWhenStopped; +} + +void +QProgressIndicator::startAnimation() +{ + m_angle = 0; + + if (m_timerId == -1) + m_timerId = startTimer(m_delay); +} + +void +QProgressIndicator::stopAnimation() +{ + if (m_timerId != -1) + killTimer(m_timerId); + + m_timerId = -1; + + update(); +} + +void +QProgressIndicator::setAnimationDelay(int delay) +{ + if (m_timerId != -1) + killTimer(m_timerId); + + m_delay = delay; + + if (m_timerId != -1) + m_timerId = startTimer(m_delay); +} + +void +QProgressIndicator::setColor(const QColor& color) +{ + m_color = color; + + update(); +} + +QSize +QProgressIndicator::sizeHint() const +{ + return QSize(20, 20); +} + +int +QProgressIndicator::heightForWidth(int w) const +{ + return w; +} + +void +QProgressIndicator::timerEvent(QTimerEvent* /*event*/) +{ + m_angle = (m_angle + 30) % 360; + + update(); +} + +void +QProgressIndicator::paintEvent(QPaintEvent* /*event*/) +{ + if (!m_displayedWhenStopped && !isAnimated()) + return; + + int width = qMin(this->width(), this->height()); + + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing); + + int outerRadius = (width - 1) * 0.5; + int innerRadius = (width - 1) * 0.5 * 0.38; + + int capsuleHeight = outerRadius - innerRadius; + int capsuleWidth = (width > 32) ? capsuleHeight * .23 : capsuleHeight * .35; + int capsuleRadius = capsuleWidth / 2; + + for (int i = 0; i < 12; i++) { + QColor color = m_color; + color.setAlphaF(1.0f - (i / 12.0f)); + p.setPen(Qt::NoPen); + p.setBrush(color); + p.save(); + p.translate(rect().center()); + p.rotate(m_angle - i * 30.0f); + p.drawRoundedRect(-capsuleWidth * 0.5, -(innerRadius + capsuleHeight), capsuleWidth, + capsuleHeight, capsuleRadius, capsuleRadius); + p.restore(); + } +} +// clang-format on diff --git a/src/components/loading/qprogressindicator.h b/src/components/loading/qprogressindicator.h new file mode 100644 index 0000000..e9952cf --- /dev/null +++ b/src/components/loading/qprogressindicator.h @@ -0,0 +1,122 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2011 Morgan Leborgne + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +// clang-format off +#ifndef QPROGRESSINDICATOR_H +#define QPROGRESSINDICATOR_H + +#include +#include + +/*! + \class QProgressIndicator + \brief The QProgressIndicator class lets an application display a progress indicator to show + that a lengthy task is under way. + + Progress indicators are indeterminate and do nothing more than spin to show that the application + is busy. + \sa QProgressBar +*/ +class QProgressIndicator : public QWidget +{ + Q_OBJECT + Q_PROPERTY(int delay READ animationDelay WRITE setAnimationDelay) + Q_PROPERTY(bool displayedWhenStopped READ isDisplayedWhenStopped WRITE setDisplayedWhenStopped) + Q_PROPERTY(QColor color READ color WRITE setColor) + public: + QProgressIndicator(QWidget* parent = 0); + + /*! Returns the delay between animation steps. + \return The number of milliseconds between animation steps. By default, the animation delay + is set to 40 milliseconds. + \sa setAnimationDelay + */ + int animationDelay() const { return m_delay; } + + /*! Returns a Boolean value indicating whether the component is currently animated. + \return Animation state. + \sa startAnimation stopAnimation + */ + bool isAnimated() const; + + /*! Returns a Boolean value indicating whether the receiver shows itself even when it is not + animating. + \return Return true if the progress indicator shows itself even when it is not animating. By + default, it returns false. + \sa setDisplayedWhenStopped + */ + bool isDisplayedWhenStopped() const; + + /*! Returns the color of the component. + \sa setColor + */ + const QColor& color() const { return m_color; } + + virtual QSize sizeHint() const; + int heightForWidth(int w) const; + public slots: + /*! Starts the spin animation. + \sa stopAnimation isAnimated + */ + void startAnimation(); + + /*! Stops the spin animation. + \sa startAnimation isAnimated + */ + void stopAnimation(); + + /*! Sets the delay between animation steps. + Setting the \a delay to a value larger than 40 slows the animation, while setting the \a + delay to a smaller value speeds it up. + \param delay The delay, in milliseconds. + \sa animationDelay + */ + void setAnimationDelay(int delay); + + /*! Sets whether the component hides itself when it is not animating. + \param state The animation state. Set false to hide the progress indicator when it is not + animating; otherwise true. + \sa isDisplayedWhenStopped + */ + void setDisplayedWhenStopped(bool state); + + /*! Sets the color of the components to the given color. + \sa color + */ + void setColor(const QColor& color); + + protected: + virtual void timerEvent(QTimerEvent* event); + virtual void paintEvent(QPaintEvent* event); + + private: + int m_angle; + int m_timerId; + int m_delay; + bool m_displayedWhenStopped; + QColor m_color; +}; + +#endif // QPROGRESSINDICATOR_H +// clang-format on diff --git a/src/components/loading_button/loadingbutton.cpp b/src/components/loading_button/loadingbutton.cpp new file mode 100644 index 0000000..3ab0f7b --- /dev/null +++ b/src/components/loading_button/loadingbutton.cpp @@ -0,0 +1,48 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#include "loadingbutton.h" + +#include + +LoadingButton::LoadingButton(QWidget* parent, QPushButton* model) + : LoadingButton("", parent, model) {} +LoadingButton::LoadingButton(const QString& text, QWidget* parent, QPushButton* model) + : QPushButton(text, parent) { + if (model == nullptr) { + model = this; + } + this->setMinimumSize(model->width(), model->minimumHeight()); + this->resize(model->width(), model->height()); + this->showingLabel = true; + + loading = new QProgressIndicator(this); + loading->setMinimumSize(this->minimumSize()); + loading->resize(this->width(), this->height()); +} + +LoadingButton::~LoadingButton() { delete loading; } + +void LoadingButton::startAnimation() { + label = this->text(); + showLabel(false); +} + +void LoadingButton::stopAnimation() { showLabel(true); } + +void LoadingButton::showLabel(bool show) { + this->showingLabel = show; + if (show) { + loading->stopAnimation(); + this->setText(this->label); + } + else { + this->setText(""); + loading->startAnimation(); + } +} + +void LoadingButton::resizeEvent(QResizeEvent* evt) { + QPushButton::resizeEvent(evt); + loading->resize(evt->size()); +} diff --git a/src/components/loading_button/loadingbutton.h b/src/components/loading_button/loadingbutton.h new file mode 100644 index 0000000..50fbbcb --- /dev/null +++ b/src/components/loading_button/loadingbutton.h @@ -0,0 +1,38 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#ifndef LOADINGBUTTON_H +#define LOADINGBUTTON_H + +#include + +#include "components/loading/qprogressindicator.h" + +class LoadingButton : public QPushButton { + Q_OBJECT + + public: + explicit LoadingButton(QWidget* parent = nullptr, QPushButton* model = nullptr); + explicit LoadingButton(const QString& text, QWidget* parent = nullptr, + QPushButton* model = nullptr); + + ~LoadingButton(); + + void startAnimation(); + void stopAnimation(); + + private: + void showLabel(bool show); + void init(); + + protected: + void resizeEvent(QResizeEvent* evt) override; + + private: + QProgressIndicator* loading; + + QString label; + bool showingLabel; +}; + +#endif // LOADINGBUTTON_H diff --git a/src/components/tag_editor/tageditor.cpp b/src/components/tag_editor/tageditor.cpp new file mode 100644 index 0000000..7fb3e09 --- /dev/null +++ b/src/components/tag_editor/tageditor.cpp @@ -0,0 +1,240 @@ +#include "tageditor.h" + +#include + +#include "dtos/tag.h" +#include "helpers/netman.h" +#include "helpers/stopreply.h" + +TagEditor::TagEditor(QWidget *parent) : QWidget(parent) { + buildUi(); + couldNotCreateTagMsg = new QErrorMessage(this); + + wireUi(); +} + +TagEditor::~TagEditor() { + delete _withTagsLabel; + delete createTagTextBox; + delete includedTagsListBox; + delete allTagsListBox; + delete includeTagButton; + delete excludeTagButton; + delete couldNotCreateTagMsg; + delete createTagButton; + delete gridLayout; + + stopReply(&getTagsReply); + stopReply(&createTagReply); +} + +void TagEditor::buildUi() { + gridLayout = new QGridLayout(this); + gridLayout->setMargin(0); + + _withTagsLabel = new QLabel("With Tags...", this); + includedTagsListBox = new QListWidget(this); + allTagsListBox = new QListWidget(this); + includeTagButton = new QPushButton("<<", this); + excludeTagButton = new QPushButton(">>", this); + createTagTextBox = new QLineEdit(); + createTagButton = new LoadingButton("Create Tag", this); + minorErrorLabel = new QLabel(this); + + // Layout + /* 0 1 2 3 + +----------+-----------+----------+---------+ + 0 | With Tags label | + +----------+-----------+----------+---------+ + 1 | Include | << btn | All | + + +-----------+ + + + 2 | Tag List | >> Btn | Tags List | + +----------+-----------+----------+---------+ + 3 | err Lab | | Add TB | add btn | + +----------+-----------+----------+---------+ + */ + + // row 0 + gridLayout->addWidget(_withTagsLabel, 0, 0, 1, gridLayout->columnCount()); + + // row 1 + gridLayout->addWidget(includedTagsListBox, 1, 0, 2, 1); + gridLayout->addWidget(includeTagButton, 1, 1); + gridLayout->addWidget(allTagsListBox, 1, 2, 2, 2); + + // row 2 + gridLayout->addWidget(excludeTagButton, 2, 1); + + // row 3 + gridLayout->addWidget(minorErrorLabel, 3, 0); + gridLayout->addWidget(createTagTextBox, 3, 2); + gridLayout->addWidget(createTagButton, 3, 3); + + this->setLayout(gridLayout); +} + +void TagEditor::wireUi() { + auto btnClicked = &QPushButton::clicked; + connect(createTagButton, btnClicked, this, &TagEditor::createTagButtonClicked); + connect(includeTagButton, btnClicked, this, &TagEditor::includeTagButtonClicked); + connect(excludeTagButton, btnClicked, this, &TagEditor::excludeTagButtonClicked); + connect(createTagTextBox, &QLineEdit::returnPressed, this, &TagEditor::createTagButtonClicked); + + allTagsListBox->setSortingEnabled(true); +} + +void TagEditor::clear() { + stopReply(&getTagsReply); + stopReply(&createTagReply); + createTagTextBox->setText(""); + allTagsListBox->clear(); + includedTagsListBox->clear(); + minorErrorLabel->setText(""); +} + +void TagEditor::setEnabled(bool enable) { + createTagButton->setEnabled(enable); + includeTagButton->setEnabled(enable); + excludeTagButton->setEnabled(enable); + createTagTextBox->setEnabled(enable); +} + +void TagEditor::loadTags(const QString& operationSlug, std::vector initialTagIDs) { + this->operationSlug = operationSlug; + + includedTagIds.clear(); + for (auto tagID : initialTagIDs) { + includedTagIds.insert(tagID); + } + + getTagsReply = NetMan::getInstance().getOperationTags(operationSlug); + connect(getTagsReply, &QNetworkReply::finished, this, &TagEditor::onGetTagsComplete); +} + +void TagEditor::refreshTagBoxes() { + allTagsListBox->clear(); + includedTagsListBox->clear(); + + for (const auto &itr : knownTags) { + QString tagText = itr.first; + qint64 tagId = itr.second; + int itemCount = includedTagIds.count(tagId); + + if (itemCount == 1) { + includedTagsListBox->addItem(tagText); + } + else { + allTagsListBox->addItem(tagText); + } + } +} + +std::vector TagEditor::getIncludedTags() { + std::vector rtn; + rtn.reserve(includedTagIds.size()); + + // Construct a reverse map to find tag names. + // slightly inefficient way to do this, but much easier to code against. + std::unordered_map reversedMap; + for (const auto &entry : knownTags) { + reversedMap.insert(std::pair(entry.second, entry.first)); + } + + for (const qint64 &tagID : includedTagIds) { + try { + auto tagName = reversedMap.at(tagID); + model::Tag tag(tagID, tagName); + rtn.push_back(tag); + } + catch (std::out_of_range &e) { + } // drop any tag ids we can't find (doesn't exist on the server, and will fail anyway) + } + + return rtn; +} + +void TagEditor::createTagButtonClicked() { + auto newText = createTagTextBox->text().trimmed(); + if (newText == "") { + return; + } + + createTagButton->startAnimation(); + createTagButton->setEnabled(false); + + dto::Tag newTag(newText, randomColor()); + + createTagReply = NetMan::getInstance().createTag(newTag, operationSlug); + connect(createTagReply, &QNetworkReply::finished, this, &TagEditor::onCreateTagComplete); +} + +void TagEditor::includeTagButtonClicked() { + auto items = allTagsListBox->selectedItems(); + for (auto item : items) { + includedTagsListBox->addItem(item->text()); + allTagsListBox->takeItem(allTagsListBox->row(item)); + includedTagIds.insert(knownTags[item->text()]); + } +} + +void TagEditor::excludeTagButtonClicked() { + auto items = includedTagsListBox->selectedItems(); + for (auto item : items) { + allTagsListBox->addItem(item->text()); + includedTagsListBox->takeItem(includedTagsListBox->row(item)); + includedTagIds.erase(knownTags[item->text()]); + } +} + +void TagEditor::onGetTagsComplete() { + bool isValid; + auto data = NetMan::extractResponse(getTagsReply, isValid); + if (isValid) { + std::vector tags = dto::Tag::parseDataAsList(data); + knownTags.clear(); + + for (const dto::Tag &tag : tags) { + knownTags.insert(std::pair(tag.name, tag.id)); + } + refreshTagBoxes(); + } + else { + minorErrorLabel->setText(tr("Unable to fetch tags. Please check your connection.")); + includeTagButton->setEnabled(false); + } + + disconnect(getTagsReply, &QNetworkReply::finished, this, &TagEditor::onGetTagsComplete); + tidyReply(&getTagsReply); + emit tagsLoaded(isValid); +} + +void TagEditor::onCreateTagComplete() { + bool isValid; + auto data = NetMan::extractResponse(createTagReply, isValid); + if (isValid) { + auto newTag = dto::Tag::parseData(data); + knownTags.insert(std::pair(newTag.name, newTag.id)); + includedTagIds.insert(newTag.id); + createTagTextBox->setText(""); + refreshTagBoxes(); + } + else { + couldNotCreateTagMsg->showMessage( + "Could not create tag. Please check your connection and try again."); + } + disconnect(createTagReply, &QNetworkReply::finished, this, &TagEditor::onCreateTagComplete); + tidyReply(&createTagReply); + createTagButton->stopAnimation(); + createTagButton->setEnabled(true); +} + +QString TagEditor::randomColor() { + // Note: this should match the frontend's color palette (naming) + static std::vector colors = { + "blue", "yellow", "green", "indigo", "orange", + "lightBlue", "lightYellow", "lightGreen", "lightIndigo", "lightOrange", + "pink", "red", "teal", "vermilion", "violet", + "lightPink", "lightRed", "lightTeal", "lightVermilion", "lightViolet"}; + auto index = QRandomGenerator::global()->bounded(int(colors.size())); + return colors.at(index); +} diff --git a/src/components/tag_editor/tageditor.h b/src/components/tag_editor/tageditor.h new file mode 100644 index 0000000..d7e6dd7 --- /dev/null +++ b/src/components/tag_editor/tageditor.h @@ -0,0 +1,70 @@ +#ifndef TAGEDITOR_H +#define TAGEDITOR_H + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "components/loading_button/loadingbutton.h" +#include "models/tag.h" + +class TagEditor : public QWidget { + Q_OBJECT + public: + explicit TagEditor(QWidget* parent = nullptr); + ~TagEditor(); + + public: + void setEnabled(bool enable); + void loadTags(const QString& operationSlug, std::vector initialTagIDs); + void clear(); + std::vector getIncludedTags(); + + signals: + void tagsLoaded(bool success); + + private: + void buildUi(); + void wireUi(); + void refreshTagBoxes(); + QString randomColor(); + + private slots: + void createTagButtonClicked(); + void includeTagButtonClicked(); + void excludeTagButtonClicked(); + + void onGetTagsComplete(); + void onCreateTagComplete(); + + private: + QString operationSlug; + std::unordered_map knownTags; + std::unordered_set includedTagIds; + + // ui Components + QGridLayout* gridLayout; + QLabel* _withTagsLabel; + LoadingButton* createTagButton; + QLineEdit* createTagTextBox; + QListWidget* includedTagsListBox; + QListWidget* allTagsListBox; + QPushButton* includeTagButton; + QPushButton* excludeTagButton; + QLabel* minorErrorLabel; + + QNetworkReply* getTagsReply = nullptr; + QNetworkReply* createTagReply = nullptr; + QErrorMessage* couldNotCreateTagMsg = nullptr; +}; + +#endif // TAGEDITOR_H diff --git a/src/db/databaseconnection.cpp b/src/db/databaseconnection.cpp new file mode 100644 index 0000000..1d2d9e5 --- /dev/null +++ b/src/db/databaseconnection.cpp @@ -0,0 +1,343 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#include "databaseconnection.h" + +#include +#include +#include +#include + +#include "exceptions/databaseerr.h" +#include "exceptions/fileerror.h" + +// DatabaseConnection constructs a connection to the database, unsurpringly. Note that the +// constructor can throw a error (see below). Additionally, many methods can throw a QSqlError, +// though are not marked as such in their comments. Other errors are listed in throw comments, or +// are marked as noexcept if no error is possible. +// +// Throws: DBDriverUnavailable if the required database driver does not exist +DatabaseConnection::DatabaseConnection() { + const QString DRIVER("QSQLITE"); + if (QSqlDatabase::isDriverAvailable(DRIVER)) { + db = QSqlDatabase::addDatabase(DRIVER); + db.setDatabaseName(dbPath); + auto dbFileRoot = dbPath.left(dbPath.lastIndexOf("/")); + QDir().mkpath(dbFileRoot); + } + else { + throw DBDriverUnavailableError("SQLite"); + } +} + +void DatabaseConnection::connect() { + if (!db.open()) { + throw db.lastError(); + } + migrateDB(); +} + +void DatabaseConnection::close() noexcept { db.close(); } + +qint64 DatabaseConnection::createEvidence(const QString &filepath, const QString &operationSlug, + const QString &contentType) { + return doInsert(&db, + "INSERT INTO evidence" + " (path, operation_slug, content_type, recorded_date)" + " VALUES" + " (?, ?, ?, datetime('now'))", + {filepath, operationSlug, contentType}); +} + +model::Evidence DatabaseConnection::getEvidenceDetails(qint64 evidenceID) { + model::Evidence rtn; + auto query = executeQuery( + &db, + "SELECT" + " id, path, operation_slug, content_type, description, error, recorded_date, upload_date" + " FROM evidence" + " WHERE id=? LIMIT 1", + {evidenceID}); + + if (query.first()) { + rtn.id = query.value("id").toLongLong(); + rtn.path = query.value("path").toString(); + rtn.operationSlug = query.value("operation_slug").toString(); + rtn.contentType = query.value("content_type").toString(); + rtn.description = query.value("description").toString(); + rtn.errorText = query.value("error").toString(); + rtn.recordedDate = query.value("recorded_date").toDateTime(); + rtn.uploadDate = query.value("upload_date").toDateTime(); + + auto getTagQuery = executeQuery(&db, + "SELECT" + " id, tag_id, name" + " FROM tags" + " WHERE evidence_id=?", + {evidenceID}); + while (getTagQuery.next()) { + rtn.tags.emplace_back(model::Tag(getTagQuery.value("id").toLongLong(), + getTagQuery.value("tag_id").toLongLong(), + getTagQuery.value("name").toString())); + } + } + else { + std::cout << "Could not find evidence with id: " << evidenceID << std::endl; + } + return rtn; +} + +void DatabaseConnection::updateEvidenceDescription(const QString &newDescription, + qint64 evidenceID) { + executeQuery(&db, "UPDATE evidence SET description=? WHERE id=?", {newDescription, evidenceID}); +} + +void DatabaseConnection::deleteEvidence(qint64 evidenceID) { + executeQuery(&db, "DELETE FROM evidence WHERE id=?", {evidenceID}); +} + +void DatabaseConnection::updateEvidenceError(const QString &errorText, qint64 evidenceID) { + executeQuery(&db, "UPDATE evidence SET error=? WHERE id=?", {errorText, evidenceID}); +} + +void DatabaseConnection::updateEvidenceSubmitted(qint64 evidenceID) { + executeQuery(&db, "UPDATE evidence SET upload_date=datetime('now') WHERE id=?", {evidenceID}); +} + +void DatabaseConnection::setEvidenceTags(const std::vector &newTags, + qint64 evidenceID) { + QList newTagIds; + for (const auto &tag : newTags) { + newTagIds.push_back(tag.serverTagId); + } + executeQuery(&db, "DELETE FROM tags WHERE tag_id NOT IN (?) AND evidence_id = ?", + {newTagIds, evidenceID}); + + auto currentTagsResult = + executeQuery(&db, "SELECT tag_id FROM tags WHERE evidence_id = ?", {evidenceID}); + QList currentTags; + while (currentTagsResult.next()) { + currentTags.push_back(currentTagsResult.value("tag_id").toLongLong()); + } + struct dataset { + qint64 evidenceID = 0; + qint64 tagID = 0; + QString name; + }; + std::vector tagDataToInsert; + QString baseQuery = "INSERT INTO tags (evidence_id, tag_id, name) VALUES "; + for (const auto &newTag : newTags) { + if (currentTags.count(newTag.serverTagId) == 0) { + dataset item; + item.evidenceID = evidenceID; + item.tagID = newTag.serverTagId; + item.name = newTag.tagName; + tagDataToInsert.push_back(item); + } + } + + // one possible concern: we are going to be passing a lot of parameters + // sqlite indicates it's default is 100 passed parameter, but it can "handle thousands" + if (!tagDataToInsert.empty()) { + std::vector args; + baseQuery += "(?,?,?)"; + baseQuery += QString(", (?,?,?)").repeated(int(tagDataToInsert.size() - 1)); + for (const auto &item : tagDataToInsert) { + args.emplace_back(item.evidenceID); + args.emplace_back(item.tagID); + args.emplace_back(item.name); + } + executeQuery(&db, baseQuery, args); + } +} + +DBQuery DatabaseConnection::buildGetEvidenceWithFiltersQuery(const EvidenceFilters &filters) { + QString query = + "SELECT id, path, operation_slug, content_type, description, error, recorded_date, " + "upload_date" + " FROM evidence"; + std::vector values; + std::vector parts; + + if (filters.hasError != Tri::Any) { + parts.emplace_back(" error LIKE ? "); + // _% will ensure at least one character exists in the error column, ensuring it's populated + values.emplace_back(filters.hasError == Tri::Yes ? "_%" : ""); + } + if (filters.submitted != Tri::Any) { + parts.emplace_back((filters.submitted == Tri::Yes) ? " upload_date IS NOT NULL " + : " upload_date IS NULL "); + } + if (!filters.operationSlug.isEmpty()) { + parts.emplace_back(" operation_slug = ? "); + values.emplace_back(filters.operationSlug); + } + if (!filters.contentType.isEmpty()) { + parts.emplace_back(" content_type = ? "); + values.emplace_back(filters.contentType); + } + if (filters.startDate.isValid()) { + parts.emplace_back(" recorded_date >= ? "); + values.emplace_back(filters.startDate); + } + if (filters.endDate.isValid()) { + auto realEndDate = filters.endDate.addDays(1); + parts.emplace_back(" recorded_date < ? "); + values.emplace_back(realEndDate); + } + + if (!parts.empty()) { + query += " WHERE " + parts.at(0); + for (size_t i = 1; i < parts.size(); i++) { + query += " AND " + parts.at(i); + } + } + return DBQuery(query, values); +} + +std::vector DatabaseConnection::getEvidenceWithFilters( + const EvidenceFilters &filters) { + auto dbQuery = buildGetEvidenceWithFiltersQuery(filters); + auto resultSet = executeQuery(&db, dbQuery.query(), dbQuery.values()); + + std::vector allEvidence; + while (resultSet.next()) { + model::Evidence evi; + evi.id = resultSet.value("id").toLongLong(); + evi.path = resultSet.value("path").toString(); + evi.operationSlug = resultSet.value("operation_slug").toString(); + evi.contentType = resultSet.value("content_type").toString(); + evi.description = resultSet.value("description").toString(); + evi.errorText = resultSet.value("error").toString(); + evi.recordedDate = resultSet.value("recorded_date").toDateTime(); + evi.uploadDate = resultSet.value("upload_date").toDateTime(); + + allEvidence.push_back(evi); + } + + return allEvidence; +} + +// migrateDB checks the migration status and then performs the full migration for any +// lacking update. +// +// Throws exceptions/FileError if a migration file cannot be found. +void DatabaseConnection::migrateDB() { + std::cout << "Checking database state" << std::endl; + auto migrationsToApply = DatabaseConnection::getUnappliedMigrations(); + + for (const QString &newMigration : migrationsToApply) { + QFile migrationFile(":/migrations/" + newMigration); + auto ok = migrationFile.open(QFile::ReadOnly); + if (!ok) { + throw FileError::mkError("Error reading migration file", + migrationFile.fileName().toStdString(), migrationFile.error()); + } + auto content = QString(migrationFile.readAll()); + migrationFile.close(); + + std::cout << "Applying Migration: " << newMigration.toStdString() << std::endl; + auto upScript = extractMigrateUpContent(content); + executeQuery(&db, upScript); + executeQuery(&db, + "INSERT INTO migrations (migration_name, applied_at) VALUES (?, datetime('now'))", + {newMigration}); + } + std::cout << "All migrations applied" << std::endl; +} + +// getUnappliedMigrations retrieves a list of all of the migrations that have not been applied +// to the local database. +// +// Note: All sql files must end in ".sql" to be picked up +// +// Throws: +// * BadDatabaseStateError if some migrations have been applied that are not known +// * QSqlError if database queries fail +QStringList DatabaseConnection::getUnappliedMigrations() { + QDir migrationsDir(":/migrations"); + + auto allMigrations = migrationsDir.entryList(QDir::Files, QDir::Name); + QStringList appliedMigrations; + QStringList migrationsToApply; + + QSqlQuery dbMigrations("SELECT migration_name FROM migrations"); + + if (dbMigrations.exec()) { + while (dbMigrations.next()) { + appliedMigrations << dbMigrations.value("migration_name").toString(); + } + } + // compare the two list to find gaps + for (const QString &possibleMigration : allMigrations) { + if (possibleMigration.right(4) != ".sql") { + continue; // assume non-sql files aren't actual migrations. + } + auto foundIndex = appliedMigrations.indexOf(possibleMigration); + if (foundIndex == -1) { + migrationsToApply << possibleMigration; + } + else { + appliedMigrations.removeAt(foundIndex); + } + } + if (!appliedMigrations.empty()) { + throw BadDatabaseStateError(); + } + return migrationsToApply; +} + +// extractMigrateUpContent parses the given migration content and retrieves only +// the portion that applies to the "up" / apply logic. The "down" section is ignored. +QString DatabaseConnection::extractMigrateUpContent(const QString &allContent) noexcept { + auto copying = false; + QString upContent; + for (const QString &line : allContent.split("\n")) { + if (line.trimmed().toLower() == "-- +migrate up") { + copying = true; + } + else if (line.trimmed().toLower() == "-- +migrate down") { + if (copying) { + break; + } + copying = false; + } + else if (copying) { + upContent.append(line + "\n"); + } + } + return upContent; +} + +// executeQuery simply attempts to execute the given stmt with the passed args. The statement is +// first prepared, and arg placements can be specified with "?" +// +// Throws: QSqlError when a query error occurs +QSqlQuery DatabaseConnection::executeQuery(QSqlDatabase *db, const QString &stmt, + const std::vector &args) { + QSqlQuery query(*db); + + if (!query.prepare(stmt)) { + throw query.lastError(); + } + + for (const auto &arg : args) { + query.addBindValue(arg); + } + + if (!query.exec()) { + throw query.lastError(); + } + return query; +} + +// doInsert is a version of executeQuery that returns the last inserted id, rather than the +// underlying query/response +// +// Throws: QSqlError when a query error occurs +qint64 DatabaseConnection::doInsert(QSqlDatabase *db, const QString &stmt, + const std::vector &args) { + auto query = executeQuery(db, stmt, args); + + return query.lastInsertId().toLongLong(); +} diff --git a/src/db/databaseconnection.h b/src/db/databaseconnection.h new file mode 100644 index 0000000..685c467 --- /dev/null +++ b/src/db/databaseconnection.h @@ -0,0 +1,70 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#ifndef DATABASECONNECTION_H +#define DATABASECONNECTION_H + +#include +#include +#include +#include +#include +#include +#include + +#include "forms/evidence_filter/evidencefilter.h" +#include "models/evidence.h" + +static QString dbPath = + QStandardPaths::writableLocation(QStandardPaths::ConfigLocation) + "/ashirt/screenshot.sqlite"; + +class DBQuery { + private: + QString _query; + std::vector _values; + + public: + DBQuery(QString query) : DBQuery(query, {}) {} + DBQuery(QString query, std::vector values) { + this->_query = query; + this->_values = values; + } + inline QString query() { return _query; } + inline std::vector values() { return _values; } +}; + +class DatabaseConnection { + public: + DatabaseConnection(); + + void connect(); + void close() noexcept; + + DBQuery buildGetEvidenceWithFiltersQuery(const EvidenceFilters &filters); + + model::Evidence getEvidenceDetails(qint64 evidenceID); + std::vector getEvidenceWithFilters(const EvidenceFilters &filters); + + qint64 createEvidence(const QString &filepath, const QString &operationSlug, + const QString &contentType); + + void updateEvidenceDescription(const QString &newDescription, qint64 evidenceID); + void updateEvidenceError(const QString &errorText, qint64 evidenceID); + void updateEvidenceSubmitted(qint64 evidenceID); + void setEvidenceTags(const std::vector &newTags, qint64 evidenceID); + + void deleteEvidence(qint64 evidenceID); + + private: + QSqlDatabase db; + + void migrateDB(); + QStringList getUnappliedMigrations(); + + static QString extractMigrateUpContent(const QString &allContent) noexcept; + static QSqlQuery executeQuery(QSqlDatabase *db, const QString &stmt, + const std::vector &args = {}); + static qint64 doInsert(QSqlDatabase *db, const QString &stmt, const std::vector &args); +}; + +#endif // DATABASECONNECTION_H diff --git a/src/dtos/operation.h b/src/dtos/operation.h new file mode 100644 index 0000000..89b3553 --- /dev/null +++ b/src/dtos/operation.h @@ -0,0 +1,52 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#ifndef DTO_OPERATION_H +#define DTO_OPERATION_H + +#include +#include + +#include "helpers/jsonhelpers.h" + +namespace dto { +class Operation { + public: + Operation() {} + + enum OperationStatus { + OperationStatusPlanning = 0, + OperationStatusAcitve = 1, + OperationStatusComplete = 2, + }; + + QString slug; + QString name; + int numUsers; + OperationStatus status; + qint64 id; + + static Operation parseData(QByteArray data) { + return parseJSONItem(data, Operation::fromJson); + } + + static std::vector parseDataAsList(QByteArray data) { + return parseJSONList(data, Operation::fromJson); + } + + private: + // provides a Operation from a given QJsonObject + static Operation fromJson(QJsonObject obj) { + Operation o; + o.slug = obj["slug"].toString(); + o.name = obj["name"].toString(); + o.numUsers = obj["numUsers"].toInt(); + o.status = static_cast(obj["status"].toInt()); + o.id = obj["id"].toVariant().toLongLong(); + + return o; + } +}; +} // namespace dto + +#endif // DTO_OPERATION_H diff --git a/src/dtos/tag.h b/src/dtos/tag.h new file mode 100644 index 0000000..080a1f4 --- /dev/null +++ b/src/dtos/tag.h @@ -0,0 +1,51 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#ifndef DTO_TAG_H +#define DTO_TAG_H + +#include +#include + +#include "helpers/jsonhelpers.h" + +namespace dto { +class Tag { + public: + Tag() {} + Tag(QString name, QString colorName) { + this->name = name; + this->colorName = colorName; + } + + qint64 id; + QString colorName; + QString name; + + static Tag parseData(QByteArray data) { return parseJSONItem(data, Tag::fromJson); } + + static std::vector parseDataAsList(QByteArray data) { + return parseJSONList(data, Tag::fromJson); + } + + static QByteArray toJson(Tag t) { + QJsonObject obj; + obj.insert("colorName", t.colorName); + obj.insert("name", t.name); + return QJsonDocument(obj).toJson(); + } + + private: + // provides a Tag from a given QJsonObject + static Tag fromJson(QJsonObject obj) { + Tag t; + t.id = obj["id"].toVariant().toLongLong(); + t.colorName = obj["colorName"].toString(); + t.name = obj["name"].toString(); + + return t; + } +}; +} // namespace dto + +#endif // DTO_TAG_H diff --git a/src/exceptions/databaseerr.h b/src/exceptions/databaseerr.h new file mode 100644 index 0000000..0af0f06 --- /dev/null +++ b/src/exceptions/databaseerr.h @@ -0,0 +1,20 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#ifndef DATABASEERR_H +#define DATABASEERR_H + +#include + +class BadDatabaseStateError : public std::runtime_error { + public: + BadDatabaseStateError() : std::runtime_error("Database is in an inconsistent state") {} +}; + +class DBDriverUnavailableError : public std::runtime_error { + public: + DBDriverUnavailableError(std::string friendlyDriverName) + : std::runtime_error(friendlyDriverName + " driver is unavailable") {} +}; + +#endif // DATABASEERR_H diff --git a/src/exceptions/fileerror.h b/src/exceptions/fileerror.h new file mode 100644 index 0000000..11993d4 --- /dev/null +++ b/src/exceptions/fileerror.h @@ -0,0 +1,69 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#ifndef FILEERROR_H +#define FILEERROR_H + +#include +#include +#include + +class FileError : public std::runtime_error { + public: + static FileError mkError(std::string msg, std::string path, QFileDevice::FileError err) { + std::string suberror; + switch (err) { + case QFileDevice::ReadError: + suberror = "Error reading file"; + break; + case QFileDevice::WriteError: + suberror = "Error writing file"; + break; + case QFileDevice::FatalError: + suberror = "Fatal error occurred"; + break; + case QFileDevice::ResourceError: + suberror = "Insufficient resources available"; + break; + case QFileDevice::OpenError: + suberror = "Could not open file"; + break; + case QFileDevice::AbortError: + suberror = "Operation was aborted"; + break; + case QFileDevice::TimeOutError: + suberror = "Operation timed out"; + break; + case QFileDevice::UnspecifiedError: + suberror = "Unknown Error"; + break; + case QFileDevice::RemoveError: + suberror = "Unable to remove file"; + break; + case QFileDevice::RenameError: + suberror = "Unable to rename/move file"; + break; + case QFileDevice::PositionError: + suberror = "Position error"; // I don't think we'll ever enounter this error + break; + case QFileDevice::ResizeError: + suberror = "Unable to resize file"; + break; + case QFileDevice::PermissionsError: + suberror = "Unable to access file"; + break; + case QFileDevice::CopyError: + suberror = "Unable to copy file"; + break; + case QFileDevice::NoError: + suberror = "Actually, no error occurred -- just bad programming."; + break; + } + return msg + " (path: " + path + "): " + suberror; + } + + private: + FileError(std::string msg) : std::runtime_error(msg) {} +}; + +#endif // FILEERROR_H diff --git a/src/forms/buttonboxform.cpp b/src/forms/buttonboxform.cpp new file mode 100644 index 0000000..dcae677 --- /dev/null +++ b/src/forms/buttonboxform.cpp @@ -0,0 +1,38 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#include "buttonboxform.h" + +#include + +ButtonBoxForm::ButtonBoxForm(QWidget *parent) : QDialog(parent) {} + +void ButtonBoxForm::setButtonBox(QDialogButtonBox *buttonBox) { this->buttonBox = buttonBox; } + +void ButtonBoxForm::routeButtonPress(QAbstractButton *btn) { + if (buttonBox == nullptr) { + return; + } + if (buttonBox->button(QDialogButtonBox::Ok) == btn) { + onOkClicked(); + } + if (buttonBox->button(QDialogButtonBox::Save) == btn) { + onSaveClicked(); + } + else if (buttonBox->button(QDialogButtonBox::Apply) == btn) { + onApplyClicked(); + } + else if (buttonBox->button(QDialogButtonBox::Close) == btn) { + onCloseClicked(); + } + else if (buttonBox->button(QDialogButtonBox::Cancel) == btn) { + onCancelClicked(); + } +} + +// default functions that just ignore the result, for easier use. +void ButtonBoxForm::onOkClicked() {} +void ButtonBoxForm::onSaveClicked() {} +void ButtonBoxForm::onApplyClicked() {} +void ButtonBoxForm::onCloseClicked() {} +void ButtonBoxForm::onCancelClicked() {} diff --git a/src/forms/buttonboxform.h b/src/forms/buttonboxform.h new file mode 100644 index 0000000..ad727f3 --- /dev/null +++ b/src/forms/buttonboxform.h @@ -0,0 +1,31 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#ifndef BUTTONBOXFORM_H +#define BUTTONBOXFORM_H + +#include +#include +#include + +class ButtonBoxForm : public QDialog { + Q_OBJECT + + public: + explicit ButtonBoxForm(QWidget* parent = nullptr); + ~ButtonBoxForm() = default; + void setButtonBox(QDialogButtonBox* buttonBox); + virtual void onOkClicked(); + virtual void onSaveClicked(); + virtual void onApplyClicked(); + virtual void onCloseClicked(); + virtual void onCancelClicked(); + + public slots: + void routeButtonPress(QAbstractButton* btn); + + private: + QDialogButtonBox* buttonBox = nullptr; +}; + +#endif // BUTTONBOXFORM_H diff --git a/src/forms/credits/credits.cpp b/src/forms/credits/credits.cpp new file mode 100644 index 0000000..e29d6e0 --- /dev/null +++ b/src/forms/credits/credits.cpp @@ -0,0 +1,119 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#include "credits.h" +#include "ui_credits.h" + +#include + +struct Attribution { + std::string library; + std::string libraryUrl; + std::string authors; + std::string license; + std::string licenseUrl; + + Attribution() = default; + Attribution(std::string library, std::string libraryUrl, std::string authors, std::string license, + std::string licenseUrl) { + this->library = library; + this->libraryUrl = libraryUrl; + this->authors = authors; + this->license = license; + this->licenseUrl = licenseUrl; + } +}; + +static std::string hyperlinkMd(std::string label, std::string url) { + return "[" + label + "](" + url + ")"; +} + +static std::string attributionMarkdown() { + std::vector attribs = { + Attribution("Qt", "http://qt.io", "The Qt Company", "GPL v3", + "https://www.gnu.org/licenses/gpl-3.0.html"), + Attribution("QProgressIndicator", "https://github.com/mojocorp/QProgressIndicator", + "Mojocorp, et al", "MIT License", + "https://github.com/mojocorp/QProgressIndicator/blob/master/LICENSE"), + Attribution("MultipartEncoder", "https://github.com/AndsonYe/MultipartEncoder", "Ye Yangang", + "MIT License", + "https://github.com/AndsonYe/MultipartEncoder/blob/master/LICENSE"), + Attribution("UGlobalHotkey", "https://github.com/joelatdeluxe/UGlobalHotkey", + "Anton Konstantinov, et al", "Public Domain", ""), + Attribution("AspectRatioPixmapLabel", "https://stackoverflow.com/a/22618496/4262552", + "phyatt, et al", "CC BY-SA 3.0", + "https://creativecommons.org/licenses/by-sa/3.0/"), + }; + + std::string msg = + "This application uses the following open source software:\n\n" + "| Project/Library | Authors | License |\n" + "| --------------- | ------- | ------- |\n"; + + for (const auto& attrib : attribs) { + std::string license; + std::string suffix; + if (!attrib.licenseUrl.empty()) { + license += "["; + suffix = "](" + attrib.licenseUrl + ")"; + } + license += attrib.license + suffix; + + // clang-format off + msg += "| " + hyperlinkMd(attrib.library, attrib.libraryUrl) + + " | " + attrib.authors + + " | " + license + + " |\n"; + // clang-format on + } + + return msg; +} + +static std::string copyrightDate() { + int initialYear = 2020; + int currentYear = QDateTime::currentDateTime().date().year(); + auto rtn = std::to_string(initialYear); + if (currentYear != initialYear) { + rtn += "-" + std::to_string(currentYear); + } + return rtn; +} +static std::string versionData() { return "Beta 1"; } + +static std::string userGuideUrl = "https://www.github.com/ascreen/blob/master/README.md"; +static std::string reportAnIssueUrl = "https://www.github.com/ascreen/issues"; + +static std::string preambleMarkdown() { + const std::string lf = "\n\n"; // double linefeed to add in linebreaks in markdown + // clang-format off + return "Version: " + versionData() + + lf + "Copyright " + copyrightDate() + ", Verizon Media" + + lf + "Licensed under the terms of [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html)" + + lf + "A short user guide can be found " + hyperlinkMd("here", userGuideUrl) + + lf + "Report issues " + hyperlinkMd("here", reportAnIssueUrl) + + lf; + // clang-format on +} + +static std::string bodyMarkdown() { + // clang-format off + return "# AScreen\n\n" + + preambleMarkdown() + + "## Credits\n" + + attributionMarkdown(); + // clang-format on +} + +Credits::Credits(QWidget* parent) : QDialog(parent), ui(new Ui::Credits) { + ui->setupUi(this); + + ui->creditsArea->setMarkdown(bodyMarkdown().c_str()); + + // Make the dialog pop up above any other windows but retain title bar and buttons + Qt::WindowFlags flags = this->windowFlags(); + flags |= Qt::CustomizeWindowHint | Qt::WindowStaysOnTopHint | Qt::WindowMinMaxButtonsHint | Qt::WindowCloseButtonHint; + this->setWindowFlags(flags); +} + +Credits::~Credits() { delete ui; } diff --git a/src/forms/credits/credits.h b/src/forms/credits/credits.h new file mode 100644 index 0000000..4aff60f --- /dev/null +++ b/src/forms/credits/credits.h @@ -0,0 +1,24 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#ifndef CREDITS_H +#define CREDITS_H + +#include + +namespace Ui { +class Credits; +} + +class Credits : public QDialog { + Q_OBJECT + + public: + explicit Credits(QWidget *parent = nullptr); + ~Credits(); + + private: + Ui::Credits *ui; +}; + +#endif // CREDITS_H diff --git a/src/forms/credits/credits.ui b/src/forms/credits/credits.ui new file mode 100644 index 0000000..df7ba79 --- /dev/null +++ b/src/forms/credits/credits.ui @@ -0,0 +1,74 @@ + + + Credits + + + + 0 + 0 + 582 + 376 + + + + About + + + + + + true + + + true + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Close + + + + + + + + + buttonBox + accepted() + Credits + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + Credits + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/forms/evidence/evidencemanager.cpp b/src/forms/evidence/evidencemanager.cpp new file mode 100644 index 0000000..71fe87f --- /dev/null +++ b/src/forms/evidence/evidencemanager.cpp @@ -0,0 +1,333 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#include "evidencemanager.h" + +#include +#include +#include +#include +#include +#include + +#include "appsettings.h" +#include "dtos/tag.h" +#include "forms/evidence_filter/evidencefilter.h" +#include "forms/evidence_filter/evidencefilterform.h" +#include "helpers/netman.h" +#include "helpers/stopreply.h" +#include "helpers/ui_helpers.h" +#include "ui_evidencemanager.h" + +enum ColumnIndexes { + COL_DATE_CAPTURED = 0, + COL_OPERATION, + COL_PATH, + COL_CONTENT_TYPE, + COL_DESCRIPTION, + COL_SUBMITTED, + COL_DATE_SUBMITTED, + COL_FAILED, + COL_ERROR_MSG +}; + +EvidenceManager::EvidenceManager(DatabaseConnection* db, QWidget* parent) + : QDialog(parent), ui(new Ui::EvidenceManager) { + ui->setupUi(this); + + this->db = db; + this->evidenceIDForRequest = 0; // initializing to remove clang-tidy warning + evidenceEditor = new EvidenceEditor(db, this); + filterForm = new EvidenceFilterForm(this); + submitButton = + new LoadingButton(ui->submitEvidenceButton->text(), this, ui->submitEvidenceButton); + + // Replace the _evidenceEditorPlaceholder with a proper editor + UiHelpers::replacePlaceholder(ui->_evidenceEditorPlaceholder, evidenceEditor, ui->gridLayout); + evidenceEditor->setSizePolicy(QSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding)); + + UiHelpers::replacePlaceholder(ui->submitEvidenceButton, submitButton, ui->gridLayout); + ui->submitEvidenceButton->setVisible(false); + ui->gridLayout->removeWidget(ui->submitEvidenceButton); + + wireUi(); +} + +EvidenceManager::~EvidenceManager() { + delete ui; + delete evidenceEditor; + delete filterForm; + delete submitButton; + stopReply(&uploadAssetReply); +} + +void EvidenceManager::closeEvent(QCloseEvent* event) { + QDialog::closeEvent(event); + evidenceEditor->updateEvidence(-1, true); +} + +void EvidenceManager::showEvent(QShowEvent* evt) { + QDialog::showEvent(evt); + resetFilterButtonClicked(); +} + +void EvidenceManager::wireUi() { + auto btnClicked = &QPushButton::clicked; + connect(submitButton, btnClicked, this, &EvidenceManager::submitEvidenceButtonClicked); + connect(ui->deleteEvidenceButton, btnClicked, this, + &EvidenceManager::deleteEvidenceButtonClicked); + connect(ui->applyFilterButton, btnClicked, this, &EvidenceManager::applyFilterButtonClicked); + connect(ui->resetFilterButton, btnClicked, this, &EvidenceManager::resetFilterButtonClicked); + connect(ui->editFiltersButton, btnClicked, this, &EvidenceManager::openFiltersMenu); + connect(ui->filterTextBox, &QLineEdit::returnPressed, this, + &EvidenceManager::applyFilterButtonClicked); + connect(ui->closeFormButton, btnClicked, this, &EvidenceManager::close); + + connect(filterForm, &EvidenceFilterForm::evidenceSet, this, &EvidenceManager::applyFilterForm); + connect(ui->evidenceTable, &QTableWidget::currentCellChanged, this, + &EvidenceManager::onRowChanged); + connect(this, &EvidenceManager::evidenceChanged, evidenceEditor, &EvidenceEditor::updateEvidence); +} + +void EvidenceManager::submitEvidenceButtonClicked() { + submitButton->startAnimation(); + setActionButtonsEnabled(false); + if (saveData()) { + evidenceIDForRequest = selectedRowEvidenceID(); + try { + model::Evidence evi = db->getEvidenceDetails(evidenceIDForRequest); + uploadAssetReply = NetMan::getInstance().uploadAsset(evi); + connect(uploadAssetReply, &QNetworkReply::finished, this, &EvidenceManager::onUploadComplete); + } + catch (QSqlError& e) { + QMessageBox::warning(this, "Cannot submit evidence", + "Could not retrieve data. Please try again."); + } + } +} + +void EvidenceManager::deleteEvidenceButtonClicked() { + auto reply = QMessageBox::question(this, "Discard Evidence", + "Are you sure you want to discard this evidence? This will " + "only delete this evidence on your computer.", + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + + if (reply == QMessageBox::Yes) { + auto deleteResp = evidenceEditor->deleteEvidence(); + auto evi = deleteResp.model; + + if (!deleteResp.dbDeleteSuccess) { + std::cout << "Could not delete evidence from internal database. Error: " + << deleteResp.errorText.toStdString() << std::endl; + } + else if (!deleteResp.fileDeleteSuccess) { + QMessageBox::warning(this, "Could not delete", + "Unable to delete evidence file.\n" + "You can try deleting the file directly. File Location:\n" + + evi.path); + } + else { + loadEvidence(); + } + + ui->closeFormButton->setEnabled(true); + } +} + +void EvidenceManager::applyFilterButtonClicked() { loadEvidence(); } + +void EvidenceManager::resetFilterButtonClicked() { + EvidenceFilters filter; + filter.operationSlug = AppSettings::getInstance().operationSlug(); + ui->filterTextBox->setText(filter.toString()); + loadEvidence(); +} + +void EvidenceManager::applyFilterForm(const EvidenceFilters& filter) { + ui->filterTextBox->setText(filter.toString()); + applyFilterButtonClicked(); +} + +void EvidenceManager::loadEvidence() { + enableEvidenceButtons(false); + ui->evidenceTable->clearContents(); + + try { + auto filter = EvidenceFilters::parseFilter(ui->filterTextBox->text()); + std::vector operationEvidence = db->getEvidenceWithFilters(filter); + ui->evidenceTable->setRowCount(operationEvidence.size()); + + // removing sorting temporarily to solve a bug (per qt: not a bug) + // Essentially, _not_ doing this breaks reloading the table. Mostly empty cells appear. + // from: https://stackoverflow.com/a/8904287/4262552 + // see also: https://bugreports.qt.io/browse/QTBUG-75479 + ui->evidenceTable->setSortingEnabled(false); + for (size_t row = 0; row < operationEvidence.size(); row++) { + auto evi = operationEvidence.at(row); + auto rowData = buildBaseEvidenceRow(evi.id); + + ui->evidenceTable->setItem(row, COL_OPERATION, rowData.operation); + ui->evidenceTable->setItem(row, COL_DESCRIPTION, rowData.description); + ui->evidenceTable->setItem(row, COL_CONTENT_TYPE, rowData.contentType); + ui->evidenceTable->setItem(row, COL_DATE_CAPTURED, rowData.dateCaptured); + ui->evidenceTable->setItem(row, COL_PATH, rowData.path); + ui->evidenceTable->setItem(row, COL_FAILED, rowData.failed); + ui->evidenceTable->setItem(row, COL_ERROR_MSG, rowData.errorText); + ui->evidenceTable->setItem(row, COL_SUBMITTED, rowData.submitted); + ui->evidenceTable->setItem(row, COL_DATE_SUBMITTED, rowData.dateSubmitted); + + setRowText(row, evi); + } + ui->evidenceTable->setSortingEnabled(true); + } + catch (QSqlError& e) { + std::cout << "Could not retrieve evidence for operation. Error: " << e.text().toStdString() + << std::endl; + } +} + +// buildBaseEvidenceRow constructs a container for a row of data. +// Note: the row (container) is on the stack, but items in the container +// are on the heap, and must be deleted. +EvidenceRow EvidenceManager::buildBaseEvidenceRow(qint64 evidenceID) { + EvidenceRow row{}; + auto basicItem = [evidenceID]() -> QTableWidgetItem* { + auto item = new QTableWidgetItem(); + item->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); + item->setData(Qt::UserRole, evidenceID); + return item; + }; + + row.dateCaptured = basicItem(); + row.description = basicItem(); + row.operation = basicItem(); + row.contentType = basicItem(); + row.path = basicItem(); + row.errorText = basicItem(); + row.dateSubmitted = basicItem(); + + row.submitted = basicItem(); + row.submitted->setTextAlignment(Qt::AlignCenter); + row.failed = basicItem(); + row.failed->setTextAlignment(Qt::AlignCenter); + + return row; +} + +void EvidenceManager::setRowText(int row, const model::Evidence& model) { + static QString dateFormat = "MMM dd, yyyy hh:mm"; + + ui->evidenceTable->item(row, COL_DATE_CAPTURED)->setText(model.recordedDate.toString(dateFormat)); + ui->evidenceTable->item(row, COL_DESCRIPTION)->setText(model.description); + ui->evidenceTable->item(row, COL_OPERATION)->setText(model.operationSlug); + ui->evidenceTable->item(row, COL_CONTENT_TYPE)->setText(model.contentType); + ui->evidenceTable->item(row, COL_SUBMITTED)->setText(model.uploadDate.isNull() ? "No" : "Yes"); + ui->evidenceTable->item(row, COL_FAILED)->setText((model.errorText == "") ? "" : "Yes"); + ui->evidenceTable->item(row, COL_PATH)->setText(model.path); + ui->evidenceTable->item(row, COL_ERROR_MSG)->setText(model.errorText); + + auto uploadDateText = model.uploadDate.isNull() ? "Never" : model.uploadDate.toString(dateFormat); + ui->evidenceTable->item(row, COL_DATE_SUBMITTED)->setText(uploadDateText); +} + +void EvidenceManager::refreshRow(int row) { + auto evidenceID = selectedRowEvidenceID(); + try { + auto updatedData = db->getEvidenceDetails(evidenceID); + setRowText(row, updatedData); + } + catch (QSqlError& e) { + std::cout << "Could not refresh table row: " << e.text().toStdString() << std::endl; + } +} + +void EvidenceManager::setActionButtonsEnabled(bool enabled) { + enableEvidenceButtons(enabled); + ui->closeFormButton->setEnabled(enabled); +} + +void EvidenceManager::enableEvidenceButtons(bool enabled) { + submitButton->setEnabled(enabled); + ui->deleteEvidenceButton->setEnabled(enabled); +} + +bool EvidenceManager::saveData() { + auto saveResponse = evidenceEditor->saveEvidence(); + if (saveResponse.actionSucceeded) { + refreshRow(ui->evidenceTable->currentRow()); + return true; + } + + QMessageBox::warning(this, "Cannot Save", + "Unable to save evidence data.\n" + "You can try uploading directly to the website. File Location:\n" + + saveResponse.model.path); + return false; +} + +void EvidenceManager::openFiltersMenu() { + filterForm->setForm(EvidenceFilters::parseFilter(ui->filterTextBox->text())); + filterForm->open(); +} + +void EvidenceManager::onRowChanged(int currentRow, int _currentColumn, int _previousRow, + int _previousColumn) { + Q_UNUSED(_currentColumn); + Q_UNUSED(_previousRow); + Q_UNUSED(_previousColumn); + + if (currentRow == -1) { + enableEvidenceButtons(false); + emit evidenceChanged(-1, true); + return; + } + + auto evidence = db->getEvidenceDetails(selectedRowEvidenceID()); + enableEvidenceButtons(true); + + auto readonly = evidence.uploadDate.isValid(); + submitButton->setEnabled(!readonly); + emit evidenceChanged(evidence.id, true); +} + +void EvidenceManager::onUploadComplete() { + bool isValid; + NetMan::extractResponse(uploadAssetReply, isValid); + + if (!isValid) { + auto errMessage = + "Unable to upload evidence: Network error (" + uploadAssetReply->errorString() + ")"; + try { + db->updateEvidenceError(errMessage, evidenceIDForRequest); + } + catch (QSqlError& e) { + std::cout << "Upload failed. Could not update internal database. Error: " + << e.text().toStdString() << std::endl; + } + QMessageBox::warning(this, "Cannot Submit Evidence", + "Upload failed: Network error. Check your connection and try again.\n" + "(Error: " + uploadAssetReply->errorString() + ")"); + } + else { + try { + db->updateEvidenceSubmitted(evidenceIDForRequest); + } + catch (QSqlError& e) { + std::cout << "Upload successful. Could not update internal database. Error: " + << e.text().toStdString() << std::endl; + } + emit evidenceChanged(evidenceIDForRequest, true); // lock the editing form + } + refreshRow(ui->evidenceTable->currentRow()); + + // we don't actually need anything from the uploadAssets reply, so just clean it up. + // one thing we might want to record: evidence uuid... not sure why we'd need it though. + submitButton->stopAnimation(); + + ui->closeFormButton->setEnabled(true); + tidyReply(&uploadAssetReply); +} + +qint64 EvidenceManager::selectedRowEvidenceID() { + return ui->evidenceTable->currentItem()->data(Qt::UserRole).toLongLong(); +} diff --git a/src/forms/evidence/evidencemanager.h b/src/forms/evidence/evidencemanager.h new file mode 100644 index 0000000..8cc276a --- /dev/null +++ b/src/forms/evidence/evidencemanager.h @@ -0,0 +1,83 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#ifndef EVIDENCEMANAGER_H +#define EVIDENCEMANAGER_H + +#include +#include +#include + +#include "components/evidence_editor/evidenceeditor.h" +#include "components/loading_button/loadingbutton.h" +#include "db/databaseconnection.h" +#include "forms/evidence_filter/evidencefilterform.h" + +namespace Ui { +class EvidenceManager; +} + +// QTableWidget should memory-manage this data. +struct EvidenceRow { + QTableWidgetItem* dateCaptured; + QTableWidgetItem* description; + QTableWidgetItem* contentType; + QTableWidgetItem* operation; + QTableWidgetItem* submitted; + QTableWidgetItem* failed; + QTableWidgetItem* path; + QTableWidgetItem* errorText; + QTableWidgetItem* dateSubmitted; +}; + +class EvidenceManager : public QDialog { + Q_OBJECT + + public: + explicit EvidenceManager(DatabaseConnection* db, QWidget* parent = nullptr); + ~EvidenceManager(); + + protected: + void closeEvent(QCloseEvent* event) override; + + private: + void wireUi(); + + bool saveData(); + void loadEvidence(); + void setActionButtonsEnabled(bool enabled); + EvidenceRow buildBaseEvidenceRow(qint64 evidenceID); + void refreshRow(int row); + void setRowText(int row, const model::Evidence& model); + void enableEvidenceButtons(bool enable); + + void showEvent(QShowEvent* evt) override; + qint64 selectedRowEvidenceID(); + + signals: + void evidenceChanged(quint64 evidenceID, bool readonly); + + private slots: + void submitEvidenceButtonClicked(); + void deleteEvidenceButtonClicked(); + void applyFilterButtonClicked(); + void resetFilterButtonClicked(); + void applyFilterForm(const EvidenceFilters& filter); + void openFiltersMenu(); + + void onRowChanged(int currentRow, int currentColumn, int previousRow, int previousColumn); + void onUploadComplete(); + + private: + Ui::EvidenceManager* ui; + EvidenceEditor* evidenceEditor; + EvidenceFilterForm* filterForm; + LoadingButton* submitButton; + + DatabaseConnection* db; + + QNetworkReply* uploadAssetReply = nullptr; + qint64 evidenceIDForRequest; +}; + +#endif // EVIDENCEMANAGER_H diff --git a/src/forms/evidence/evidencemanager.ui b/src/forms/evidence/evidencemanager.ui new file mode 100644 index 0000000..96ec35e --- /dev/null +++ b/src/forms/evidence/evidencemanager.ui @@ -0,0 +1,209 @@ + + + EvidenceManager + + + + 0 + 0 + 800 + 600 + + + + + 0 + 8 + + + + + 0 + 300 + + + + Evidence Manager + + + + + + Delete + + + false + + + + + + + + + + Apply + + + false + + + + + + + Close + + + false + + + + + + + Submit + + + false + + + + + + + Reset + + + false + + + + + + + Edit Filters + + + false + + + + + + + + 0 + 0 + + + + _evidenceEditorPlaceholder + + + + + + + + 0 + 0 + + + + + 0 + 0 + + + + QAbstractItemView::SingleSelection + + + QAbstractItemView::SelectRows + + + true + + + false + + + true + + + true + + + false + + + + Date Captured + + + + + Operation + + + + + Path + + + + + Content Type + + + + + Description + + + + + Submitted + + + + + Date Submitted + + + + + Failed + + + + + Error + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + editFiltersButton + filterTextBox + evidenceTable + + + + diff --git a/src/forms/evidence_filter/evidencefilter.cpp b/src/forms/evidence_filter/evidencefilter.cpp new file mode 100644 index 0000000..d42c9cb --- /dev/null +++ b/src/forms/evidence_filter/evidencefilter.cpp @@ -0,0 +1,198 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#include "evidencefilter.h" + +EvidenceFilters::EvidenceFilters() = default; + +QString EvidenceFilters::standardizeFilterKey(QString key) { + if (FILTER_KEYS_ERROR.contains(key, Qt::CaseInsensitive)) { + return FILTER_KEY_ERROR; + } + if (FILTER_KEYS_SUBMITTED.contains(key, Qt::CaseInsensitive)) { + return FILTER_KEY_SUBMITTED; + } + if (FILTER_KEYS_TO.contains(key, Qt::CaseInsensitive)) { + return FILTER_KEY_TO; + } + if (FILTER_KEYS_FROM.contains(key, Qt::CaseInsensitive)) { + return FILTER_KEY_FROM; + } + if (FILTER_KEYS_ON.contains(key, Qt::CaseInsensitive)) { + return FILTER_KEY_ON; + } + if (FILTER_KEYS_OPERATION.contains(key, Qt::CaseInsensitive)) { + return FILTER_KEY_OPERATION; + } + if (FILTER_KEYS_CONTENT_TYPE.contains(key, Qt::CaseInsensitive)) { + return FILTER_KEY_CONTENT_TYPE; + } + return key; +} + +QString EvidenceFilters::toString() const { + // helper functions + static auto toCommonDate = [](QDate d) -> QString { + auto today = QDateTime::currentDateTime().date(); + if (d == today) { + return "Today"; + } + if (d == today.addDays(-1)) { + return "Yesterday"; + } + return d.toString("yyyy-MM-dd"); + }; + static auto triToText = [](Tri t) -> QString { return t == Yes ? "yes" : "no"; }; + + QString rtn = ""; + if (!operationSlug.isEmpty()) { + rtn.append(" " + FILTER_KEY_OPERATION + ": " + operationSlug); + } + if (!contentType.isEmpty()) { + rtn.append(" " + FILTER_KEY_CONTENT_TYPE + ": " + contentType); + } + if (hasError != Any) { + rtn.append(" " + FILTER_KEY_ERROR + ": " + triToText(hasError)); + } + if (startDate.isValid() || endDate.isValid()) { + if (startDate == endDate) { + rtn.append(" " + FILTER_KEY_ON + ": " + toCommonDate(startDate)); + } + else { + if (startDate.isValid()) { + rtn.append(" " + FILTER_KEY_FROM + ": " + toCommonDate(startDate)); + } + + if (endDate.isValid()) { + rtn.append(" " + FILTER_KEY_TO + ": " + toCommonDate(endDate)); + } + } + } + if (submitted != Any) { + rtn.append(" " + FILTER_KEY_SUBMITTED + ": " + triToText(submitted)); + } + + return rtn.trimmed(); +} + +EvidenceFilters EvidenceFilters::parseFilter(const QString& text) { + EvidenceFilters filter; + if (text.trimmed().isEmpty()) { + return filter; + } + + auto tokenizedFilter = tokenizeFilterText(text); + + for (const auto& item : tokenizedFilter) { + QString key = EvidenceFilters::standardizeFilterKey(item.first.toLower().trimmed()); + QString value = item.second.trimmed(); + + if (key == FILTER_KEY_ERROR) { + auto val = value.toLower(); + filter.hasError = parseTriFilterValue(val); + } + else if (key == FILTER_KEY_SUBMITTED) { + auto val = value.toLower(); + filter.submitted = parseTriFilterValue(val); + } + else if (key == FILTER_KEY_OPERATION) { + filter.operationSlug = value; + } + else if (key == FILTER_KEY_TO) { + filter.endDate = parseDateString(value); + } + else if (key == FILTER_KEY_FROM) { + filter.startDate = parseDateString(value); + } + else if (key == FILTER_KEY_ON) { + auto formattedValue = parseDateString(value); + filter.startDate = formattedValue; + filter.endDate = formattedValue; + } + else if (key == FILTER_KEY_CONTENT_TYPE) { + filter.contentType = value; + } + } + + return filter; +} + +// parseTriFilterValue returns a Tri object given a string. If the given string is "t" or "y" +// then Tri::Yes will be returned. Otherwise, in non-strict mode, Tri::No will be returned. +// In strict mode, Tri::No will be returned only if it starts with "f" or "n", otherwise Tri::Any +// is returned. +Tri EvidenceFilters::parseTriFilterValue(const QString& text, bool strict) { + auto val = text.toLower().trimmed(); + if (val.startsWith("t") || val.startsWith("y")) { + return Tri::Yes; + } + if (strict) { + return (val.startsWith("f") || val.startsWith("n")) ? Tri::No : Tri::Any; + } + return Tri::No; +} + +std::vector> EvidenceFilters::tokenizeFilterText(const QString& text) { + QStringList list = text.split(":", QString::SplitBehavior::SkipEmptyParts); + // now in: [Key][value key]...[value] format + QStringList keys; + QStringList values; + keys.append(list.first()); + + for (int i = 1; i < list.size() - 1; i++) { + auto valueKeyPair = list.at(i).split(" ", QString::SplitBehavior::SkipEmptyParts); + keys.append(valueKeyPair.last()); + valueKeyPair.removeLast(); + values.append(valueKeyPair.join(" ")); + } + values.append(list.last()); + + std::vector> rtn; + + for (int i = 0; i < keys.length(); i++) { + auto keyvalue = std::pair(keys.at(i), values.at(i)); + rtn.emplace_back(keyvalue); + } + return rtn; +} + +QDate EvidenceFilters::parseDateString(QString text) { + static QString DATE_FORMAT = "yyyy-MM-dd"; + + text = text.toLower(); + if (text == "today") { + text = QDateTime::currentDateTime().toString(DATE_FORMAT); + } + else if (text == "yesterday") { + text = QDateTime::currentDateTime().addDays(-1).toString(DATE_FORMAT); + } + // other thoughts: this week (hard -- spans years), this month, this year, last ${period} + + return QDate::fromString(text, DATE_FORMAT); +} + +// parseTri returns Tri::Yes if the given text is exactly "Yes", Tri::No if the text is exactly "No" +// otherwise Tri::Any. +// This is the inverse of triToString +Tri EvidenceFilters::parseTri(const QString& text) { + if (text == "Yes") { + return Yes; + } + if (text == "No") { + return No; + } + return Any; +} + +// triToString returns "Yes" for Tri::Yes, "No" for Tri::No, otherwise "Any" +// This is the inverse to parseTri +QString EvidenceFilters::triToString(const Tri& tri) { + switch (tri) { + case Yes: + return "Yes"; + case No: + return "No"; + default: + return "Any"; + } +} diff --git a/src/forms/evidence_filter/evidencefilter.h b/src/forms/evidence_filter/evidencefilter.h new file mode 100644 index 0000000..08c4e1b --- /dev/null +++ b/src/forms/evidence_filter/evidencefilter.h @@ -0,0 +1,58 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#ifndef EVIDENCEFILTER_H +#define EVIDENCEFILTER_H + +#include +#include +#include +#include + +enum Tri { Any, Yes, No }; + +// These represent the standard key for a filter +const QString FILTER_KEY_ERROR = "err"; +const QString FILTER_KEY_SUBMITTED = "submitted"; +const QString FILTER_KEY_TO = "to"; +const QString FILTER_KEY_FROM = "from"; +const QString FILTER_KEY_ON = "on"; +const QString FILTER_KEY_OPERATION = "op"; +const QString FILTER_KEY_CONTENT_TYPE = "type"; + +// These represent aliases for standard key for a filter +const QStringList FILTER_KEYS_ERROR = {FILTER_KEY_ERROR, "error", "failed", "fail"}; +const QStringList FILTER_KEYS_SUBMITTED = {FILTER_KEY_SUBMITTED}; +const QStringList FILTER_KEYS_TO = {FILTER_KEY_TO, "before", "til", "until"}; +const QStringList FILTER_KEYS_FROM = {FILTER_KEY_FROM, "after"}; +const QStringList FILTER_KEYS_ON = {FILTER_KEY_ON}; +const QStringList FILTER_KEYS_OPERATION = {FILTER_KEY_OPERATION, "operation"}; +const QStringList FILTER_KEYS_CONTENT_TYPE = {FILTER_KEY_CONTENT_TYPE, "contentType"}; + +class EvidenceFilters { + public: + EvidenceFilters(); + + static QString standardizeFilterKey(QString key); + QString toString() const; + static EvidenceFilters parseFilter(const QString &text); + + public: + QString operationSlug = ""; + QString contentType = ""; + Tri hasError = Any; + Tri submitted = Any; + QDate startDate = QDate(); + QDate endDate = QDate(); + + public: + static Tri parseTri(const QString &text); + static QString triToString(const Tri &tri); + + private: + static std::vector> tokenizeFilterText(const QString &text); + static QDate parseDateString(QString text); + static Tri parseTriFilterValue(const QString &text, bool strict = false); +}; + +#endif // EVIDENCEFILTER_H diff --git a/src/forms/evidence_filter/evidencefilterform.cpp b/src/forms/evidence_filter/evidencefilterform.cpp new file mode 100644 index 0000000..91d1ba6 --- /dev/null +++ b/src/forms/evidence_filter/evidencefilterform.cpp @@ -0,0 +1,129 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#include "evidencefilterform.h" + +#include "appsettings.h" +#include "helpers/netman.h" +#include "helpers/ui_helpers.h" +#include "ui_evidencefilterform.h" + +static void initializeTriCombobox(QComboBox *box) { + box->clear(); + box->addItem("Any"); + box->addItem("Yes"); + box->addItem("No"); +} + +static void initializeDateEdit(QDateEdit *dateEdit) { + dateEdit->setDate(QDateTime::currentDateTime().date()); + dateEdit->setDisplayFormat("MMM dd, yyyy"); + dateEdit->setDateRange(QDate(2000, 01, 01), QDateTime::currentDateTime().date()); + dateEdit->setEnabled(false); +} + +EvidenceFilterForm::EvidenceFilterForm(QWidget *parent) + : QDialog(parent), ui(new Ui::EvidenceFilterForm) { + ui->setupUi(this); + + ui->erroredComboBox->setEditable(false); + ui->operationComboBox->setEditable(false); + ui->submittedComboBox->setEditable(false); + ui->contentTypeComboBox->setEditable(false); + + initializeTriCombobox(ui->submittedComboBox); + initializeTriCombobox(ui->erroredComboBox); + ui->contentTypeComboBox->addItem("", ""); + ui->contentTypeComboBox->addItem("Image", "image"); + ui->contentTypeComboBox->addItem("Codeblock", "codeblock"); + + initializeDateEdit(ui->fromDateEdit); + initializeDateEdit(ui->toDateEdit); + + wireUi(); +} + +EvidenceFilterForm::~EvidenceFilterForm() { delete ui; } + +void EvidenceFilterForm::wireUi() { + connect(&NetMan::getInstance(), &NetMan::operationListUpdated, this, + &EvidenceFilterForm::onOperationListUpdated); + connect(ui->buttonBox, &QDialogButtonBox::accepted, this, &EvidenceFilterForm::writeForm); + + connect(ui->includeStartDateCheckBox, &QCheckBox::stateChanged, + [this](bool checked) { ui->fromDateEdit->setEnabled(checked); }); + connect(ui->includeEndDateCheckBox, &QCheckBox::stateChanged, + [this](bool checked) { ui->toDateEdit->setEnabled(checked); }); +} + +void EvidenceFilterForm::writeForm() { + auto filter = encodeForm(); + emit evidenceSet(filter); +} + +EvidenceFilters EvidenceFilterForm::encodeForm() { + EvidenceFilters filter; + + filter.hasError = EvidenceFilters::parseTri(ui->erroredComboBox->currentText()); + filter.submitted = EvidenceFilters::parseTri(ui->submittedComboBox->currentText()); + filter.operationSlug = ui->operationComboBox->currentData().toString(); + filter.contentType = ui->contentTypeComboBox->currentData().toString(); + + // swap dates so smaller date is always "from" / after + if (ui->fromDateEdit->isEnabled() && ui->toDateEdit->isEnabled() && + ui->fromDateEdit->date() > ui->toDateEdit->date()) { + auto copy = ui->fromDateEdit->date(); + ui->fromDateEdit->setDate(ui->toDateEdit->date()); + ui->toDateEdit->setDate(copy); + } + + if (ui->includeStartDateCheckBox->isChecked()) { + filter.startDate = ui->fromDateEdit->date(); + } + if (ui->includeEndDateCheckBox->isChecked()) { + filter.endDate = ui->toDateEdit->date(); + } + + return filter; +} + +void EvidenceFilterForm::setForm(const EvidenceFilters &model) { + UiHelpers::setComboBoxValue(ui->operationComboBox, model.operationSlug); + UiHelpers::setComboBoxValue(ui->contentTypeComboBox, model.contentType); + ui->erroredComboBox->setCurrentText(EvidenceFilters::triToString(model.hasError)); + ui->submittedComboBox->setCurrentText(EvidenceFilters::triToString(model.submitted)); + + ui->includeStartDateCheckBox->setChecked(model.startDate.isValid()); + ui->fromDateEdit->setDate(model.startDate.isValid() ? model.startDate + : QDateTime::currentDateTime().date()); + + ui->includeEndDateCheckBox->setChecked(model.endDate.isValid()); + ui->toDateEdit->setDate(model.endDate.isValid() ? model.endDate + : QDateTime::currentDateTime().date()); + + // swap dates so smaller date is always "from" / after + if (model.startDate.isValid() && model.endDate.isValid() && + ui->fromDateEdit->date() > ui->toDateEdit->date()) { + auto copy = ui->fromDateEdit->date(); + ui->fromDateEdit->setDate(ui->toDateEdit->date()); + ui->toDateEdit->setDate(copy); + } +} + +void EvidenceFilterForm::onOperationListUpdated(bool success, + const std::vector &operations) { + ui->operationComboBox->setEnabled(false); + if (!success) { + ui->operationComboBox->setItemText(0, "Unable to fetch operations"); + ui->operationComboBox->setCurrentIndex(0); + return; + } + + ui->operationComboBox->clear(); + ui->operationComboBox->addItem("", ""); + for (const auto &op : operations) { + ui->operationComboBox->addItem(op.name, op.slug); + } + UiHelpers::setComboBoxValue(ui->operationComboBox, AppSettings::getInstance().operationSlug()); + ui->operationComboBox->setEnabled(true); +} diff --git a/src/forms/evidence_filter/evidencefilterform.h b/src/forms/evidence_filter/evidencefilterform.h new file mode 100644 index 0000000..e8174ed --- /dev/null +++ b/src/forms/evidence_filter/evidencefilterform.h @@ -0,0 +1,42 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#ifndef EVIDENCEFILTERFORM_H +#define EVIDENCEFILTERFORM_H + +#include +#include + +#include "src/db/databaseconnection.h" +#include "src/dtos/operation.h" + +namespace Ui { +class EvidenceFilterForm; +} + +class EvidenceFilterForm : public QDialog { + Q_OBJECT + + public: + explicit EvidenceFilterForm(QWidget *parent = nullptr); + ~EvidenceFilterForm(); + + private: + void wireUi(); + void writeForm(); + + public: + void setForm(const EvidenceFilters &model); + + signals: + void evidenceSet(EvidenceFilters filter); + + public slots: + void onOperationListUpdated(bool success, const std::vector &operations); + EvidenceFilters encodeForm(); + + private: + Ui::EvidenceFilterForm *ui; +}; + +#endif // EVIDENCEFILTERFORM_H diff --git a/src/forms/evidence_filter/evidencefilterform.ui b/src/forms/evidence_filter/evidencefilterform.ui new file mode 100644 index 0000000..b1ae74f --- /dev/null +++ b/src/forms/evidence_filter/evidencefilterform.ui @@ -0,0 +1,147 @@ + + + EvidenceFilterForm + + + + 0 + 0 + 320 + 240 + + + + Evidence Editor + + + + + + Had Error + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Ok + + + + + + + Operation + + + + + + + To Date + + + + + + + + + + + + + From Date + + + + + + + Include + + + + + + + Include + + + + + + + + + + + + + false + + + + Loading... + + + + + + + + Was Submitted + + + + + + + Content Type + + + + + + + + + + + + buttonBox + accepted() + EvidenceFilterForm + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + EvidenceFilterForm + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/forms/getinfo/getinfo.cpp b/src/forms/getinfo/getinfo.cpp new file mode 100644 index 0000000..7f63da2 --- /dev/null +++ b/src/forms/getinfo/getinfo.cpp @@ -0,0 +1,146 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#include "getinfo.h" + +#include + +#include "appsettings.h" +#include "components/evidence_editor/evidenceeditor.h" +#include "helpers/netman.h" +#include "helpers/stopreply.h" +#include "helpers/ui_helpers.h" +#include "ui_getinfo.h" + +GetInfo::GetInfo(DatabaseConnection* db, qint64 evidenceID, QWidget* parent) + : QDialog(parent), ui(new Ui::GetInfo) { + ui->setupUi(this); + this->db = db; + this->evidenceID = evidenceID; + this->setAttribute(Qt::WA_DeleteOnClose); + + evidenceEditor = new EvidenceEditor(evidenceID, db, this); + evidenceEditor->setEnabled(true); + loadingButton = new LoadingButton(ui->submitButton->text(), this); + + UiHelpers::replacePlaceholder(ui->evidenceEditorPlaceholder, evidenceEditor, ui->gridLayout); + UiHelpers::replacePlaceholder(ui->submitButton, loadingButton, ui->gridLayout); + + wireUi(); + + // Make the dialog pop up above any other windows but retain title bar and buttons + Qt::WindowFlags flags = this->windowFlags(); + flags |= Qt::CustomizeWindowHint | Qt::WindowStaysOnTopHint | Qt::WindowMinMaxButtonsHint | Qt::WindowCloseButtonHint; + this->setWindowFlags(flags); +} + +GetInfo::~GetInfo() { + delete ui; + delete evidenceEditor; + delete loadingButton; + stopReply(&uploadAssetReply); +} + +void GetInfo::wireUi() { + connect(loadingButton, &QPushButton::clicked, this, &GetInfo::submitButtonClicked); + connect(ui->deleteButton, &QPushButton::clicked, this, &GetInfo::deleteButtonClicked); +} + +bool GetInfo::saveData() { + auto saveResponse = evidenceEditor->saveEvidence(); + if (!saveResponse.actionSucceeded) { + QMessageBox::warning(this, "Cannot Save", + "Unable to save evidence data.\n" + "You can try uploading directly to the website. File Location:\n" + + saveResponse.model.path); + } + return saveResponse.actionSucceeded; +} + +void GetInfo::submitButtonClicked() { + loadingButton->startAnimation(); + setActionButtonsEnabled(false); + if (saveData()) { + try { + model::Evidence evi = db->getEvidenceDetails(evidenceID); + uploadAssetReply = NetMan::getInstance().uploadAsset(evi); + connect(uploadAssetReply, &QNetworkReply::finished, this, &GetInfo::onUploadComplete); + } + catch (QSqlError& e) { + QMessageBox::warning(this, "Cannot submit evidence", + "Could not retrieve data. Please try again."); + } + } +} + +void GetInfo::deleteButtonClicked() { + auto reply = QMessageBox::question(this, "Discard Evidence", + "Are you sure you want to discard this evidence?", + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + + if (reply == QMessageBox::Yes) { + setActionButtonsEnabled(false); + bool shouldClose = true; + + model::Evidence evi = evidenceEditor->encodeEvidence(); + if (!QFile::remove(evi.path)) { + QMessageBox::warning(this, "Could not delete", + "Unable to delete evidence file.\n" + "You can try deleting the file directly. File Location:\n" + + evi.path); + shouldClose = false; + } + try { + db->deleteEvidence(evidenceID); + } + catch (QSqlError& e) { + std::cout << "Could not delete evidence from internal database. Error: " + << e.text().toStdString() << std::endl; + } + + setActionButtonsEnabled(true); + if (shouldClose) { + this->close(); + } + } +} + +void GetInfo::setActionButtonsEnabled(bool enabled) { + loadingButton->setEnabled(enabled); + ui->deleteButton->setEnabled(enabled); +} + +void GetInfo::onUploadComplete() { + if (uploadAssetReply->error() != QNetworkReply::NoError) { + auto errMessage = + "Unable to upload evidence: Network error (" + uploadAssetReply->errorString() + ")"; + try { + db->updateEvidenceError(errMessage, this->evidenceID); + } + catch (QSqlError& e) { + std::cout << "Upload failed. Could not update internal database. Error: " + << e.text().toStdString() << std::endl; + } + QMessageBox::warning(this, "Cannot submit evidence", + "Upload failed: Network error. Check your connection and try again.\n" + "Note: This evidence has been saved. You can close this window and " + "re-submit from the evidence manager." + "\n(Error: " + + uploadAssetReply->errorString() + ")"); + } + else { + try { + db->updateEvidenceSubmitted(this->evidenceID); + this->close(); + } + catch (QSqlError& e) { + std::cout << "Upload successful. Could not update internal database. Error: " + << e.text().toStdString() << std::endl; + } + } + // we don't actually need anything from the uploadAssets reply, so just clean it up. + // one thing we might want to record: evidence uuid... not sure why we'd need it though. + loadingButton->stopAnimation(); + setActionButtonsEnabled(true); + tidyReply(&uploadAssetReply); +} diff --git a/src/forms/getinfo/getinfo.h b/src/forms/getinfo/getinfo.h new file mode 100644 index 0000000..7fc754e --- /dev/null +++ b/src/forms/getinfo/getinfo.h @@ -0,0 +1,49 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#ifndef GETINFO_H +#define GETINFO_H + +#include +#include + +#include "components/evidence_editor/evidenceeditor.h" +#include "components/loading/qprogressindicator.h" +#include "components/loading_button/loadingbutton.h" +#include "db/databaseconnection.h" +#include "dtos/tag.h" + +namespace Ui { +class GetInfo; +} + +class GetInfo : public QDialog { + Q_OBJECT + + public: + explicit GetInfo(DatabaseConnection *db, qint64 evidenceID, QWidget *parent = nullptr); + ~GetInfo(); + + private: + void wireUi(); + bool saveData(); + void setActionButtonsEnabled(bool enabled); + + private slots: + void submitButtonClicked(); + void deleteButtonClicked(); + + void onUploadComplete(); + + private: + Ui::GetInfo *ui; + DatabaseConnection *db; + qint64 evidenceID; + + EvidenceEditor *evidenceEditor; + + QNetworkReply *uploadAssetReply = nullptr; + LoadingButton *loadingButton; +}; + +#endif // GETINFO_H diff --git a/src/forms/getinfo/getinfo.ui b/src/forms/getinfo/getinfo.ui new file mode 100644 index 0000000..ada6bf5 --- /dev/null +++ b/src/forms/getinfo/getinfo.ui @@ -0,0 +1,68 @@ + + + GetInfo + + + + 0 + 0 + 640 + 480 + + + + Add Evidence Details + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Submit + + + + + + + Delete + + + + + + + + + + + + + + + diff --git a/src/forms/settings/settings.cpp b/src/forms/settings/settings.cpp new file mode 100644 index 0000000..0ff8b7e --- /dev/null +++ b/src/forms/settings/settings.cpp @@ -0,0 +1,158 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#include "settings.h" + +#include +#include +#include + +#include "appconfig.h" +#include "appsettings.h" +#include "helpers/http_status.h" +#include "helpers/netman.h" +#include "helpers/pathseparator.h" +#include "helpers/stopreply.h" +#include "helpers/ui_helpers.h" +#include "hotkeymanager.h" +#include "ui_settings.h" + +using namespace std; + +Settings::Settings(HotkeyManager *hotkeyManager, QWidget *parent) + : ButtonBoxForm(parent), ui(new Ui::Settings) { + ui->setupUi(this); + this->hotkeyManager = hotkeyManager; + + couldNotSaveSettingsMsg = new QErrorMessage(this); + setButtonBox(ui->buttonBox); + + testConnectionButton = new LoadingButton(ui->testHostButton->text(), this, ui->testHostButton); + UiHelpers::replacePlaceholder(ui->testHostButton, testConnectionButton, ui->gridLayout); + + wireUi(); + + // Make the dialog pop up above any other windows but retain title bar and buttons + Qt::WindowFlags flags = this->windowFlags(); + flags |= Qt::CustomizeWindowHint | Qt::WindowStaysOnTopHint | Qt::WindowMinMaxButtonsHint | Qt::WindowCloseButtonHint; + this->setWindowFlags(flags); +} + +Settings::~Settings() { + delete ui; + delete couldNotSaveSettingsMsg; + stopReply(¤tTestReply); + delete testConnectionButton; +} + +void Settings::wireUi() { + connect(ui->buttonBox, &QDialogButtonBox::clicked, this, &Settings::routeButtonPress); + connect(testConnectionButton, &QPushButton::clicked, this, &Settings::onTestConnectionClicked); + connect(ui->eviRepoBrowseButton, &QPushButton::clicked, this, &Settings::onBrowseClicked); +} + +void Settings::showEvent(QShowEvent *evt) { + QDialog::showEvent(evt); + AppConfig &inst = AppConfig::getInstance(); + + // reset the form in case a user left junk in the text boxes and pressed "cancel" + ui->eviRepoTextBox->setText(inst.evidenceRepo); + ui->accessKeyTextBox->setText(inst.accessKey); + ui->secretKeyTextBox->setText(inst.secretKey); + ui->hostPathTextBox->setText(inst.apiURL); + ui->screenshotCmdTextBox->setText(inst.screenshotExec); + ui->screenshotShortcutTextBox->setText(inst.screenshotShortcutCombo); + + ui->captureWindowCmdTextBox->setText(inst.captureWindowExec); + ui->captureWindowShortCutTextBox->setText(inst.captureWindowShortcut); + + // re-enable form + testConnectionButton->setEnabled(true); +} + +void Settings::closeEvent(QCloseEvent *event) { + onSaveClicked(); + QDialog::closeEvent(event); +} + +void Settings::onCancelClicked() { + stopReply(¤tTestReply); + ui->statusIconLabel->setText(""); +} + +void Settings::onSaveClicked() { + stopReply(¤tTestReply); + ui->statusIconLabel->setText(""); + + AppConfig &inst = AppConfig::getInstance(); + + inst.evidenceRepo = ui->eviRepoTextBox->text(); + inst.accessKey = ui->accessKeyTextBox->text(); + inst.secretKey = ui->secretKeyTextBox->text(); + inst.apiURL = ui->hostPathTextBox->text(); + inst.screenshotExec = ui->screenshotCmdTextBox->text(); + inst.screenshotShortcutCombo = ui->screenshotShortcutTextBox->text(); + inst.captureWindowExec = ui->captureWindowCmdTextBox->text(); + inst.captureWindowShortcut = ui->captureWindowShortCutTextBox->text(); + + try { + inst.writeConfig(); + } + catch (std::exception &e) { + couldNotSaveSettingsMsg->showMessage("Unable to save settings. Error: " + QString(e.what())); + } + + hotkeyManager->updateHotkeys(); +} + +void Settings::onBrowseClicked() { + auto browseStart = ui->eviRepoTextBox->text(); + browseStart = QFile(browseStart).exists() ? browseStart : QDir::homePath(); + auto filename = QFileDialog::getExistingDirectory(this, tr("Select a project directory"), + browseStart, QFileDialog::ShowDirsOnly); + if (filename != nullptr) { + ui->eviRepoTextBox->setText(filename); + } +} + +void Settings::onTestConnectionClicked() { + testConnectionButton->startAnimation(); + testConnectionButton->setEnabled(false); + currentTestReply = NetMan::getInstance().testConnection( + ui->hostPathTextBox->text(), ui->accessKeyTextBox->text(), ui->secretKeyTextBox->text()); + connect(currentTestReply, &QNetworkReply::finished, this, &Settings::onTestRequestComplete); +} + +void Settings::onTestRequestComplete() { + bool ok = true; + auto statusCode = + currentTestReply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(&ok); + + if (ok) { + switch (statusCode) { + case HttpStatus::StatusOK: + this->ui->statusIconLabel->setText("Connected"); + break; + case HttpStatus::StatusUnauthorized: + this->ui->statusIconLabel->setText( + "Could not connect: Unauthorized (check api key and secret)"); + break; + case HttpStatus::StatusNotFound: + this->ui->statusIconLabel->setText("Could not connect: Not Found (check URL)"); + break; + default: + QString msg = "Could not connect: Unexpected Error (code: "; + msg.append(statusCode); + msg.append(")"); + this->ui->statusIconLabel->setText(msg); + } + } + else { + this->ui->statusIconLabel->setText( + "Could not connect: Unexpected Error (check network connection and URL)"); + } + + testConnectionButton->stopAnimation(); + testConnectionButton->setEnabled(true); + tidyReply(¤tTestReply); +} diff --git a/src/forms/settings/settings.h b/src/forms/settings/settings.h new file mode 100644 index 0000000..9c72e40 --- /dev/null +++ b/src/forms/settings/settings.h @@ -0,0 +1,51 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#ifndef SETTINGS_H +#define SETTINGS_H + +#include +#include +#include +#include + +#include "components/loading_button/loadingbutton.h" +#include "forms/buttonboxform.h" +#include "hotkeymanager.h" + +namespace Ui { +class Settings; +} + +class Settings : public ButtonBoxForm { + Q_OBJECT + + public: + explicit Settings(HotkeyManager *hotkeyManager, QWidget *parent = nullptr); + ~Settings(); + + private: + void wireUi(); + + public slots: + void onSaveClicked() override; + void onCancelClicked() override; + + void showEvent(QShowEvent *evt) override; + void closeEvent(QCloseEvent *event) override; + + void onTestConnectionClicked(); + void onTestRequestComplete(); + void onBrowseClicked(); + + private: + Ui::Settings *ui; + QErrorMessage *couldNotSaveSettingsMsg; + LoadingButton *testConnectionButton; + + QNetworkReply *currentTestReply = nullptr; + + HotkeyManager *hotkeyManager; // borrowed pointer +}; + +#endif // SETTINGS_H diff --git a/src/forms/settings/settings.ui b/src/forms/settings/settings.ui new file mode 100644 index 0000000..dbf626d --- /dev/null +++ b/src/forms/settings/settings.ui @@ -0,0 +1,196 @@ + + + Settings + + + + 0 + 0 + 759 + 304 + + + + Settings + + + + + + Shortcut + + + + + + + + + + Shortcut + + + + + + + Test Connection + + + + + + + Capture Area Command + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Secret Key + + + + + + + + + + Evidence Repository + + + + + + + Host Path + + + + + + + + + + Browse + + + + + + + Access Key + + + + + + + + + + + + + true + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Save + + + false + + + + + + + Capture Window Command + + + + + + + + + + + + + + + + + + + + eviRepoTextBox + eviRepoBrowseButton + accessKeyTextBox + secretKeyTextBox + hostPathTextBox + screenshotCmdTextBox + screenshotShortcutTextBox + captureWindowCmdTextBox + captureWindowShortCutTextBox + testHostButton + + + + + buttonBox + accepted() + Settings + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + Settings + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/helpers/clipboard/clipboardhelper.cpp b/src/helpers/clipboard/clipboardhelper.cpp new file mode 100644 index 0000000..5f9db6d --- /dev/null +++ b/src/helpers/clipboard/clipboardhelper.cpp @@ -0,0 +1,29 @@ +#include "clipboardhelper.h" + +#include + +QString ClipboardHelper::readPlaintext() { + const QClipboard *clipboard = QApplication::clipboard(); + const QMimeData *mimeData = clipboard->mimeData(); + QString data; + if (mimeData->hasHtml()) { + data = (mimeData->text()); + } + else if (mimeData->hasText()) { + data = mimeData->text(); + } + else { + data = ""; + } + return data; +} + +QPixmap ClipboardHelper::readImage() { + const QClipboard *clipboard = QApplication::clipboard(); + const QMimeData *mimeData = clipboard->mimeData(); + QPixmap data; + if (mimeData->hasImage()) { + data = qvariant_cast(mimeData->imageData()); + } + return data; +} diff --git a/src/helpers/clipboard/clipboardhelper.h b/src/helpers/clipboard/clipboardhelper.h new file mode 100644 index 0000000..b2a57e3 --- /dev/null +++ b/src/helpers/clipboard/clipboardhelper.h @@ -0,0 +1,22 @@ +#ifndef CLIPBOARDHELPER_H +#define CLIPBOARDHELPER_H + +#include +#include +#include +#include +#include + +class ClipboardHelper : public QObject { + Q_OBJECT + public: + explicit ClipboardHelper(QObject *parent = nullptr) = delete; + + public: + static QString readPlaintext(); + static QPixmap readImage(); + + signals: +}; + +#endif // CLIPBOARDHELPER_H diff --git a/src/helpers/file_helpers.h b/src/helpers/file_helpers.h new file mode 100644 index 0000000..4bb5e66 --- /dev/null +++ b/src/helpers/file_helpers.h @@ -0,0 +1,111 @@ +#ifndef FILE_HELPERS_H +#define FILE_HELPERS_H + +#include +#include +#include +#include +#include +#include +#include + +#include "appconfig.h" +#include "appsettings.h" +#include "exceptions/fileerror.h" +#include "helpers/pathseparator.h" + +class FileHelpers { + public: + /// randomText generates an arbitrary number of case-sensitive english letters + static QString randomText(unsigned int numChars) { + std::array asciiOffset = {'A', 'a'}; + QStringList replacement; + + for (unsigned int i = 0; i < numChars; i++) { + int letter = QRandomGenerator::global()->bounded(52); + auto base = asciiOffset.at(letter < 26 ? 0 : 1); + replacement << QString(char(base + (letter % 26))); + } + return replacement.join(""); + } + + /** + * @brief randomFilename replaces a string of 6 consecutive X characters with 6 consecutive random + * english letters. Each letter may either be upper or lower case. Similar to what QTemporaryFile + * does. + * @param templateStr The model string, with + * @return The resulting filename + */ + static QString randomFilename(QString templateStr) { + QString replaceToken = "XXXXXX"; + int templateIndex = templateStr.indexOf(replaceToken); + + QString replacement = randomText(replaceToken.length()); + return templateStr.replace(templateIndex, replaceToken.length(), replacement); + } + + /// converts a c++ std string into QByteArray, ensuring proper encoding + static QByteArray stdStringToByteArray(std::string str) { + return QByteArray(str.c_str(), str.size()); + } + + /** + * @brief qstringToByteArray converts a QString into a QByteArray, ensuring proper encoding. Only + * safe for ascii content. + * @param q The string to convert + * @return the QString as a QByteArray + */ + static QByteArray qstringToByteArray(QString q) { return stdStringToByteArray(q.toStdString()); } + + /// Returns (and creates, if necessary) the path to where evidence should be stored (includes + /// ending path separator) + static QString pathToEvidence() { + AppConfig &conf = AppConfig::getInstance(); + auto op = AppSettings::getInstance().operationSlug(); + auto root = conf.evidenceRepo + PATH_SEPARATOR; + if (op != "") { + root += op + PATH_SEPARATOR; + } + + QDir().mkpath(root); + return root; + } + + /// writeFile write the provided content to the provided path. + /// @throws a FileError if there are issues opening or writing to the file. + static void writeFile(QString path, QString content) { + writeFile(path, qstringToByteArray(content)); + } + + /// writeFile write the provided content to the provided path. + /// @throws a FileError if there are issues opening or writing to the file. + static void writeFile(QString path, QByteArray content) { + QFile file(path); + bool opened = file.open(QIODevice::WriteOnly); + if (opened) { + file.write(content); + file.close(); + } + if (file.error() != QFile::NoError) { + throw FileError::mkError("Unable to write to file", path.toStdString(), file.error()); + } + } + + /// readFile reads all of the data from the given path. + /// @throws a FileError if any issues occur while writing the filethere are issues opening or + /// reading the file. + static QByteArray readFile(QString path) { + QFile file(path); + QByteArray data; + bool opened = file.open(QIODevice::ReadOnly); + if (opened) { + data = file.readAll(); + } + if (file.error() != QFile::NoError) { + throw FileError::mkError("Unable to read from file", path.toStdString(), file.error()); + } + return data; + } +}; + +#endif // FILE_HELPERS_H diff --git a/src/helpers/http_status.h b/src/helpers/http_status.h new file mode 100644 index 0000000..b0a2200 --- /dev/null +++ b/src/helpers/http_status.h @@ -0,0 +1,77 @@ +#ifndef HTTP_STATUS_H +#define HTTP_STATUS_H + +// clang-format off +// copied/modified from https://golang.org/src/net/http/status.go +enum HttpStatus { + StatusContinue = 100, // RFC 7231, 6.2.1 + StatusSwitchingProtocols = 101, // RFC 7231, 6.2.2 + StatusProcessing = 102, // RFC 2518, 10.1 + StatusEarlyHints = 103, // RFC 8297 + + StatusOK = 200, // RFC 7231, 6.3.1 + StatusCreated = 201, // RFC 7231, 6.3.2 + StatusAccepted = 202, // RFC 7231, 6.3.3 + StatusNonAuthoritativeInfo = 203, // RFC 7231, 6.3.4 + StatusNoContent = 204, // RFC 7231, 6.3.5 + StatusResetContent = 205, // RFC 7231, 6.3.6 + StatusPartialContent = 206, // RFC 7233, 4.1 + StatusMultiStatus = 207, // RFC 4918, 11.1 + StatusAlreadyReported = 208, // RFC 5842, 7.1 + StatusIMUsed = 226, // RFC 3229, 10.4.1 + + StatusMultipleChoices = 300, // RFC 7231, 6.4.1 + StatusMovedPermanently = 301, // RFC 7231, 6.4.2 + StatusFound = 302, // RFC 7231, 6.4.3 + StatusSeeOther = 303, // RFC 7231, 6.4.4 + StatusNotModified = 304, // RFC 7232, 4.1 + StatusUseProxy = 305, // RFC 7231, 6.4.5 + _ = 306, // RFC 7231, 6.4.6 (Unused) + StatusTemporaryRedirect = 307, // RFC 7231, 6.4.7 + StatusPermanentRedirect = 308, // RFC 7538, 3 + + StatusBadRequest = 400, // RFC 7231, 6.5.1 + StatusUnauthorized = 401, // RFC 7235, 3.1 + StatusPaymentRequired = 402, // RFC 7231, 6.5.2 + StatusForbidden = 403, // RFC 7231, 6.5.3 + StatusNotFound = 404, // RFC 7231, 6.5.4 + StatusMethodNotAllowed = 405, // RFC 7231, 6.5.5 + StatusNotAcceptable = 406, // RFC 7231, 6.5.6 + StatusProxyAuthRequired = 407, // RFC 7235, 3.2 + StatusRequestTimeout = 408, // RFC 7231, 6.5.7 + StatusConflict = 409, // RFC 7231, 6.5.8 + StatusGone = 410, // RFC 7231, 6.5.9 + StatusLengthRequired = 411, // RFC 7231, 6.5.10 + StatusPreconditionFailed = 412, // RFC 7232, 4.2 + StatusRequestEntityTooLarge = 413, // RFC 7231, 6.5.11 + StatusRequestURITooLong = 414, // RFC 7231, 6.5.12 + StatusUnsupportedMediaType = 415, // RFC 7231, 6.5.13 + StatusRequestedRangeNotSatisfiable = 416, // RFC 7233, 4.4 + StatusExpectationFailed = 417, // RFC 7231, 6.5.14 + StatusTeapot = 418, // RFC 7168, 2.3.3 + StatusMisdirectedRequest = 421, // RFC 7540, 9.1.2 + StatusUnprocessableEntity = 422, // RFC 4918, 11.2 + StatusLocked = 423, // RFC 4918, 11.3 + StatusFailedDependency = 424, // RFC 4918, 11.4 + StatusTooEarly = 425, // RFC 8470, 5.2. + StatusUpgradeRequired = 426, // RFC 7231, 6.5.15 + StatusPreconditionRequired = 428, // RFC 6585, 3 + StatusTooManyRequests = 429, // RFC 6585, 4 + StatusRequestHeaderFieldsTooLarge = 431, // RFC 6585, 5 + StatusUnavailableForLegalReasons = 451, // RFC 7725, 3 + + StatusInternalServerError = 500, // RFC 7231, 6.6.1 + StatusNotImplemented = 501, // RFC 7231, 6.6.2 + StatusBadGateway = 502, // RFC 7231, 6.6.3 + StatusServiceUnavailable = 503, // RFC 7231, 6.6.4 + StatusGatewayTimeout = 504, // RFC 7231, 6.6.5 + StatusHTTPVersionNotSupported = 505, // RFC 7231, 6.6.6 + StatusVariantAlsoNegotiates = 506, // RFC 2295, 8.1 + StatusInsufficientStorage = 507, // RFC 4918, 11.5 + StatusLoopDetected = 508, // RFC 5842, 7.2 + StatusNotExtended = 510, // RFC 2774, 7 + StatusNetworkAuthenticationRequired = 511, // RFC 6585, 6 +}; +// clang-format on + +#endif // HTTP_STATUS_H diff --git a/src/helpers/jsonhelpers.h b/src/helpers/jsonhelpers.h new file mode 100644 index 0000000..e6364db --- /dev/null +++ b/src/helpers/jsonhelpers.h @@ -0,0 +1,51 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#ifndef JSONHELPERS_H +#define JSONHELPERS_H + +#include +#include +#include +#include + +// parseJSONList parses a JSON list into a vector of concrete types from a byte[]. If any error +// occurs during parsing, an empty vector is returned +template +static std::vector parseJSONList(QByteArray data, T (*dataToItem)(QJsonObject)) { + QJsonParseError err; + QJsonDocument doc = QJsonDocument::fromJson(data, &err); + if (err.error != QJsonParseError::NoError) { + return std::vector(); + } + QJsonArray arr = doc.array(); + std::vector list; + + for (QJsonValue val : arr) { + auto item = dataToItem(val.toObject()); + list.push_back(item); + } + + return list; +} + +// parseJSONItem parses a single item (assumed to be a Json Object) from a byte[]. If any error +// occurs during parsing, an empty object is returned. +template +static T parseJSONItem(QByteArray data, T (*dataToItem)(QJsonObject)) { + QJsonParseError err; + QJsonDocument doc = QJsonDocument::fromJson(data, &err); + if (err.error != QJsonParseError::NoError) { + return T(); + } + return dataToItem(doc.object()); +} + +// Possilbe generic version of converting to json +// template +// static QByteArray toJSONObject(T item, QJsonObject(*itemToData)(T)) { +// auto obj = itemToData(item); +// return QJsonDocument::fromVariant(obj).toJson(); +//} + +#endif // JSONHELPERS_H diff --git a/src/helpers/multipartparser.cpp b/src/helpers/multipartparser.cpp new file mode 100644 index 0000000..58f526e --- /dev/null +++ b/src/helpers/multipartparser.cpp @@ -0,0 +1,107 @@ +/********************************************************************************* + * File Name : multipartparser.cpp + * Created By : Ye Yangang + * Creation Date : [2017-02-20 16:50] + * Last Modified : [AUTO_UPDATE_BEFORE_SAVE] + * Description : Generate multipart/form-data POST body + **********************************************************************************/ + +#include "multipartparser.h" + +#include +#include + +#include +#include +#include +#include + +const std::string MultipartParser::boundary_prefix_("----AScreenTrayApp"); +const std::string MultipartParser::rand_chars_( + "0123456789" + "abcdefghijklmnopqrstuvwxyz" + "ABCDEFGHIJKLMNOPQRSTUVWXYZ"); +MultipartParser::MultipartParser() { + int i = 0; + int len = rand_chars_.size(); + boundary_ = boundary_prefix_; + while (i < 16) { + int idx = rand() % len; + boundary_.push_back(rand_chars_[idx]); + ++i; + } +} + +const std::string &MultipartParser::GenBodyContent() { + std::vector> futures; + body_content_.clear(); + for (auto &file : files_) { + std::future content_futures = std::async(std::launch::async, [&file]() { + std::ifstream ifile(file.second, std::ios::binary | std::ios::ate); + std::streamsize size = ifile.tellg(); + ifile.seekg(0, std::ios::beg); + char *buff = new char[size]; + ifile.read(buff, size); + ifile.close(); + std::string ret(buff, size); + delete[] buff; + return ret; + }); + futures.push_back(std::move(content_futures)); + } + + for (auto ¶m : params_) { + body_content_ += "\r\n--"; + body_content_ += boundary_; + body_content_ += "\r\nContent-Disposition: form-data; name=\""; + body_content_ += param.first; + body_content_ += "\"\r\n\r\n"; + body_content_ += param.second; + } + + for (size_t i = 0; i < files_.size(); ++i) { + std::string *filename = new std::string(); + std::string *content_type = new std::string(); + std::string file_content = futures[i].get(); + _get_file_name_type(files_[i].second, filename, content_type); + body_content_ += "\r\n--"; + body_content_ += boundary_; + body_content_ += "\r\nContent-Disposition: form-data; name=\""; + body_content_ += files_[i].first; + body_content_ += "\"; filename=\""; + body_content_ += *filename; + body_content_ += "\"\r\nContent-Type: "; + body_content_ += *content_type; + body_content_ += "\r\n\r\n"; + body_content_ += file_content; + } + body_content_ += "\r\n--"; + body_content_ += boundary_; + body_content_ += "--\r\n"; + return body_content_; +} + +void MultipartParser::_get_file_name_type(const std::string &file_path, std::string *filename, + std::string *content_type) { + if (filename == NULL || content_type == NULL) return; + + size_t last_spliter = file_path.find_last_of("/\\"); + *filename = file_path.substr(last_spliter + 1); + size_t dot_pos = filename->find_last_of("."); + if (dot_pos == std::string::npos) { + *content_type = "application/octet-stream"; + return; + } + std::string ext = filename->substr(dot_pos + 1); + std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower); + if (ext == "jpg" || ext == "jpeg") { + *content_type = "image/jpeg"; + return; + } + if (ext == "txt" || ext == "log") { + *content_type = "text/plain"; + return; + } + *content_type = "application/octet-stream"; + return; +} diff --git a/src/helpers/multipartparser.h b/src/helpers/multipartparser.h new file mode 100644 index 0000000..88a10a3 --- /dev/null +++ b/src/helpers/multipartparser.h @@ -0,0 +1,42 @@ +/********************************************************************************* + * File Name : multipartparser.h + * Created By : Ye Yangang + * Creation Date : [2017-02-20 16:50] + * Last Modified : [AUTO_UPDATE_BEFORE_SAVE] + * Description : Generate multipart/form-data POST body + **********************************************************************************/ + +#ifndef MULTIPARTPARSER_H +#define MULTIPARTPARSER_H + +#include +#include +#include + +class MultipartParser { + public: + MultipartParser(); + inline const std::string &body_content() { return body_content_; } + inline const std::string &boundary() { return boundary_; } + inline void AddParameter(const std::string &name, const std::string &value) { + params_.push_back(std::pair(name, value)); + } + inline void AddFile(const std::string &name, const std::string &value) { + files_.push_back(std::pair(name, value)); + } + const std::string &GenBodyContent(); + + private: + void _get_file_name_type(const std::string &file_path, std::string *filenae, + std::string *content_type); + + private: + static const std::string boundary_prefix_; + static const std::string rand_chars_; + std::string boundary_; + std::string body_content_; + std::vector> params_; + std::vector> files_; +}; + +#endif // MULTIPARTPARSER_H diff --git a/src/helpers/netman.h b/src/helpers/netman.h new file mode 100644 index 0000000..619878c --- /dev/null +++ b/src/helpers/netman.h @@ -0,0 +1,218 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#ifndef NETMAN_H +#define NETMAN_H + +#include +#include +#include +#include +#include +#include +#include + +#include "appconfig.h" +#include "dtos/operation.h" +#include "dtos/tag.h" +#include "helpers/file_helpers.h" +#include "helpers/multipartparser.h" +#include "helpers/stopreply.h" +#include "models/evidence.h" + +static auto NO_BODY = ""; + +class NetMan : public QObject { + Q_OBJECT + + public: + static NetMan &getInstance() { + static NetMan instance; + return instance; + } + NetMan(NetMan const &) = delete; + void operator=(NetMan const &) = delete; + + enum RequestMethod { METHOD_GET = 0, METHOD_POST }; + static QString RequestMethodToString(RequestMethod val) { + static QString names[] = {"GET", "POST"}; + return names[val]; + } + + signals: + + void operationListUpdated(bool success, + std::vector operations = std::vector()); + + private: + QNetworkAccessManager *nam; + + NetMan() { nam = new QNetworkAccessManager; } + ~NetMan() { + delete nam; + stopReply(&allOpsReply); + } + + // mkApiUrl creates a new URL with the appropriate request start + QString mkApiUrl(QString endpoint, QString host = "") { + QString base = (host == "") ? AppConfig::getInstance().apiURL : host; + if (base.size() == 0) { // if a user hasn't set up the application, then base could be empty + return endpoint; + } + if (base.at(base.size() - 1) == '/') { + base.chop(1); + } + return base + endpoint; + } + + QString getRFC1123Date() { + return QDateTime::currentDateTimeUtc().toString("ddd, dd MMM yyyy hh:mm:ss 'GMT'"); + } + + QString generateHash(QString method, QString path, QString date, QByteArray body = NO_BODY, + const QString &secretKey = "") { + auto hashedBody = QCryptographicHash::hash(body, QCryptographicHash::Sha256); + std::string msg = (method + "\n" + path + "\n" + date + "\n").toStdString(); + msg += hashedBody.toStdString(); + + QString secretKeyCopy = QString(secretKey); + if (secretKeyCopy.isEmpty()) { + secretKeyCopy = AppConfig::getInstance().secretKey; + } + + QMessageAuthenticationCode code(QCryptographicHash::Sha256); + QByteArray key = QByteArray::fromBase64(FileHelpers::qstringToByteArray(secretKeyCopy)); + + code.setKey(key); + code.addData(FileHelpers::stdStringToByteArray(msg)); + return code.result().toBase64(); + } + + QNetworkReply *makeJsonRequest(RequestMethod method, QString endpoint, QByteArray body = NO_BODY, + const QString &host = "", const QString &apiKey = "", + const QString &secretKey = "") { + QNetworkRequest req = prepRequest(method, endpoint, body, host, apiKey, secretKey); + + switch (method) { + case METHOD_GET: + return nam->get(req); + case METHOD_POST: + req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + return nam->post(req, body); + default: + std::cerr << "makeRequest passed an unsupported method" << std::endl; + } + return nullptr; + } + + QNetworkReply *makeFormRequest(RequestMethod method, QString endpoint, QString boundry, + QByteArray body = NO_BODY, QString host = "") { + QNetworkRequest req = prepRequest(method, endpoint, body, host); + + switch (method) { + case METHOD_GET: + return nam->get(req); + case METHOD_POST: + req.setHeader(QNetworkRequest::ContentTypeHeader, + "multipart/form-data; boundary=" + boundry); + return nam->post(req, body); + default: + std::cerr << "makeRequest passed an unsupported method" << std::endl; + } + return nullptr; + } + + QNetworkRequest prepRequest(RequestMethod method, QString endpoint, QByteArray body, QString host, + const QString &apiKey = "", const QString &secretKey = "") { + QNetworkRequest req; + auto now = getRFC1123Date(); + QString reqMethod = RequestMethodToString(method); + + QString apiKeyCopy = QString(apiKey); + if (apiKeyCopy.isEmpty()) { + apiKeyCopy = AppConfig::getInstance().accessKey; + } + + auto code = generateHash(reqMethod, endpoint, now, body, secretKey); + auto authValue = apiKeyCopy + ":" + code; + + req.setUrl(mkApiUrl(endpoint, host)); + req.setRawHeader(QByteArray("Date"), FileHelpers::qstringToByteArray(now)); + req.setRawHeader(QByteArray("Authorization"), FileHelpers::qstringToByteArray(authValue)); + + return req; + } + + void onGetOpsComplete() { + bool isValid; + auto data = extractResponse(allOpsReply, isValid); + if (isValid) { + std::vector ops = dto::Operation::parseDataAsList(data); + std::sort(ops.begin(), ops.end(), + [](dto::Operation i, dto::Operation j) { return i.name < j.name; }); + + emit operationListUpdated(true, ops); + } + else { + emit operationListUpdated(false); + } + tidyReply(&allOpsReply); + } + + public: + QNetworkReply *uploadAsset(model::Evidence evidence) { + MultipartParser parser; + parser.AddParameter("notes", evidence.description.toStdString()); + parser.AddParameter("contentType", evidence.contentType.toStdString()); + + // TODO: convert this time below into a proper unix timestamp (mSecSinceEpoch and secsSinceEpoch + // produce invalid times) + // parser.AddParameter("occurred_at", std::to_string(evidence.recordedDate); + + QStringList list; + for (auto tag : evidence.tags) { + list << QString::number(tag.serverTagId); + } + parser.AddParameter("tagIds", ("[" + list.join(",") + "]").toStdString()); + + parser.AddFile("file", evidence.path.toStdString()); + + auto body = FileHelpers::stdStringToByteArray(parser.GenBodyContent()); + return makeFormRequest(METHOD_POST, "/api/operations/" + evidence.operationSlug + "/evidence", + parser.boundary().c_str(), body); + } + + QNetworkReply *testConnection(QString host, QString apiKey, QString secretKey) { + return makeJsonRequest(METHOD_GET, "/api/operations", NO_BODY, host, apiKey, secretKey); + } + + QNetworkReply *getAllOperations() { return makeJsonRequest(METHOD_GET, "/api/operations"); } + + void refreshOperationsList() { + allOpsReply = getAllOperations(); + connect(allOpsReply, &QNetworkReply::finished, this, &NetMan::onGetOpsComplete); + } + + QNetworkReply *getOperationTags(QString operationSlug) { + auto url = "/api/operations/" + operationSlug + "/tags"; + return makeJsonRequest(METHOD_GET, url); + } + + QNetworkReply *createTag(dto::Tag tag, QString operationSlug) { + auto url = "/api/operations/" + operationSlug + "/tags"; + return makeJsonRequest(METHOD_POST, url, dto::Tag::toJson(tag)); + } + + static QByteArray extractResponse(QNetworkReply *reply, bool &valid) { + auto status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute); + auto reqErr = reply->error(); + valid = (QNetworkReply::NoError == reqErr && status.isValid()); + valid = valid && (status == 200 || status == 201); + return reply->readAll(); + } + + private: + QNetworkReply *allOpsReply = nullptr; +}; + +#endif // NETMAN_H diff --git a/src/helpers/pathseparator.h b/src/helpers/pathseparator.h new file mode 100644 index 0000000..ed230b6 --- /dev/null +++ b/src/helpers/pathseparator.h @@ -0,0 +1,20 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#ifndef PATHSEPARATOR_H +#define PATHSEPARATOR_H + +// FROM: +// https://stackoverflow.com/questions/12971499/how-to-get-the-file-separator-symbol-in-standard-c-c-or +// Note: this is only going to work for Windows and (linux/mac). Boost may have the ability to +// resolve this Note2: We probably don't need this. Qt treats "/" as the universal path separator. +// We would only need this for reflecting the path to the user. See: +// https://doc.qt.io/qt-5/qdir.html + +#if defined(WIN32) || defined(_WIN32) +#define PATH_SEPARATOR '\\' +#else +#define PATH_SEPARATOR '/' +#endif + +#endif // PATHSEPARATOR_H diff --git a/src/helpers/screenshot.cpp b/src/helpers/screenshot.cpp new file mode 100644 index 0000000..4dece90 --- /dev/null +++ b/src/helpers/screenshot.cpp @@ -0,0 +1,60 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#include "screenshot.h" + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "appconfig.h" +#include "helpers/file_helpers.h" +#include "helpers/pathseparator.h" + +Screenshot::Screenshot(QObject *parent) : QObject(parent) {} + +QString Screenshot::formatScreenshotCmd(QString cmdProto, const QString &filename) { + auto lowerCmd = cmdProto.toLower(); + QString key = "%file"; + auto idx = lowerCmd.indexOf(key); + if (idx == -1) { + return cmdProto; + } + QString fixedFilename = "'" + filename + "'"; + return cmdProto.replace(idx, key.length(), fixedFilename); +} + +void Screenshot::captureArea() { basicScreenshot(AppConfig::getInstance().screenshotExec); } + +void Screenshot::captureWindow() { basicScreenshot(AppConfig::getInstance().captureWindowExec); } + +void Screenshot::basicScreenshot(QString cmdProto) { + auto root = FileHelpers::pathToEvidence(); + auto hasPath = QDir().mkpath(root); + + if (hasPath) { + auto tempPath = FileHelpers::randomFilename(QDir::tempPath() + PATH_SEPARATOR + + "ashirt_screenshot_XXXXXX.png"); + + QString cmd = formatScreenshotCmd(std::move(cmdProto), tempPath); + auto lastSlash = tempPath.lastIndexOf(PATH_SEPARATOR) + 1; + QString tempName = tempPath.right(tempPath.length() - lastSlash); + + system(cmd.toStdString().c_str()); + + // check if file exists before doing this + auto finalName = root + tempName; + QFile src(tempPath); + if (src.exists()) { + auto moved = src.rename(QString(finalName)); + auto trueName = moved ? finalName : tempName; + emit onScreenshotCaptured(trueName); + } + } +} diff --git a/src/helpers/screenshot.h b/src/helpers/screenshot.h new file mode 100644 index 0000000..cb2b15d --- /dev/null +++ b/src/helpers/screenshot.h @@ -0,0 +1,25 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#ifndef SCREENSHOT_H +#define SCREENSHOT_H + +#include +#include + +class Screenshot : public QObject { + Q_OBJECT + public: + Screenshot(QObject* parent = 0); + void captureArea(); + void captureWindow(); + + signals: + void onScreenshotCaptured(QString filepath); + + private: + QString formatScreenshotCmd(QString cmdProto, const QString& filename); + void basicScreenshot(QString cmdProto); +}; + +#endif // SCREENSHOT_H diff --git a/src/helpers/stopreply.cpp b/src/helpers/stopreply.cpp new file mode 100644 index 0000000..27647b5 --- /dev/null +++ b/src/helpers/stopreply.cpp @@ -0,0 +1,32 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#include "stopreply.h" + +#include + +// stopReply aborts a request, then cleans up the reply (via deleteLater) +// also sets the reply pointer to nullptr. This is for use cases where the +// reply is to be ignored. +void stopReply(QNetworkReply **reply) { + if (*reply == nullptr) { + return; + } + + (*reply)->abort(); + (*reply)->deleteLater(); + *reply = nullptr; +} + +// tidyReply cleans up a "completed" reply, closing the connection and marking +// for deletion. Additionally, this sets the reply pointer to nullptr. This is +// for use cases where a reply has extracted all necessary information and clean-up is necessary. +void tidyReply(QNetworkReply **reply) { + if (*reply == nullptr) { + return; + } + + (*reply)->close(); + (*reply)->deleteLater(); + *reply = nullptr; +} diff --git a/src/helpers/stopreply.h b/src/helpers/stopreply.h new file mode 100644 index 0000000..bc6cb10 --- /dev/null +++ b/src/helpers/stopreply.h @@ -0,0 +1,12 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#ifndef STOPREPLY_H +#define STOPREPLY_H + +#include + +void stopReply(QNetworkReply **reply); +void tidyReply(QNetworkReply **reply); + +#endif // STOPREPLY_H diff --git a/src/helpers/ui_helpers.h b/src/helpers/ui_helpers.h new file mode 100644 index 0000000..0993877 --- /dev/null +++ b/src/helpers/ui_helpers.h @@ -0,0 +1,66 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#ifndef REPLACE_PLACEHOLDER_H +#define REPLACE_PLACEHOLDER_H + +#include +#include +#include +#include +#include + +class UiHelpers { + public: + /** + * @brief overlapPlaceholder Adds a component in the same position as the "placeholder" widget + * @param placeholder The QWidget to overlap + * @param replacement The QWidget that will overlap the placeholder + * @param layout Where _both_ QWidgets should live + */ + static void overlapPlaceholder(QWidget* placeholder, QWidget* replacement, QGridLayout* layout) { + int row, col, rSpan, cSpan; + auto widgetIndex = layout->indexOf(placeholder); + if (widgetIndex == -1) { + throw std::runtime_error("Placeholder is not contained in layout"); + } + layout->getItemPosition(widgetIndex, &row, &col, &rSpan, &cSpan); + layout->addWidget(replacement, row, col, rSpan, cSpan); + } + + /** + * @brief replacePlaceholder Replaces the _placeholder_ component with the _replacement_ + * component. The original/placeholder component is hidden and removed from the layout + * @param placeholder The QWidget to remove + * @param replacement The QWidget to add in the placeholder's position + * @param layout Where the placeholder lives, and where the replacement will live + */ + static void replacePlaceholder(QWidget* placeholder, QWidget* replacement, QGridLayout* layout) { + overlapPlaceholder(placeholder, replacement, layout); + placeholder->setVisible(false); + layout->removeWidget(placeholder); + } + + /** + * @brief setComboBoxValue Sets a combobox's value based on the supplied _value_. Sets the value + * to the proper index if found, otherwise sets the index to 0 if not found. Note: this does a + * linear search for values, so may not be appropriate for all boxen. + * @param box The source combobox + * @param value The value to search for in the combobox + */ + static void setComboBoxValue(QComboBox* box, const QString& value) { + bool found = false; + for (int i = 0; i < box->count(); i++) { + if (box->itemData(i) == value) { + box->setCurrentIndex(i); + found = true; + break; + } + } + if (!found) { + box->setCurrentIndex(0); + } + } +}; + +#endif // REPLACE_PLACEHOLDER_H diff --git a/src/hotkeymanager.cpp b/src/hotkeymanager.cpp new file mode 100644 index 0000000..dbb32e0 --- /dev/null +++ b/src/hotkeymanager.cpp @@ -0,0 +1,51 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#include "hotkeymanager.h" + +#include +#include + +#include "appconfig.h" +#include "appsettings.h" +#include "helpers/screenshot.h" + +HotkeyManager::HotkeyManager(Screenshot* ss) { + hotkeyManager = new UGlobalHotkeys(); + screenshotTool = ss; + connect(hotkeyManager, &UGlobalHotkeys::activated, this, &HotkeyManager::hotkeyTriggered); +} + +HotkeyManager::~HotkeyManager() { delete hotkeyManager; } + +void HotkeyManager::registerKey(const QString& binding, GlobalHotkeyEvent evt) { + hotkeyManager->registerHotkey(binding, size_t(evt)); +} + +void HotkeyManager::unregisterKey(GlobalHotkeyEvent evt) { + hotkeyManager->unregisterHotkey(size_t(evt)); +} + +void HotkeyManager::hotkeyTriggered(size_t hotkeyIndex) { + if (hotkeyIndex == ACTION_CAPTURE_AREA) { + screenshotTool->captureArea(); + } + else if (hotkeyIndex == ACTION_CAPTURE_WINDOW) { + screenshotTool->captureWindow(); + } +} + +void HotkeyManager::updateHotkeys() { + hotkeyManager->unregisterAllHotkeys(); + if (!AppSettings::getInstance().isOperationPaused()) { + auto shortcut = AppConfig::getInstance().screenshotShortcutCombo; + if (shortcut != "") { + registerKey(shortcut, ACTION_CAPTURE_AREA); + } + + shortcut = AppConfig::getInstance().captureWindowShortcut; + if (shortcut != "") { + registerKey(shortcut, ACTION_CAPTURE_WINDOW); + } + } +} diff --git a/src/hotkeymanager.h b/src/hotkeymanager.h new file mode 100644 index 0000000..2957054 --- /dev/null +++ b/src/hotkeymanager.h @@ -0,0 +1,41 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#ifndef HOTKEYMANAGER_H +#define HOTKEYMANAGER_H + +#include + +#include "helpers/screenshot.h" +#include "tools/UGlobalHotkey/uglobalhotkeys.h" + +// HotkeyManager is a singleton for managing global hotkeys. +class HotkeyManager : public QObject { + Q_OBJECT + + public: + HotkeyManager(Screenshot* ss); + ~HotkeyManager(); + + enum GlobalHotkeyEvent { + // Reserving 1 (UGlobalHotkey default) + ACTION_CAPTURE_AREA = 2, + ACTION_CAPTURE_WINDOW = 3 + }; + + private: + Screenshot* screenshotTool; + UGlobalHotkeys* hotkeyManager; + + public: + void registerKey(const QString& binding, GlobalHotkeyEvent evt); + void unregisterKey(GlobalHotkeyEvent evt); + + public slots: + void updateHotkeys(); + + private slots: + void hotkeyTriggered(size_t hotkeyIndex); +}; + +#endif // HOTKEYMANAGER_H diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..47dcf0d --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,106 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#include +#include +#include +#include + +void handleCLI(std::vector args); + +#ifndef QT_NO_SYSTEMTRAYICON +#include +#include + +#include "appconfig.h" +#include "appsettings.h" +#include "db/databaseconnection.h" +#include "exceptions/databaseerr.h" +#include "exceptions/fileerror.h" +#include "traymanager.h" + +int main(int argc, char* argv[]) { + Q_INIT_RESOURCE(res_icons); + Q_INIT_RESOURCE(res_migrations); + + QCoreApplication::setAttribute(Qt::AA_UseHighDpiPixmaps); + QCoreApplication::setOrganizationName("Verizon Media Group"); + QCoreApplication::setOrganizationDomain("verizon.com"); + QCoreApplication::setApplicationName("AShirt Screenshot"); + + DatabaseConnection* conn; + try { + conn = new DatabaseConnection(); + conn->connect(); + } + catch (FileError& err) { + std::cout << err.what() << std::endl; + return -1; + } + catch (DBDriverUnavailableError& err) { + std::cout << err.what() << std::endl; + return -1; + } + catch (QSqlError& e) { + std::cout << e.text().toStdString() << std::endl; + return -1; + } + catch (std::exception& e) { + std::cout << "Unexpected error: " << e.what() << std::endl; + return -1; + } + + auto configError = AppConfig::getInstance().errorText.toStdString(); + if (!configError.empty()) { // quick check & preload config data + std::cout << "Unable to load config file: " << configError << std::endl; + return -1; + } + + int rtn; + try { + QApplication app(argc, argv); + + if (!QSystemTrayIcon::isSystemTrayAvailable()) { + handleCLI(std::vector(argv, argv + argc)); + } + QApplication::setQuitOnLastWindowClosed(false); + + auto window = new TrayManager(conn); + rtn = app.exec(); + AppSettings::getInstance().sync(); + delete window; + } + catch (std::exception const& ex) { + std::cout << "Exception while running: " << ex.what() << std::endl; + } + catch (...) { + std::cout << "Unhandled exception while running" << std::endl; + } + conn->close(); + delete conn; + + return rtn; +} + +#else + +#include +#include + +int main(int argc, char *argv[]) { handleCLI(std::vector(argv, argv + argc)); } + +#endif + +void handleCLI(std::vector args) { + size_t trueCount = args.size() - 1; + std::cout << "You provided " << trueCount << " arguments.\n"; + if (trueCount == 0) { + std::cout << "Next time try suppling some arguments." << std::endl; + return; + } + + std::cout << "All arguments:" << std::endl; + for (size_t i = 1; i < args.size(); i++) { + std::cout << "\t" << args.at(i) << std::endl; + } +} diff --git a/src/models/codeblock.cpp b/src/models/codeblock.cpp new file mode 100644 index 0000000..580384a --- /dev/null +++ b/src/models/codeblock.cpp @@ -0,0 +1,60 @@ +#include "codeblock.h" + +#include +#include +#include +#include +#include "helpers/file_helpers.h" +#include "helpers/jsonhelpers.h" + +static Codeblock fromJson(QJsonObject obj) { + Codeblock rtn; + rtn.content = obj["content"].toVariant().toString(); + rtn.subtype = obj["contentSubtype"].toString(); + QJsonObject meta = obj["metadata"].toObject(); + if (!meta.empty()) { + rtn.source = meta["source"].toString(); + } + + return rtn; +} + +Codeblock::Codeblock() = default; + +Codeblock::Codeblock(QString content) { + this->filename = + FileHelpers::randomFilename(FileHelpers::pathToEvidence() + "ashirt_codeblock_XXXXXX.json"); + this->content = std::move(content); + this->subtype = ""; + this->source = ""; +} + +void Codeblock::saveCodeblock(Codeblock codeblock) { + FileHelpers::writeFile(codeblock.filename, codeblock.encode()); +} + +Codeblock Codeblock::readCodeblock(const QString& filepath) { + QByteArray data = FileHelpers::readFile(filepath); + + Codeblock rtn = parseJSONItem(data, fromJson); + rtn.filename = filepath; + return rtn; +} + +QByteArray Codeblock::encode() { + QJsonObject root; + + root.insert("contentSubtype", subtype); + root.insert("content", content); + + QJsonObject metadata; + + if (source != "") { + metadata.insert("source", source); + } + + if (!metadata.empty()) { + root.insert("metadata", metadata); + } + return QJsonDocument(root).toJson(); +} diff --git a/src/models/codeblock.h b/src/models/codeblock.h new file mode 100644 index 0000000..55649a8 --- /dev/null +++ b/src/models/codeblock.h @@ -0,0 +1,62 @@ +#ifndef CODEBLOCK_H +#define CODEBLOCK_H + +#include + +/** + * @brief The Codeblock class represents ASHIRT "codeblock"-style evidence. Codeblocks are + * represented by a handful of values: the actual content, where the content was retrieved, and what + * language the codeblock represents. + * + * As this file is meant for storage, it also includes the ability to manage its own on-disk file. + */ +class Codeblock { + public: + /// default constructor, no data is provided + Codeblock(); + /// creates a new codeblock with the given content and a new random filename + /// (all other data must be provided) + Codeblock(QString content); + + /** + * @brief readCodeblock parses a local codeblock file and returns back the data as a codeblock + * @param filepath The path to the codeblock file + * @throws a FileError if any issues occur while reading the file + * @return a parsed Codeblock object, ready for use. + */ + static Codeblock readCodeblock(const QString& filepath); + + public: + /// content stores the actual codeblock data (i.e. the source code) + QString content; + /// subtype stores what language the codeblock was written in, or an empty string if plaintext + QString subtype; + /// source store where the codeblock was found, typically represented as a url + QString source; + + private: + /// filename is the path to where this file was read from/will be written to + QString filename; + + public: + /// filePath is a small helper to access the filename + inline QString filePath() { return filename; } + + /** + * @brief encode converts the Codeblock into a json-encoded QNyteArray (it's normal + * representation) + * @return the encoded Codeblock + */ + QByteArray encode(); + + public: + /** + * @brief saveCodeblock encodes the provided codeblock, then writes that codeblock to it's + * filePath + * @throws a FileError if any issues occur while writing the file + * @param codeblock The codeblock to save + */ + static void saveCodeblock(Codeblock codeblock); +}; + +#endif // CODEBLOCK_H diff --git a/src/models/evidence.h b/src/models/evidence.h new file mode 100644 index 0000000..cb05ae3 --- /dev/null +++ b/src/models/evidence.h @@ -0,0 +1,27 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#ifndef MODEL_EVIDENCE_H +#define MODEL_EVIDENCE_H + +#include +#include + +#include "tag.h" + +namespace model { +class Evidence { + public: + qint64 id; + QString path; + QString operationSlug; + QString description; + QString errorText; + QString contentType; + QDateTime recordedDate; + QDateTime uploadDate; + std::vector tags; +}; +} // namespace model + +#endif // MODEL_EVIDENCE_H diff --git a/src/models/tag.h b/src/models/tag.h new file mode 100644 index 0000000..ee9cb42 --- /dev/null +++ b/src/models/tag.h @@ -0,0 +1,24 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#ifndef MODEL_TAG_H +#define MODEL_TAG_H + +#include + +namespace model { +class Tag { + public: + Tag(qint64 id, qint64 tagId, QString name) : Tag(tagId, name) { this->id = id; } + Tag(qint64 tagId, QString name) { + this->serverTagId = tagId; + this->tagName = name; + } + + qint64 id; + qint64 serverTagId; + QString tagName; +}; +} // namespace model + +#endif // MODEL_TAG_H diff --git a/src/traymanager.cpp b/src/traymanager.cpp new file mode 100644 index 0000000..6f8b20c --- /dev/null +++ b/src/traymanager.cpp @@ -0,0 +1,278 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#include "traymanager.h" + +#ifndef QT_NO_SYSTEMTRAYICON + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "appconfig.h" +#include "appsettings.h" +#include "db/databaseconnection.h" +#include "forms/getinfo/getinfo.h" +#include "helpers/clipboard/clipboardhelper.h" +#include "helpers/netman.h" +#include "helpers/screenshot.h" +#include "hotkeymanager.h" +#include "models/codeblock.h" +#include "tools/UGlobalHotkey/uglobalhotkeys.h" + +// Tray icons are handled differently between different OS and desktop +// environments. MacOS uses a monochrome mask to render a light or dark icon +// depending on which appearance is used. Gnome does not seam to be aware of a +// light/dark theme within it's tray to be able to automatically take advantage +// of this. Desktop environments and themes in Linux don't appear to be +// consistent so we will default to a light icon given that the default bars +// appear to be dark until we know more. Who knows about windows? +#ifdef Q_OS_MACOS +#define ICON ":/icons/shirt-dark.svg" +#else +#define ICON ":/icons/shirt-light.svg" +#endif + +TrayManager::TrayManager(DatabaseConnection* db) { + this->db = db; + + screenshotTool = new Screenshot(); + hotkeyManager = new HotkeyManager(screenshotTool); + hotkeyManager->updateHotkeys(); + + settingsWindow = new Settings(hotkeyManager, this); + evidenceManagerWindow = new EvidenceManager(db, this); + creditsWindow = new Credits(this); + + // delayed so that windows can listen for get all ops signal + NetMan::getInstance().refreshOperationsList(); + + wireUi(); + + createActions(); + createTrayMenu(); + QIcon icon = QIcon(ICON); + // TODO: figure out if any other environments support masking +#ifdef Q_OS_MACOS + icon.setIsMask(true); +#endif + trayIcon->setIcon(icon); + + setActiveOperationLabel(); + trayIcon->show(); +} + +TrayManager::~TrayManager() { + setVisible(false); + + delete quitAction; + delete showSettingsAction; + delete currentOperationMenuAction; + delete captureScreenAreaAction; + delete captureWindowAction; + delete showEvidenceManagerAction; + delete showCreditsAction; + delete addCodeblockAction; + cleanChooseOpSubmenu(); // must be done before deleting chooseOpSubmenu/action + + delete chooseOpStatusAction; + delete pauseOperationAction; + delete refreshOperationListAction; + delete chooseOpSubmenu; + + delete trayIconMenu; + delete trayIcon; + + delete screenshotTool; + delete hotkeyManager; + delete settingsWindow; + delete evidenceManagerWindow; + delete creditsWindow; +} + +void TrayManager::cleanChooseOpSubmenu() { + for (QAction* act : allOperationActions) { + chooseOpSubmenu->removeAction(act); + delete act; + } + allOperationActions.clear(); +} + +void TrayManager::wireUi() { + connect(screenshotTool, &Screenshot::onScreenshotCaptured, this, + &TrayManager::onScreenshotCaptured); + + connect(&NetMan::getInstance(), &NetMan::operationListUpdated, this, + &TrayManager::onOperationListUpdated); + connect(&AppSettings::getInstance(), &AppSettings::onOperationStateChanged, this, + &TrayManager::setActiveOperationLabel); + connect(&AppSettings::getInstance(), &AppSettings::onOperationUpdated, this, + &TrayManager::setActiveOperationLabel); +} + +void TrayManager::closeEvent(QCloseEvent* event) { +#ifdef Q_OS_MACOS + if (!event->spontaneous() || !isVisible()) { + return; + } +#endif + if (trayIcon->isVisible()) { + hide(); + event->ignore(); + } +} + +void TrayManager::createActions() { + quitAction = new QAction(tr("Quit"), this); + connect(quitAction, &QAction::triggered, qApp, &QCoreApplication::quit); + + showSettingsAction = new QAction(tr("Settings"), this); + connect(showSettingsAction, &QAction::triggered, settingsWindow, &QWidget::show); + + currentOperationMenuAction = new QAction(this); + currentOperationMenuAction->setEnabled(false); + + captureScreenAreaAction = new QAction(tr("Capture Screen Area"), this); + connect(captureScreenAreaAction, &QAction::triggered, screenshotTool, &Screenshot::captureArea); + + captureWindowAction = new QAction(tr("Capture Window"), this); + connect(captureWindowAction, &QAction::triggered, screenshotTool, &Screenshot::captureWindow); + + showEvidenceManagerAction = new QAction(tr("View Accumulated Evidence"), this); + connect(showEvidenceManagerAction, &QAction::triggered, evidenceManagerWindow, &QWidget::show); + + showCreditsAction = new QAction(tr("About"), this); + connect(showCreditsAction, &QAction::triggered, creditsWindow, &QWidget::show); + + addCodeblockAction = new QAction(tr("Add Codeblock from Clipboard"), this); + connect(addCodeblockAction, &QAction::triggered, [this]() { + QString clipboardContent = ClipboardHelper::readPlaintext(); + if (clipboardContent != "") { + Codeblock evidence(clipboardContent); + Codeblock::saveCodeblock(evidence); + auto evidenceID = db->createEvidence(evidence.filePath(), + AppSettings::getInstance().operationSlug(), "codeblock"); + auto getInfoWindow = new GetInfo(db, evidenceID, this); + getInfoWindow->show(); + } + }); + + chooseOpSubmenu = new QMenu(tr("Select Operation")); + chooseOpStatusAction = new QAction("Loading operations...", chooseOpSubmenu); + chooseOpStatusAction->setEnabled(false); + + refreshOperationListAction = new QAction(tr("Refresh Operations"), chooseOpSubmenu); + connect(refreshOperationListAction, &QAction::triggered, [this] { + chooseOpStatusAction->setText("Loading operations..."); + NetMan::getInstance().refreshOperationsList(); + }); + + pauseOperationAction = new QAction(this); + connect(pauseOperationAction, &QAction::triggered, [this] { + AppSettings::getInstance().toggleOperationPaused(); + hotkeyManager->updateHotkeys(); + }); + + chooseOpSubmenu->addAction(chooseOpStatusAction); + chooseOpSubmenu->addAction(refreshOperationListAction); + chooseOpSubmenu->addSeparator(); + chooseOpSubmenu->addAction(pauseOperationAction); + chooseOpSubmenu->addSeparator(); +} + +void TrayManager::createTrayMenu() { + trayIconMenu = new QMenu(this); + + trayIconMenu->addAction(this->addCodeblockAction); + trayIconMenu->addAction(this->captureScreenAreaAction); + trayIconMenu->addAction(this->captureWindowAction); + trayIconMenu->addAction(this->showEvidenceManagerAction); + trayIconMenu->addAction(this->showSettingsAction); + trayIconMenu->addSeparator(); + trayIconMenu->addAction(this->currentOperationMenuAction); + trayIconMenu->addMenu(chooseOpSubmenu); + trayIconMenu->addSeparator(); + trayIconMenu->addAction(this->showCreditsAction); + trayIconMenu->addAction(this->quitAction); + + trayIcon = new QSystemTrayIcon(this); + trayIcon->setContextMenu(trayIconMenu); +} + +void TrayManager::onScreenshotCaptured(const QString& path) { + std::cout << "Captured screenshot to file: " << path.toStdString() << std::endl; + try { + auto evidenceID = + db->createEvidence(path, AppSettings::getInstance().operationSlug(), "image"); + auto getInfoWindow = new GetInfo(db, evidenceID, this); + getInfoWindow->show(); + } + catch (QSqlError& e) { + std::cout << "could not write to the database: " << e.text().toStdString() << std::endl; + } +} + +void TrayManager::setActiveOperationLabel() { + auto opName = AppSettings::getInstance().operationName(); + auto isPaused = AppSettings::getInstance().isOperationPaused(); + + pauseOperationAction->setText(tr(isPaused ? "Enable Operation" : "Pause Operation")); + + QString opLabel = tr(isPaused ? "Paused" : "Active"); + opLabel += tr(" Operation: "); + opLabel += (opName == "") ? QString(tr("")) : QString(opName); + + currentOperationMenuAction->setText(opLabel); +} + +void TrayManager::onOperationListUpdated(bool success, + const std::vector& operations) { + auto currentOp = AppSettings::getInstance().operationSlug(); + + if (success) { + chooseOpStatusAction->setText(tr("Operations loaded")); + cleanChooseOpSubmenu(); + + for (const auto& op : operations) { + auto newAction = new QAction(op.name, chooseOpSubmenu); + + if (currentOp == op.slug) { + newAction->setCheckable(true); + newAction->setChecked(true); + selectedAction = newAction; + } + + connect(newAction, &QAction::triggered, [this, newAction, op] { + AppSettings::getInstance().setOperationDetails(op.slug, op.name); + if (selectedAction != nullptr) { + selectedAction->setChecked(false); + selectedAction->setCheckable(false); + } + newAction->setCheckable(true); + newAction->setChecked(true); + selectedAction = newAction; + }); + allOperationActions.push_back(newAction); + chooseOpSubmenu->addAction(newAction); + } + } + else { + chooseOpStatusAction->setText(tr("Unable to load operations")); + } +} + +#endif diff --git a/src/traymanager.h b/src/traymanager.h new file mode 100644 index 0000000..7ebf9b1 --- /dev/null +++ b/src/traymanager.h @@ -0,0 +1,89 @@ +// Copyright 2020, Verizon Media +// Licensed under the terms of GPLv3. See LICENSE file in project root for terms. + +#ifndef WINDOW_H +#define WINDOW_H + +#include + +#include "db/databaseconnection.h" +#include "dtos/operation.h" +#include "forms/credits/credits.h" +#include "forms/evidence/evidencemanager.h" +#include "forms/settings/settings.h" +#include "helpers/screenshot.h" +#include "hotkeymanager.h" +#include "tools/UGlobalHotkey/uglobalhotkeys.h" + +#ifndef QT_NO_SYSTEMTRAYICON + +#include + +QT_BEGIN_NAMESPACE +class QAction; +class QCheckBox; +class QComboBox; +class QGroupBox; +class QLabel; +class QLineEdit; +class QMenu; +class QPushButton; +class QSpinBox; +class QTextEdit; +QT_END_NAMESPACE + +class TrayManager : public QDialog { + Q_OBJECT + + public: + TrayManager(DatabaseConnection *); + ~TrayManager(); + + protected: + void closeEvent(QCloseEvent *event) override; + + public slots: + void onScreenshotCaptured(const QString &filepath); + void setActiveOperationLabel(); + + private slots: + void onOperationListUpdated(bool success, const std::vector &operations); + + private: + void createActions(); + void createTrayMenu(); + void wireUi(); + + QAction *quitAction; + QAction *showSettingsAction; + QAction *currentOperationMenuAction; + QAction *captureScreenAreaAction; + QAction *captureWindowAction; + QAction *showEvidenceManagerAction; + QAction *showCreditsAction; + QAction *addCodeblockAction; + + void cleanChooseOpSubmenu(); + QMenu *chooseOpSubmenu; + QAction *chooseOpStatusAction; + QAction *pauseOperationAction; + QAction *refreshOperationListAction; + QAction *selectedAction = nullptr; // note: do not delete; for reference only + std::vector allOperationActions; + + QSystemTrayIcon *trayIcon; + QMenu *trayIconMenu; + + Settings *settingsWindow; + EvidenceManager *evidenceManagerWindow; + Credits *creditsWindow; + + Screenshot *screenshotTool; + HotkeyManager *hotkeyManager; + + DatabaseConnection *db; +}; + +#endif // QT_NO_SYSTEMTRAYICON + +#endif diff --git a/tools/UGlobalHotkey b/tools/UGlobalHotkey new file mode 160000 index 0000000..4b1049f --- /dev/null +++ b/tools/UGlobalHotkey @@ -0,0 +1 @@ +Subproject commit 4b1049f8e5248ac816b6b90dfa0e70c54b9b8306