Initial open source release

This commit is contained in:
Ronni Skansing
2025-08-21 16:14:09 +02:00
commit 11cf01f08e
488 changed files with 97180 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
name: Release Build and Upload
on:
push:
tags:
- "v*.*.*"
jobs:
build-and-release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Set up Docker
uses: docker/setup-buildx-action@v3
- name: Extract version from tag
id: get_version
run: |
echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
echo "HASH=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Build frontend files
working-directory: frontend
run: |
sudo docker run --rm \
-v "$(pwd)":/app \
-w /app \
node:alpine \
sh -c "npm ci && npm run build-production"
- name: Move frontend build to backend
run: |
rm -rf backend/frontend/build
mkdir -p backend/frontend/build
cp -r frontend/build/* backend/frontend/build/
- name: Build single binary with all features
run: |
sudo docker run --rm \
-v "$(pwd)":/app \
-w /app/backend \
golang:alpine \
go build -trimpath \
-ldflags="-X github.com/phishingclub/phishingclub/version.hash=ph${{ steps.get_version.outputs.HASH }} -X github.com/phishingclub/phishingclub/version.version=${{ steps.get_version.outputs.VERSION }}" \
-tags production -o ../build/phishingclub main.go
- name: Fix build directory permissions
run: |
sudo chown -R $USER:$USER build/
chmod 755 build/
ls -la build/
- name: Sign binary with Ed25519
run: |
# Create directory for keys
mkdir -p /tmp/keys
chmod 700 /tmp/keys
# Save both private keys from GitHub secrets
echo "${{ secrets.SIGNKEY_1 }}" > /tmp/keys/private1.pem
echo "${{ secrets.SIGNKEY_2 }}" > /tmp/keys/private2.pem
chmod 600 /tmp/keys/private1.pem
chmod 600 /tmp/keys/private2.pem
# Sign binary with primary key (Key 1)
openssl pkeyutl -sign -inkey /tmp/keys/private1.pem \
-rawin -in build/phishingclub \
-out build/phishingclub.sig
# Clean up keys
rm -rf /tmp/keys
- name: Create compressed package with signature
run: |
mkdir -p packages
# Package binary with signature
tar -czf packages/phishingclub_${{ steps.get_version.outputs.VERSION }}.tar.gz \
-C build \
phishingclub \
phishingclub.sig
- name: Create GitHub Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create ${{ steps.get_version.outputs.TAG }} \
./packages/phishingclub_${{ steps.get_version.outputs.VERSION }}.tar.gz \
--title "PhishingClub ${{ steps.get_version.outputs.TAG }}" \
--notes "PhishingClub release ${{ steps.get_version.outputs.TAG }}"
- name: Notify about release
run: |
curl -d "phishingclub version ${{ steps.get_version.outputs.VERSION }} has been released on GitHub" https://ntfy.sh/phishing_club_released
+125
View File
@@ -0,0 +1,125 @@
name: Test Build
on:
#pull_request:
# branches: [ main, develop ]
push:
branches: [test-build]
jobs:
test-build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Set up Docker
uses: docker/setup-buildx-action@v3
- name: Extract version info
id: get_version
run: |
echo "VERSION=test-$(date +%Y%m%d-%H%M%S)" >> $GITHUB_OUTPUT
echo "HASH=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Build frontend files
working-directory: frontend
run: |
sudo docker run --rm \
-v "$(pwd)":/app \
-w /app \
node:alpine \
sh -c "npm ci && npm run build-production"
- name: Move frontend build to backend
run: |
rm -rf backend/frontend/build
mkdir -p backend/frontend/build
cp -r frontend/build/* backend/frontend/build/
- name: Build single binary with all features
run: |
sudo docker run --rm \
-v "$(pwd)":/app \
-w /app/backend \
golang:alpine \
go build -trimpath \
-ldflags="-X github.com/phishingclub/phishingclub/version.hash=ph${{ steps.get_version.outputs.HASH }} -X github.com/phishingclub/phishingclub/version.version=${{ steps.get_version.outputs.VERSION }}" \
-tags production -o ../build/phishingclub main.go
- name: Fix build directory permissions
run: |
sudo chown -R $USER:$USER build/
chmod 755 build/
ls -la build/
- name: Test binary signing (if keys available)
run: |
if [ -n "${{ secrets.SIGNKEY_1 }}" ]; then
echo "Testing binary signing..."
# Create directory for keys
mkdir -p /tmp/keys
chmod 700 /tmp/keys
# Save private key from GitHub secrets
echo "${{ secrets.SIGNKEY_1 }}" > /tmp/keys/private1.pem
chmod 600 /tmp/keys/private1.pem
# Sign binary with primary key
openssl pkeyutl -sign -inkey /tmp/keys/private1.pem \
-rawin -in build/phishingclub \
-out build/phishingclub.sig
# Clean up keys
rm -rf /tmp/keys
echo "✅ Binary signing test successful"
else
echo "⚠️ SIGNKEY_1 not available - skipping signing test"
fi
- name: Test package creation
run: |
mkdir -p packages
# Test packaging
if [ -f build/phishingclub.sig ]; then
tar -czf packages/phishingclub_${{ steps.get_version.outputs.VERSION }}.tar.gz \
-C build \
phishingclub \
phishingclub.sig
echo "✅ Package created with signature"
else
tar -czf packages/phishingclub_${{ steps.get_version.outputs.VERSION }}.tar.gz \
-C build \
phishingclub
echo "✅ Package created without signature"
fi
- name: Verify build artifacts
run: |
echo "=== Build Summary ==="
echo "Binary size: $(du -h build/phishingclub | cut -f1)"
echo "Binary info:"
file build/phishingclub
if [ -f build/phishingclub.sig ]; then
echo "Signature size: $(du -h build/phishingclub.sig | cut -f1)"
fi
echo "Package size: $(du -h packages/phishingclub_${{ steps.get_version.outputs.VERSION }}.tar.gz | cut -f1)"
echo "Package contents:"
tar -tzf packages/phishingclub_${{ steps.get_version.outputs.VERSION }}.tar.gz
- name: Upload build artifacts (for review)
uses: actions/upload-artifact@v4
with:
name: phishingclub-test-build-${{ steps.get_version.outputs.HASH }}
path: |
build/phishingclub
build/phishingclub.sig
packages/phishingclub_${{ steps.get_version.outputs.VERSION }}.tar.gz
retention-days: 2
+1
View File
@@ -0,0 +1 @@
/build
+25
View File
@@ -0,0 +1,25 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Delve remote",
"type": "go",
"request": "attach",
"mode": "remote",
"substitutePath": [
{
"from": "${workspaceFolder}/",
"to": "/app/",
},
],
"port": 2345,
"host": "127.0.0.1",
"showLog": true,
"apiVersion": 2,
"trace": "verbose"
}
]
}
+127
View File
@@ -0,0 +1,127 @@
# Contributor License Agreement (CLA)
Thank you for your interest in contributing to Phishing Club ("we" or "us").
This Contributor License Agreement ("Agreement") documents the rights granted by contributors to us. To make this document effective, please read it carefully and indicate your agreement by signing off on your commits as described below.
## 1. Definitions
**"You"** (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with us.
**"Contribution"** shall mean any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to us for inclusion in, or documentation of, any of our products.
**"Submit"** means any form of electronic, verbal, or written communication sent to us or our representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems.
## 2. Grant of Copyright License
Subject to the terms and conditions of this Agreement, You hereby grant to us and to recipients of software distributed by us a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to:
- Use, reproduce, modify, display, perform, sublicense, and distribute Your Contributions
- License Your Contributions under the GNU Affero General Public License v3.0 (AGPL-3.0)
- License Your Contributions under commercial licenses that allow redistribution without AGPL restrictions
## 3. Grant of Patent License
Subject to the terms and conditions of this Agreement, You hereby grant to us and to recipients of software distributed by us a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer Your Contributions.
## 4. Dual Licensing
You understand and agree that:
- Your Contributions will be available under the AGPL-3.0 license
- We may also license Your Contributions under commercial licenses
- This dual licensing model allows us to offer commercial licenses to users who prefer not to comply with AGPL-3.0 requirements
- Revenue from commercial licenses helps support the continued development of this open source project
## 5. Representations
You represent that:
1. **Legal Right**: You have the legal right to grant the above licenses
2. **Original Work**: Each of Your Contributions is Your original creation (or You have sufficient rights to grant the licenses for any third-party material included)
3. **No Violations**: Your Contributions do not violate any third-party rights, including intellectual property rights
4. **Accuracy**: The information You provide in this Agreement is accurate
## 6. Disclaimer
You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE.
## 7. How to Sign
To indicate your acceptance of this CLA, include the following line in all your commit messages:
```
Signed-off-by: Your Full Name <your.email@example.com>
```
You can add this automatically by using the `-s` flag when committing:
```bash
git commit -s -m "Your commit message"
```
By including this sign-off, you certify that:
- You have read and agree to this CLA
- Your contribution complies with the Developer Certificate of Origin (DCO)
- You have the right to submit the contribution under these terms
## 8. Developer Certificate of Origin (DCO)
By signing off on your commits, you also agree to the Developer Certificate of Origin v1.1:
```
Developer Certificate of Origin
Version 1.1
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.
Developer's Certificate of Origin 1.1
By making a contribution to this project, I certify that:
(a) The contribution was created in whole or in part by me and I
have the right to submit it under the open source license
indicated in the file; or
(b) The contribution is based upon previous work that, to the best
of my knowledge, is covered under an appropriate open source
license and I have the right under that license to submit that
work with modifications, whether created in whole or in part
by me, under the same open source license (unless I am
permitted to submit under a different license), as indicated
in the file; or
(c) The contribution was provided directly to me by some other
person who certified (a), (b) or (c) and I have not modified
it.
(d) I understand and agree that this project and the contribution
are public and that a record of the contribution (including all
personal information I submit with it, including my sign-off) is
maintained indefinitely and may be redistributed consistent with
this project or the open source license(s) involved.
```
## 9. Corporate Contributors
If You are contributing on behalf of a corporation or other legal entity, the entity must also agree to this CLA. Please have an authorized representative of the entity sign a Corporate CLA by contacting us at legal@phishing.club.
## 10. Contact
For questions about this CLA, please contact:
- Email: help@phishing.club
- Create a GitHub issue for clarifications
- Join our Discord: https://discord.gg/Zssps7U8gX
## 11. Changes to this Agreement
We may update this CLA from time to time. We will notify contributors of any significant changes through our usual communication channels (GitHub and Discord).
---
**Thank you for contributing to Phishing Club!**
This CLA helps ensure that the project can continue to be developed and distributed under both open source and commercial licenses, benefiting the entire community while supporting the project's sustainability.
+80
View File
@@ -0,0 +1,80 @@
# Contributors
This file acknowledges the individuals and organizations who have contributed to Phishing Club.
## How to Contribute
We welcome contributions from the community! Before contributing, please:
1. Read our [Contributing Guidelines](README.md#contributing)
2. Sign our Contributor License Agreement (see below)
3. Follow our development workflow and coding standards
## Contributor License Agreement (CLA)
**Important**: By contributing to Phishing Club, you agree to the following terms:
### Grant of Rights
You hereby grant to Phishing Club and to recipients of software distributed by Phishing Club a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable license to:
- Use, reproduce, modify, display, perform, sublicense, and distribute your contributions
- License your contributions under both the AGPL-3.0 license and commercial licenses
### Representations
You represent that:
- You have the legal right to grant the above licenses
- Your contributions are your original creation or you have sufficient rights to grant the licenses
- Your contributions do not violate any third-party rights
- You understand and agree that your contributions may be licensed under both open source and commercial terms
### Developer Certificate of Origin (DCO)
All commits must include a "Signed-off-by" line to indicate agreement with the [Developer Certificate of Origin](https://developercertificate.org/):
```
git commit -s -m "Your commit message"
```
This adds a line like:
```
Signed-off-by: Your Name <your.email@example.com>
```
By adding this line, you certify that you have the right to contribute the code and agree to our CLA terms.
## Recognition
Contributors will be recognized in the following ways:
- Listed in this file (with permission)
- GitHub contributor statistics
## Types of Contributions
Phishing Club has a lots of room for improvement, both in maintaince and in features that can be implemented. Much of the code can be refactored and improved in various ways.
Join our discord and we can help you if you want a specific project.
Be mindful that all contributions should strive to be secure and work with the
live update system.
We appreciate all forms of contribution:
- 🐛 Bug reports and fixes
- ✨ New features and enhancements
- 📖 Documentation improvements
- 🧪 Test coverage improvements
- 🎨 UI/UX improvements
- 🔒 Security improvements
- 🌍 Translations and internationalization
- 💡 Ideas and feature suggestions
## Contact
For questions about contributing or the CLA:
- Create a GitHub issue
- Join our [Discord community](https://discord.gg/Zssps7U8gX)
- Email: contribute@phishing.club
---
*Thank you to all contributors who help make Phishing Club better!* 🙏
# CONTRIBUTORS
**contributors add yourself here**
+55
View File
@@ -0,0 +1,55 @@
Copyright (C) 2025-present Phishing Club
This file is part of Phishing Club.
Phishing Club is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Phishing Club is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with Phishing Club. If not, see <https://www.gnu.org/licenses/>.
Additional permissions under GNU AGPL version 3 section 7:
If you modify this Program, or any covered work, by linking or combining
it with other code, such other code is not for that reason alone subject
to any of the requirements of the GNU AGPL version 3.
For commercial licensing that allows you to distribute Phishing Club
or derivative works without the restrictions of the AGPL, please contact
license@phishing.club.
================================================================================
DUAL LICENSING
Phishing Club is available under a dual licensing model:
1. Open Source License (AGPL-3.0)
- Free for open source projects
- Free for educational and research use
- Free for internal security testing
- Requires source code disclosure for network services
2. Commercial License
- Removes AGPL restrictions
- Allows proprietary use and distribution
- Enables SaaS offerings without source disclosure
- Includes commercial support options
For commercial licensing inquiries, contact: license@phishing.club
================================================================================
CONTACT
Website: https://phishing.club
Email: license@phishing.club
Security: security@phishing.club
Discord: https://discord.gg/Zssps7U8gX
+681
View File
@@ -0,0 +1,681 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2025-present Phishing Club
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
================================================================================
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+67
View File
@@ -0,0 +1,67 @@
Phishing Club
Copyright (C) 2025-present Phishing Club
This product includes software developed by the Phishing Club project.
================================================================================
DUAL LICENSE NOTICE
================================================================================
This software is available under a dual licensing model:
1. GNU Affero General Public License v3.0 (AGPL-3.0)
- For open source use, educational purposes, and non-commercial applications
- Full license text available in the LICENSE file
- Source code must be made available when distributed or used as a network service
2. Commercial License
- For commercial use without AGPL restrictions
- For proprietary integrations and SaaS offerings
- Contact license@phishing.club for commercial licensing terms
================================================================================
THIRD PARTY COMPONENTS
================================================================================
This software may include third-party components with their own licensing terms.
Please refer to the respective component documentation and license files for
complete licensing information.
================================================================================
CONTRIBUTOR LICENSE AGREEMENT
================================================================================
By contributing to this project, contributors agree that their contributions
will be licensed under the same dual license terms (AGPL-3.0 and commercial).
Contributors confirm they have the right to contribute the code and grant
the project maintainers the right to license contributions under both licenses.
================================================================================
CONTACT INFORMATION
================================================================================
For licensing inquiries: license@phishing.club
For security issues: security@phishing.club
Project website: https://phishing.club
Community Discord: https://discord.gg/Zssps7U8gX
================================================================================
ETHICAL USE DISCLAIMER
================================================================================
This software is designed for authorized security testing, penetration testing,
and security awareness training purposes only. Users are responsible for:
- Obtaining proper authorization before conducting any phishing simulations
- Complying with all applicable laws and regulations
- Using the software ethically and responsibly
- Protecting any data collected during authorized testing
Misuse of this software may violate applicable laws. Users are solely
responsible for ensuring their use complies with all applicable laws
and regulations.
+263
View File
@@ -0,0 +1,263 @@
# Phishing Club
[![Latest Release](https://img.shields.io/github/v/release/phishingclub/phishingclub)](https://github.com/phishingclub/phishingclub/releases/latest)
[![Downloads](https://img.shields.io/github/downloads/phishingclub/phishingclub/total)](https://github.com/phishingclub/phishingclub/releases)
[![Discord](https://img.shields.io/badge/Discord-Join%20Server-7289da?style=flat&logo=discord&logoColor=white)](https://discord.gg/Zssps7U8gX)
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
</div>
The self-hosted phishing framework for security awareness training and penetration testing.
## Overview
Phishing Club is a phishing simulation framework designed for security professionals, red teams, and organizations looking to test and improve their security awareness. This platform provides tools for creating, deploying, and managing phishing campaigns in a controlled environment.
## License
Phishing Club is available under a dual licensing model:
### Open Source License (AGPL-3.0)
This project is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0). This means:
- ✅ You can use, modify, and distribute the software freely
- ✅ Perfect for educational, research, and non-commercial use
- ✅ You can run your own instance for internal security testing
- ⚠️ **Important**: If you provide the software as a network service (SaaS), you must make your source code available under AGPL-3.0
### Commercial License
For organizations that want to:
- Use Phishing Club in commercial products without AGPL restrictions
- Offer Phishing Club as a service without source code disclosure
- Integrate with proprietary software
- Get dedicated support and maintenance
**Contact us for commercial licensing**: [license@phishing.club](mailto:license@phishing.club)
See the [LICENSE](LICENSE) file for the full AGPL-3.0 terms.
## Getting Started
### Production Installation
For production use, download the latest release and follow our installation guide:
1. **Download the latest version** from [GitHub Releases](https://github.com/phishingclub/phishingclub/releases)
2. **Follow the installation guide** at [https://phishing.club/guide/management/#install](https://phishing.club/guide/management/#install)
3. **Complete the setup** by following the step-by-step instructions in our documentation
For detailed setup instructions, troubleshooting, and best practices, visit the [Phishing Club Guide](https://phishing.club/guide/introduction/).
## Development Setup
This repository contains the core Phishing Club platform.
### Prerequisites
- Docker and Docker Compose
- Git
- Make (optional, for convenience commands)
### Quick Start
1. **Clone the repository:**
```bash
git clone https://github.com/phishingclub/phishingclub.git
cd phishingclub
```
2. **Start the services:**
```bash
make up
# or manually:
docker compose up -d
```
3. **Access the platform:**
- Administration: `http://localhost:8003`
- HTTP Phishing Server: `http://localhost:80`
- HTTPS Phishing Server: `https://localhost:443`
4. **Get admin credentials:**
The **username** and **password** are output in the terminal when you start the services. If you restart the backend service before completing setup by logging in, the username and password will change.
```bash
make backend-password
```
5. **Setup and start phishing:**
Open `https://localhost:8003` and setup the admin account using the credentials from step 4.
Visit the [Phishing Club Guide](https://phishing.club/guide/introduction/) for more information.
## Services and Ports
| Port | Service | Description |
|------|---------|-------------|
| 80 | HTTP Phishing Server | HTTP phishing server for campaigns |
| 443 | HTTPS Phishing Server | HTTPS phishing server with SSL |
| 8002 | Backend API | Backend API server |
| 8003 | Frontend | Development frontend with Vite |
| 8101 | Database Viewer | DBGate database administration |
| 8102 | Mail Server | Mailpit SMTP server for testing |
| 8103 | Container Logs | Dozzle log viewer |
| 8104 | Container Stats | Docker container statistics |
| 8201 | ACME Server | Pebble ACME server for certificates |
| 8202 | ACME Management | Pebble management interface |
## Development Commands
```bash
# Start all services
make up
# Stop all services
make down
# View logs
make logs
# Restart specific service
make backend-restart
make frontend-restart
# Access service containers
make backend-attach
make frontend-attach
# Reset backend database
make backend-db-reset
# Get backend admin password
make backend-password
```
## Development Domains
All domains ending with `.test` are automatically handled by the development setup. To use custom domains during development:
### Option 1: DNSMasq (Recommended)
```bash
# Add to your DNSMasq configuration
address=/.test/127.0.0.1
```
### Option 2: Hosts File
Add to `/etc/hosts`:
```
127.0.0.1 microsoft.test
127.0.0.1 google.test
127.0.0.1 vikings.test
127.0.0.1 dark-water.test
```
## Configuration
### Environment Variables
Copy the example environment file and customize:
```bash
cp backend/.env.example backend/.env.development
```
Key configuration options:
- Database settings
- SMTP configuration
- Domain settings
- Security keys
### SSL Certificates
The development environment uses Pebble ACME server for automatic SSL certificate generation. In production, configure your preferred ACME provider or upload custom certificates.
## Contributing
We welcome contributions from the community! Please follow our contribution guidelines:
### Before Contributing
1. **Check existing issues** - Search for existing feature requests or bug reports
2. **Create a feature request** - If your idea doesn't exist, create a detailed feature request issue, we have criteria for which features we want to add and do not waste anyones time with feature requests we never wanted.
3. **Wait for approval** - Allow us to review and approve your proposal
4. **Discuss implementation** - We may suggest changes or alternative approaches
### Development Workflow
1. **Fork the repository** and clone your fork
2. **Create a feature branch** from `main`:
```bash
git checkout -b feat/your-feature-name
```
3. **Follow naming conventions**:
- Features: `feat/feature-name`
- Bug fixes: `fix/bug-description`
- Documentation: `docs/update-description`
- Refactoring: `refactor/component-name`
4. **Follow conventions**:
- Follow existing code style and patterns
- Update documentation as needed
5. **Prepare for submission**:
- **Rebase your commits** to a single, clean commit before creating the pull request
- **Sign your commit** using the `-s` flag: `git commit -s -m "Your commit message"`
- Ensure your commit message is clear and descriptive
6. **Submit a pull request**:
- Reference the related issue number
- Provide a clear description of changes
- Include screenshots/videos for UI changes
### Code Standards
- **Formatting**: Use project configurations
- **Documentation**: Update relevant docs with your changes
- **Security**: Follow secure coding practices
### License Agreement
**Important**: All contributors must agree to our Contributor License Agreement (CLA).
By contributing to Phishing Club, you agree that your contributions will be licensed under the same dual license terms (AGPL-3.0 and commercial). You confirm that:
- You have the right to contribute the code
- Your contributions are your original work or properly attributed
- You grant Phishing Club the right to license your contributions under both AGPL-3.0 and commercial licenses
**Required**:
- All commits must be signed off using the `-s` flag: `git commit -s -m "Your commit message"`
- Before submitting a pull request, rebase your branch to a single commit
- Use descriptive commit messages that explain what and why
```bash
# Example workflow:
git rebase -i main # Interactive rebase against main branch to squash commits
git commit --amend -s # Add sign-off to the final commit if needed
```
This adds a "Signed-off-by" line indicating you agree to our [CLA](CLA.md) and the [Developer Certificate of Origin](https://developercertificate.org/).
For detailed terms, see:
- [Contributor License Agreement (CLA.md)](CLA.md)
- [Contributors Guide (CONTRIBUTORS.md)](CONTRIBUTORS.md)
## Support and Security
Need help, join the [Phishing Club Discord](https://discord.gg/Zssps7U8gX)
- **Security Issues**: Report privately via [security@phishing.club](mailto:security@phishing.club)
- **Commercial Licensing**: Contact [license@phishing.club](mailto:license@phishing.club)
- **General Support**: Join our Discord community or open a GitHub issue
## Only for ethical use
This platform is designed for authorized security testing only. Users are responsible for:
- Obtaining proper authorization before conducting phishing simulations
- Complying with all applicable laws and regulations
- Using the platform ethically and responsibly
- Protecting any data collected during testing
This tool is for authorized security testing only. Misuse of this software may violate applicable laws. Users are solely responsible for ensuring their use complies with all applicable laws and regulations.
+17
View File
@@ -0,0 +1,17 @@
FROM golang:1.24.5
WORKDIR /app
# Add user
# Add group with ID 1000 and user with ID 1000
RUN groupadd -g 1000 appuser && \
useradd -r -u 1000 -g appuser appuser -d /home/appuser -m
COPY go.mod /app/go.mod
COPY main.go /app/main.go
RUN chown -R appuser:appuser /app
USER appuser
RUN go mod tidy
CMD ["go", "run", "main.go"]
+1
View File
@@ -0,0 +1 @@
This service is used for testing and using the functionality of the api sender and webhook functionality.
+3
View File
@@ -0,0 +1,3 @@
module github.com/phishingclub/phishingclub/api-test-server
go 1.23.8
+164
View File
@@ -0,0 +1,164 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math/rand"
"net/http"
"strings"
"time"
)
type Message struct {
To string `json:"to"`
From string `json:"from"`
Content string `json:"content"`
APIKey string `json:"apiKey"`
}
func (m *Message) isValid() error {
if m.To == "" {
return errors.New("missing 'to' field")
}
if m.From == "" {
return errors.New("missing 'from' field")
}
if m.Content == "" {
return errors.New("missing 'content' field")
}
if m.APIKey == "" {
return errors.New("missing 'apiKey' field")
}
return nil
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("POST /api-sender/{clientID}", handleAPISender)
mux.HandleFunc("POST /webhook", handleTestWebhook) // todo rename method and usage to test prefoxhl
err := http.ListenAndServe(":80", mux)
if err != nil {
panic(err)
}
}
func handleAPISender(w http.ResponseWriter, req *http.Request) {
body1, body2, err := cloneBody(req)
if err != nil {
log.Println("failed to clone request body:", err)
http.Error(w, "failed to clone request body", http.StatusInternalServerError)
return
}
log.Println("received api send request")
log.Println(prettyRequest(req, body1))
clientID := req.PathValue("clientID")
if clientID != "5200" {
log.Println("invalid client ID")
http.Error(w, "invalid client ID", http.StatusForbidden)
return
}
// parse message
msg := &Message{}
dec := json.NewDecoder(body2)
if err := dec.Decode(&msg); err != nil {
log.Println("failed to decode message:", err)
http.Error(w, "invalid message", http.StatusBadRequest)
return
}
if err := msg.isValid(); err != nil {
log.Println("invalid message:", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
sleepTime := time.Duration(rand.Intn(2)+1) * time.Second
log.Printf("sleeping for %f seconds\n", sleepTime.Seconds())
time.Sleep(sleepTime)
// return success
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"data": "message sent"})
log.Println("message sent successfully")
}
func handleTestWebhook(w http.ResponseWriter, req *http.Request) {
log.Println("received webhook")
body1, body2, err := cloneBody(req)
if err != nil {
log.Println("failed to clone request body:", err)
http.Error(w, "failed to clone request body", http.StatusInternalServerError)
return
}
log.Println(prettyRequest(req, body1))
// sleep random time between 1 and 3 seconds
time.Sleep(time.Duration(rand.Intn(2)+1) * time.Second)
bodyBytes, err := io.ReadAll(body2)
if err != nil {
log.Println("failed to read body for HMAC calculation:", err)
http.Error(w, "failed to read body", http.StatusInternalServerError)
return
}
// Calculate HMAC256
// from seed/webhooks.go
h := hmac.New(sha256.New, []byte("WEBHOOK_TEST_KEY@1234"))
h.Write(bodyBytes)
calculatedHMAC := hex.EncodeToString(h.Sum(nil))
// Get the signature from the header
signature := req.Header.Get("x-signature")
if signature == "" {
log.Println("missing x-signature header")
http.Error(w, "missing x-signature header", http.StatusBadRequest)
return
}
if signature != "UNSIGNED" {
// Compare the calculated HMAC with the signature
if calculatedHMAC != signature {
log.Println("invalid HMAC signature")
http.Error(w, "invalid HMAC signature", http.StatusForbidden)
return
}
log.Println("valid HMAC signature")
} else {
log.Println("skipping HMAC signature")
}
// return success
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"data": "webhook processed"})
//log.Printf("respone: %++v\n", w)
log.Println("test webhook processed successfully")
}
func prettyRequest(req *http.Request, body io.ReadCloser) string {
l := fmt.Sprintf("Request:\n\tMethod: %s\n\tURL: %s\n\tHeaders:\n", req.Method, req.URL)
for k, v := range req.Header {
// which headers have multiple values?
value := strings.Join(v, "")
l += fmt.Sprintf("\t\t%s: %s\n", k, value)
}
b, err := io.ReadAll(body)
if err != nil {
return l + fmt.Sprintf("\tBody: failed to read body: %v\n", err)
}
l += fmt.Sprintf("\tBody: %s\n", string(b))
return l
}
func cloneBody(req *http.Request) (io.ReadCloser, io.ReadCloser, error) {
bodyBytes, err := io.ReadAll(req.Body)
if err != nil {
return nil, nil, err
}
body1 := io.NopCloser(strings.NewReader(string(bodyBytes)))
body2 := io.NopCloser(strings.NewReader(string(bodyBytes)))
return body1, body2, nil
}
+49
View File
@@ -0,0 +1,49 @@
root = "."
testdata_dir = "testdata"
tmp_dir = ".dev"
[build]
args_bin = ["-files ./.dev", "-config ./config.docker.json"]
bin = "/app/.dev-air/platform"
cmd = "CGO_ENABLED=1 go build dev -o ./.dev-air/platform main.go" # community
delay = 1000
exclude_dir = [
"out",
".dev",
"vendor",
"testdata",
".git",
"build",
"frontend/build/",
]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
#full_bin = "dlv exec --log-dest /.dev/dlv.log --accept-multiclient --headless --continue --listen 0.0.0.0:2345 --api-version 2 /app/air/platform --"
# to debug something early in the application like at boot up, then remove the continue flag
# this will make the debugger not start the program before a client attaches to it
#full_bin = "dlv exec --accept-multiclient --headless --continue --listen 0.0.0.0:2345 --api-version 2 /app/air/platform --"
include_dir = [""]
include_ext = ["go", "tpl", "tmpl", "html"]
kill_delay = "0s"
log = "build-errors.log"
send_interrupt = true #false
stop_on_error = true
[color]
# app = "red"
# build = "yellow"
# main = "magenta"
# runner = "green"
# watcher = "cyan"
[log]
time = false
main_only = false
[misc]
clean_on_exit = false
[screen]
clear_on_rebuild = false
+2
View File
@@ -0,0 +1,2 @@
**/node_modules/
./.dev/**
View File
+10
View File
@@ -0,0 +1,10 @@
.dev-air/*
.dev/**/*
db.sqlite3
*.sqlite3
# air
air
vendor
frontend/build/*
go.work
go.work.sum
+1
View File
@@ -0,0 +1 @@
vendor
+9
View File
@@ -0,0 +1,9 @@
{
"lsp": {
"gopls": {
"initialization_options": {
"buildFlags": ["-tags=dev"]
}
}
}
}
+27
View File
@@ -0,0 +1,27 @@
# development docker file
FROM golang:1.24.5
EXPOSE 8000 8001
WORKDIR /app
# Add user
# Add group with ID 1000 and user with ID 1000
RUN groupadd -g 1000 appuser && \
useradd -r -u 1000 -g appuser appuser -d /home/appuser -m
# install deps
#RUN go install github.com/cosmtrek/air@latest \
#RUN go install github.com/go-delve/delve/cmd/dlv@1.9.1
COPY go.mod /app/go.mod
COPY go.sum /app/go.sum
RUN mkdir -p /app/.dev
RUN mkdir -p /app/.test
RUN chown -R appuser:appuser /app
USER appuser
RUN go install github.com/cosmtrek/air@v1.40.4 && go install github.com/go-delve/delve/cmd/dlv@latest
RUN go mod tidy
CMD ["air", "-c", "/.dev-air/.air.docker.toml"]
+53
View File
@@ -0,0 +1,53 @@
### Platform backend
Install AIR for auto-reloading `go install github.com/cosmtrek/air@latest`
To start the project locally run: `make backend-dev`
Check the terminal output to see the address, username and password
### Production / Deployment
The program must be executable
`chmod +x ./path/to/binary`
The program must have rights to serve on privliged ports
`sudo setcap CAP_NET_BIND_SERVICE=+eip /path/to/binary`
### Known Issues
#### Hot reloading not working / New files now working
If a file accessible in the frontend after adding it, save an existing file to trigger a rebuild. This should now include the new file. If this does not work, try to run `make sorry` which will restart all services.
### Debugging with AIR via. docker and delve
To debug the backend you must uncomment the full bin line in the docker air toml file.
Attach to the debugger to trigger starting the backend.
Do not edit files while in debug mode, instead stop the debugger, edit the file and start the debugger again.
### docker-compose
docker-compose is a plugin for docker that allows you to define multiple services in a single file.
Before this was a stand alone python script run with `docker-compose` but as a plugin this is `docker compose`.
In the makefile, you can edit the top line to change if docker compose is called with or without the dash (-) in the middle.
# Notes about allow listing
{
admin_allowed
trusted_proxies
trusted_ip_header
}
if no admin_allowed is set, all IPs are welcome.
If no trusted proxies are set, headers such as X-Forwarded-By will not be used.
If TrustedIPHeader is set, then this header is used for finding the real IP.
For example cloudflare uses cf-connecting-ip.
If TrustedIPHeader is not set and trusted_proxies is set, then it trusts the IP
from X-Forwarded
# SSO Setup
## Microsoft Entra-ID
### Ensure only specific tenant user's can log in.
In 'properties' set 'Assignment required' to 'Yes'.
In 'Users and groups' add the users or groups that should be able to log into the application.
+89
View File
@@ -0,0 +1,89 @@
package acme
import (
_ "embed"
"github.com/caddyserver/certmagic"
"github.com/go-errors/errors"
"github.com/phishingclub/phishingclub/config"
"github.com/phishingclub/phishingclub/database"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"gorm.io/gorm"
)
// maintenanceCore wraps the original core to filter maintenance messages
type maintenanceCore struct {
zapcore.Core
originalCore zapcore.Core
}
func (c *maintenanceCore) Check(ent zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry {
if ent.Message == "started background certificate maintenance" {
c.Core = c.originalCore
return nil
}
return c.Core.Check(ent, ce)
}
func (c *maintenanceCore) With(fields []zapcore.Field) zapcore.Core {
return &maintenanceCore{
Core: c.Core.With(fields),
originalCore: c.originalCore,
}
}
func setupCertMagic(
certStoragePath string,
conf *config.Config,
db *gorm.DB,
logger *zap.SugaredLogger,
) (*certmagic.Config, *certmagic.Cache, error) {
l := logger.Desugar()
usedLogger := l.Core()
if l.Level() != zap.DebugLevel {
usedLogger = &maintenanceCore{
Core: l.Core(),
originalCore: usedLogger,
}
}
filteredLogger := zap.New(usedLogger)
// Create main config first
certmagic.DefaultACME.Logger = l
certmagic.DefaultACME.Email = conf.ACMEEmail()
mainConfig := certmagic.NewDefault()
mainConfig.Logger = l
mainConfig.Storage = &certmagic.FileStorage{Path: certStoragePath}
mainConfig.OnDemand = &certmagic.OnDemandConfig{
DecisionFunc: func(name string) error {
// check if admin server with auto TLS
if conf.TLSAuto() && conf.TLSHost() == name {
return nil
}
// check phishing host with managed TLS
res := db.
Select("id").
Where("name = ?", name).
Where("managed_tls_certs IS true").
First(&database.Domain{})
if res.RowsAffected > 0 {
return nil
}
return errors.Errorf("not allowing TLS on-demand request for '%s'", name)
},
}
// create cache with config getter
var finalConfig *certmagic.Config
defaultCache := certmagic.NewCache(certmagic.CacheOptions{
GetConfigForCert: func(cert certmagic.Certificate) (*certmagic.Config, error) {
return finalConfig, nil
},
Logger: filteredLogger,
})
// create final config that uses the cache
finalConfig = certmagic.New(defaultCache, *mainConfig)
return finalConfig, defaultCache, nil
}
+56
View File
@@ -0,0 +1,56 @@
//go:build dev
package acme
import (
"crypto/x509"
_ "embed"
"encoding/pem"
"log"
"github.com/caddyserver/certmagic"
"github.com/phishingclub/phishingclub/config"
"github.com/phishingclub/phishingclub/errs"
"go.uber.org/zap"
"gorm.io/gorm"
)
const DEV_ACME_URL = "https://pebble:14000/dir"
//go:embed pebble.minica.pem
var acmeRootCertPemBlock []byte
func loadDevelopmentPebbleCertificate() (*x509.Certificate, error) {
certDERBlock, _ := pem.Decode(acmeRootCertPemBlock)
if certDERBlock == nil {
log.Fatal("Failed to parse the certificate PEM.")
}
acmeRootCert, err := x509.ParseCertificate(certDERBlock.Bytes)
if err != nil {
log.Fatal(err)
}
return acmeRootCert, nil
}
// SetupCertMagic creates a certmagic config for development
// and checks which domains are allowed from the db before getting a certificate
func SetupCertMagic(
certStoragePath string,
conf *config.Config,
db *gorm.DB,
logger *zap.SugaredLogger,
) (*certmagic.Config, *certmagic.Cache, error) {
cert, err := loadDevelopmentPebbleCertificate()
if err != nil {
return nil, nil, errs.Wrap(err)
}
pool := x509.NewCertPool()
pool.AddCert(cert)
certmagic.DefaultACME = certmagic.ACMEIssuer{
CA: DEV_ACME_URL,
TestCA: DEV_ACME_URL,
Agreed: true,
TrustedRoots: pool,
}
return setupCertMagic(certStoragePath, conf, db, logger)
}
+23
View File
@@ -0,0 +1,23 @@
//go:build !dev
package acme
import (
_ "embed"
"github.com/caddyserver/certmagic"
"github.com/phishingclub/phishingclub/config"
"go.uber.org/zap"
"gorm.io/gorm"
)
// SetupCertMagic creates a certmagic config for development
// and checks which domains are allowed from the db before getting a certificate
func SetupCertMagic(
certStoragePath string,
conf *config.Config,
db *gorm.DB,
logger *zap.SugaredLogger,
) (*certmagic.Config, *certmagic.Cache, error) {
return setupCertMagic(certStoragePath, conf, db, logger)
}
+20
View File
@@ -0,0 +1,20 @@
{
"pebble": {
"listenAddress": "0.0.0.0:14000",
"managementListenAddress": "0.0.0.0:15000",
"certificate": "test/certs/localhost/cert.pem",
"privateKey": "test/certs/localhost/key.pem",
"httpPort": 8000,
"tlsPort": 8001,
"ocspResponderURL": "",
"externalAccountBindingRequired": false,
"domainBlocklist": [
"blocked-domain.example"
],
"retryAfter": {
"authz": 3,
"order": 5
},
"certificateValidityPeriod": 157766400
}
}
+19
View File
@@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDCTCCAfGgAwIBAgIIJOLbes8sTr4wDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE
AxMVbWluaWNhIHJvb3QgY2EgMjRlMmRiMCAXDTE3MTIwNjE5NDIxMFoYDzIxMTcx
MjA2MTk0MjEwWjAgMR4wHAYDVQQDExVtaW5pY2Egcm9vdCBjYSAyNGUyZGIwggEi
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC5WgZNoVJandj43kkLyU50vzCZ
alozvdRo3OFiKoDtmqKPNWRNO2hC9AUNxTDJco51Yc42u/WV3fPbbhSznTiOOVtn
Ajm6iq4I5nZYltGGZetGDOQWr78y2gWY+SG078MuOO2hyDIiKtVc3xiXYA+8Hluu
9F8KbqSS1h55yxZ9b87eKR+B0zu2ahzBCIHKmKWgc6N13l7aDxxY3D6uq8gtJRU0
toumyLbdzGcupVvjbjDP11nl07RESDWBLG1/g3ktJvqIa4BWgU2HMh4rND6y8OD3
Hy3H8MY6CElL+MOCbFJjWqhtOxeFyZZV9q3kYnk9CAuQJKMEGuN4GU6tzhW1AgMB
AAGjRTBDMA4GA1UdDwEB/wQEAwIChDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYB
BQUHAwIwEgYDVR0TAQH/BAgwBgEB/wIBADANBgkqhkiG9w0BAQsFAAOCAQEAF85v
d40HK1ouDAtWeO1PbnWfGEmC5Xa478s9ddOd9Clvp2McYzNlAFfM7kdcj6xeiNhF
WPIfaGAi/QdURSL/6C1KsVDqlFBlTs9zYfh2g0UXGvJtj1maeih7zxFLvet+fqll
xseM4P9EVJaQxwuK/F78YBt0tCNfivC6JNZMgxKF59h0FBpH70ytUSHXdz7FKwix
Mfn3qEb9BXSk0Q3prNV5sOV3vgjEtB4THfDxSz9z3+DepVnW3vbbqwEbkXdk3j82
2muVldgOUgTwK8eT+XdofVdntzU/kzygSAtAQwLJfn51fS1GvEcYGBc1bDryIqmF
p9BI7gVKtWSZYegicA==
-----END CERTIFICATE-----
+187
View File
@@ -0,0 +1,187 @@
package acme
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"os"
"path/filepath"
"time"
"github.com/go-errors/errors"
"github.com/phishingclub/phishingclub/build"
"go.uber.org/zap"
)
// Information is a struct for certificate information
type Information struct {
CommonName string
Organization []string
Country []string
Province []string
Locality []string
StreetAddress []string
PostalCode []string
}
// NewInformation creates a new Information
func NewInformation(
commonName string,
organization []string,
country []string,
province []string,
locality []string,
streetAddress []string,
postalCode []string,
) Information {
return Information{
Organization: organization,
Country: country,
Province: province,
Locality: locality,
StreetAddress: streetAddress,
PostalCode: postalCode,
}
}
// NewInformationWithDefault creates a new Information with default values
func NewInformationWithDefault() Information {
return NewInformation(
"",
[]string{""},
[]string{""},
[]string{""},
[]string{""},
[]string{""},
[]string{""},
)
}
// CreateSelfSignedCert creates a self signed certificate with provided hostnames
func CreateSelfSignedCert(
logger *zap.SugaredLogger,
info Information,
hostnames []string,
publicPath string,
privatePath string,
) error {
// Process hostnames into IP addresses and DNS names
var ipAddresses []net.IP
var dnsNames []string
if !build.Flags.Production {
ipAddresses = append(ipAddresses, net.IPv4(127, 0, 0, 1), net.IPv6loopback)
dnsNames = append(dnsNames, "localhost")
}
for _, h := range hostnames {
if ip := net.ParseIP(h); ip != nil {
ipAddresses = append(ipAddresses, ip)
} else {
dnsNames = append(dnsNames, h)
}
}
// Use info.CommonName if provided, otherwise use first hostname or "localhost"
commonName := info.CommonName
if commonName == "" || commonName == "127.0.0.1" {
if len(hostnames) > 0 {
commonName = hostnames[0]
} else {
commonName = "localhost"
}
}
// Create certificate with appropriate SAN extensions
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
return errors.Errorf("failed to generate serial number: %s", err)
}
cert := &x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
CommonName: commonName,
Organization: info.Organization,
Country: info.Country,
Province: info.Province,
Locality: info.Locality,
StreetAddress: info.StreetAddress,
PostalCode: info.PostalCode,
},
IPAddresses: ipAddresses,
DNSNames: dnsNames,
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(10, 0, 0),
SubjectKeyId: []byte{0, 0, 0, 0, 0},
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
BasicConstraintsValid: true,
}
certPrivKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return errors.Errorf("failed to generate private key: %s", err)
}
certBytes, err := x509.CreateCertificate(rand.Reader, cert, cert, &certPrivKey.PublicKey, certPrivKey)
if err != nil {
return errors.Errorf("failed to create certificate: %s", err)
}
// Create directories if they don't exist
certDir := filepath.Dir(publicPath)
if err := os.MkdirAll(certDir, 0750); err != nil {
return errors.Errorf("failed to create certificate directory: %s", err)
}
keyDir := filepath.Dir(privatePath)
if err := os.MkdirAll(keyDir, 0750); err != nil {
return errors.Errorf("failed to create key directory: %s", err)
}
// Write certificate
// #nosec
certOut, err := os.Create(publicPath)
if err != nil {
return errors.Errorf("failed to open certificate file for writing: %s", err)
}
defer certOut.Close()
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: certBytes}); err != nil {
return errors.Errorf("failed to write certificate: %s", err)
}
// Write private key
// #nosec
keyOut, err := os.OpenFile(privatePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return errors.Errorf("failed to open key file for writing: %s", err)
}
defer keyOut.Close()
privBlock := &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(certPrivKey),
}
if err := pem.Encode(keyOut, privBlock); err != nil {
return errors.Errorf("failed to write private key: %s", err)
}
/*
logger.Debugf("generated self-signed certificate",
"certificate", publicPath,
"key", privatePath,
"common_name", commonName,
"ip_addresses", ipAddresses,
"dns_names", dnsNames,
)
*/
return nil
}
+173
View File
@@ -0,0 +1,173 @@
package api
import (
"fmt"
"net/http"
"github.com/go-errors/errors"
"github.com/gin-gonic/gin"
)
const (
// All constant here are used for frontend responses
NotFound = "Not found"
InvalidData = "Missing or invalid data"
Unauthorized = "Authorization failed"
Forbidden = "Access denied"
ServerError = "Internal server error"
InvalidCompanyID = "Invalid company ID"
InvalidDomainID = "Invalid domain ID"
InvalidMessageID = "Invalid message ID"
InvalidPageID = "Invalid page ID"
InvalidPageTypeID = "Invalid page type ID"
InvalidRecipientID = "Invalid recipient ID"
InvalidRecipientGroupID = "Invalid recipient group ID"
InvalidSMTPConfigurationID = "Invalid SMTP configuration ID"
CompanyNotFound = "Company not found"
)
// JSONResponse is the response structure for the API
type JSONResponse struct {
Success bool `json:"success"`
Data any `json:"data"`
Error string `json:"error"`
}
// JSONResponseHandler is a interface for API responses
type JSONResponseHandler interface {
OK(g *gin.Context, data any)
NotFound(g *gin.Context)
Unauthorized(g *gin.Context)
Forbidden(g *gin.Context)
BadRequest(g *gin.Context)
BadRequestMessage(g *gin.Context, message string)
ValidationFailed(g *gin.Context, field string, err error)
ServerError(g *gin.Context)
ServerErrorMessage(g *gin.Context, message string)
}
// jsonResponseHandler is a JSON API responder
type jsonResponseHandler struct{}
// NewJSONResponseHandler creates a new JSON responder
func NewJSONResponseHandler() JSONResponseHandler {
return &jsonResponseHandler{}
}
// newResponse creates a new JSON response
func (r *jsonResponseHandler) newResponse(
success bool,
data any,
errorMessage string,
) JSONResponse {
return JSONResponse{
Success: success,
Data: data,
Error: errorMessage,
}
}
// newOK creates a new OK response
func (r *jsonResponseHandler) newOK(data any) JSONResponse {
return r.newResponse(true, data, "")
}
// newError creates a new error response
func (r *jsonResponseHandler) newError(errorMessage string) JSONResponse {
return r.newResponse(false, nil, errorMessage)
}
// OK responds with 200 - OK
func (r *jsonResponseHandler) OK(g *gin.Context, data any) {
g.JSON(http.StatusOK, r.newOK(data))
}
// NotFound responds 404 - NOT FOUND
func (r *jsonResponseHandler) NotFound(g *gin.Context) {
g.JSON(
http.StatusNotFound,
r.newError(NotFound),
)
g.Abort()
}
// Unauthorized responds with 401 - UNAUTHORIZED
// generic error handler for authentication errors
func (r *jsonResponseHandler) Unauthorized(g *gin.Context) {
g.JSON(
http.StatusUnauthorized,
r.newError(Forbidden),
)
g.Abort()
}
// Forbidden responds with 403 - FORBIDDEN and a custom error message
// generic error handler for authorization errors
func (r *jsonResponseHandler) Forbidden(g *gin.Context) {
g.JSON(
http.StatusForbidden,
r.newError(Unauthorized),
)
g.Abort()
}
// BadRequest responds with 400 - BAD REQUEST
func (r *jsonResponseHandler) BadRequest(g *gin.Context) {
g.JSON(
http.StatusBadRequest,
r.newError(InvalidData),
)
g.Abort()
}
// BadRequestMessage responds with 400 - BAD REQUEST and a custom error message
func (r *jsonResponseHandler) BadRequestMessage(g *gin.Context, message string) {
g.JSON(
http.StatusBadRequest,
r.newError(message),
)
g.Abort()
}
func (r *jsonResponseHandler) unwrapErrorMessage(err error) string {
message := err.Error()
unwrapped := errors.Unwrap(err)
if unwrapped != nil {
message = r.unwrapErrorMessage(unwrapped)
}
return message
}
// ValidationFailed responds with 400 - BAD REQUEST and a validation error message
// that includes the field name and the validation error message
// if the err IS a ValidationError it will unwrap the validation error
// else it will use the error passed
func (r *jsonResponseHandler) ValidationFailed(g *gin.Context, field string, err error) {
message := r.unwrapErrorMessage(err)
g.JSON(
http.StatusBadRequest,
r.newError(
fmt.Sprintf("%s %s", field, message),
),
)
g.Abort()
}
// ServerError responds with 500 - INTERNAL SERVER ERROR
func (r *jsonResponseHandler) ServerError(g *gin.Context) {
g.JSON(
http.StatusInternalServerError,
r.newError(ServerError),
)
g.Abort()
}
// ServerError responds with 500 - INTERNAL SERVER ERROR and a custom error message
func (r *jsonResponseHandler) ServerErrorMessage(g *gin.Context, message string) {
g.JSON(
http.StatusInternalServerError,
r.newError(message),
)
g.Abort()
}
+817
View File
@@ -0,0 +1,817 @@
package app
import (
"context"
"crypto/tls"
"crypto/x509"
"embed"
"encoding/pem"
"fmt"
"io/fs"
"log"
"net"
"net/http"
"os"
"strings"
"time"
"github.com/caddyserver/certmagic"
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/acme"
"github.com/phishingclub/phishingclub/build"
"github.com/phishingclub/phishingclub/config"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/frontend"
"github.com/phishingclub/phishingclub/server"
"go.uber.org/zap"
)
const (
// health
ROUTE_V1_HEALTH = "/api/v1/healthz"
ROUTE_V1_LIVE = "/api/v1/livez"
ROUTE_V1_READY = "/api/v1/readyz"
// application
ROUTE_V1_FEATURE = "/api/v1/features"
ROUTE_V1_UPDATE_AVAILABLE = "/api/v1/update/available"
ROUTE_V1_UPDATE_AVAILABLE_CACHED = "/api/v1/update/available/cached"
ROUTE_V1_UPDATE = "/api/v1/update"
// user
ROUTE_V1_USER = "/api/v1/user"
ROUTE_V1_USER_ID = "/api/v1/user/:id"
ROUTE_V1_USER_LOGIN = "/api/v1/user/login"
ROUTE_V1_USER_LOGOUT = "/api/v1/user/logout"
// #nosec
ROUTE_V1_USER_PASSWORD = "/api/v1/user/password"
ROUTE_V1_USER_USERNAME = "/api/v1/user/username"
ROUTE_V1_USER_FULLNAME = "/api/v1/user/fullname"
ROUTE_V1_USER_EMAIL = "/api/v1/user/email"
ROUTE_V1_USER_SESSIONS = "/api/v1/user/sessions"
ROUTE_V1_USER_SESSIONS_INVALIDATE = "/api/v1/user/sessions/invalidate"
ROUTE_V1_USER_API = "/api/v1/user/api"
// sso
ROUTE_V1_SSO_ENTRA_ID = "/api/v1/sso/entra-id"
ROUTE_V1_SSO_ENTRA_ID_ENABLED = "/api/v1/sso/entra-id/enabled"
ROUTE_V1_SSO_ENTRA_ID_LOGIN = "/api/v1/sso/entra-id/login"
ROUTE_V1_SSO_ENTRA_ID_CALLBACK = "/api/v1/sso/entra-id/auth"
// mfa
ROUTE_V1_USER_MFA_TOTP_SETUP = "/api/v1/user/mfa/totp/setup"
ROUTE_V1_USER_MFA_TOTP_SETUP_VERIFY = "/api/v1/user/mfa/totp/setup/verify"
ROUTE_V1_USER_MFA_TOTP_VERIFY = "/api/v1/user/mfa/totp/verify"
ROUTE_V1_USER_MFA_TOTP = "/api/v1/user/mfa/totp"
ROUTE_V1_QR_FROM_TOTP = "/api/v1/qr/totp"
ROUTE_V1_QR_URL_TO_HTML = "/api/v1/qr/html"
// session
ROUTE_V1_SESSION_ID = "/api/v1/session/:id"
ROUTE_V1_SESSION_PING = "/api/v1/session/ping"
// company
ROUTE_V1_COMPANY = "/api/v1/company"
ROUTE_V1_COMPANY_ID = "/api/v1/company/:id"
ROUTE_V1_COMPANY_ID_EXPORT = "/api/v1/company/:id/export"
ROUTE_V1_COMPANY_ID_EXPORT_SHARED = "/api/v1/company/shared/export"
// option
ROUTE_V1_OPTION = "/api/v1/option"
ROUTE_V1_OPTION_GET = "/api/v1/option/:key"
// installation
ROUTE_V1_INSTALL = "/api/v1/install"
// domain
ROUTE_V1_DOMAIN = "/api/v1/domain"
ROUTE_V1_DOMAIN_SUBSET = "/api/v1/domain/subset"
ROUTE_V1_DOMAIN_ID = "/api/v1/domain/:id"
ROUTE_V1_DOMAIN_NAME = "/api/v1/domain/name/:domain"
// page
ROUTE_V1_PAGE = "/api/v1/page"
ROUTE_V1_PAGE_OVERVIEW = "/api/v1/page/overview"
ROUTE_V1_PAGE_ID = "/api/v1/page/:id"
ROUTE_V1_PAGE_CONTENT_ID = "/api/v1/page/:id/content"
// recipient and groups
ROUTE_V1_RECIPIENT = "/api/v1/recipient"
ROUTE_V1_RECIPIENT_IMPORT = "/api/v1/recipient/import"
ROUTE_V1_RECIPIENT_EXPORT = "/api/v1/recipient/:id/export"
ROUTE_V1_RECIPIENT_ID = "/api/v1/recipient/:id"
ROUTE_V1_RECIPIENT_ID_EVENTS = "/api/v1/recipient/:id/events"
ROUTE_V1_RECIPIENT_ID_STATS = "/api/v1/recipient/:id/stats"
ROUTE_V1_RECIPIENT_REPEAT_OFFENDERS = "/api/v1/recipient/repeat-offenders"
ROUTE_V1_RECIPIENT_GROUP = "/api/v1/recipient/group"
ROUTE_V1_RECIPIENT_GROUP_ID = "/api/v1/recipient/group/:id"
ROUTE_V1_RECIPIENT_GROUP_ID_IMPORT = "/api/v1/recipient/group/:id/import"
ROUTE_V1_RECIPIENT_GROUP_RECIPIENTS = "/api/v1/recipient/group/:id/recipients"
// logging
ROUTE_V1_LOG = "/api/v1/log"
ROUTE_V1_LOG_TEST = "/api/v1/log/test"
// smtp configuration
ROUTE_V1_SMTP_CONFIGURATION = "/api/v1/smtp-configuration"
ROUTE_V1_SMTP_CONFIGURATION_ID = "/api/v1/smtp-configuration/:id"
ROUTE_V1_SMTP_CONFIGURATION_ID_TEST_EMAIL = "/api/v1/smtp-configuration/:id/test-email"
ROUTE_V1_SMTP_CONFIGURATION_HEADERS = "/api/v1/smtp-configuration/:id/header"
ROUTE_V1_SMTP_HEADER_ID = "/api/v1/smtp-configuration/:id/header/:headerID"
// email
ROUTE_V1_EMAIL = "/api/v1/email"
ROUTE_V1_EMAIL_OVERVIEW = "/api/v1/email/overview"
ROUTE_V1_EMAIL_ID = "/api/v1/email/:id"
ROUTE_V1_EMAIL_SEND_TEST = "/api/v1/email/:id/send-test"
ROUTE_V1_EMAIL_CONTENT_ID = "/api/v1/email/:id/content"
// campaign
ROUTE_V1_CAMPAIGN_TEMPLATE = "/api/v1/campaign/template"
ROUTE_v1_CAMPAIGN_TEMPLATE_ID = "/api/v1/campaign/template/:id"
ROUTE_V1_CAMPAIGN = "/api/v1/campaign"
ROUTE_V1_CAMPAIGN_CALENDAR = "/api/v1/campaign/calendar"
ROUTE_V1_CAMPAIGN_ACTIVE = "/api/v1/campaign/active"
ROUTE_V1_CAMPAIGN_UPCOMING = "/api/v1/campaign/upcoming"
ROUTE_V1_CAMPAIGN_FINISHED = "/api/v1/campaign/finished"
ROUTE_V1_CAMPAIGN_CLOSE = "/api/v1/campaign/:id/close"
ROUTE_V1_CAMPAIGN_EXPORT_EVENTS = "/api/v1/campaign/:id/export/events"
ROUTE_V1_CAMPAIGN_EXPORT_SUBMISSIONS = "/api/v1/campaign/:id/export/submissions"
ROUTE_V1_CAMPAIGN_ANONYMIZE = "/api/v1/campaign/:id/anonymize"
ROUTE_V1_CAMPAIGN_ID = "/api/v1/campaign/:id"
ROUTE_V1_CAMPAIGN_NAME = "/api/v1/campaign/name/:name"
ROUTE_V1_CAMPAIGN_RECIPIENTS = "/api/v1/campaign/:id/recipients"
ROUTE_V1_CAMPAIGN_RESULT_STATS = "/api/v1/campaign/:id/statistics"
ROUTE_V1_CAMPAIGN_EVENTS = "/api/v1/campaign/:id/events"
ROUTE_V1_CAMPAIGN_EVENT_NAMES = "/api/v1/campaign/event-types"
ROUTE_V1_CAMPAIGN_STATS = "/api/v1/campaign/statistics"
ROUTE_V1_CAMPAIGN_STATS_ID = "/api/v1/campaign/:id/stats"
ROUTE_V1_CAMPAIGN_STATS_ALL = "/api/v1/campaign/stats/all"
// campaign-recipient
ROUTE_V1_CAMPAIGN_RECIPIENT_EMAIL = "/api/v1/campaign/recipient/:id/email"
ROUTE_V1_CAMPAIGN_RECIPIENT_URL = "/api/v1/campaign/recipient/:id/url"
ROUTE_V1_CAMPAIGN_RECIPIENT_SET_SENT = "/api/v1/campaign/recipient/:id/sent"
// asset
ROUTE_V1_ASSET = "/api/v1/asset"
ROUTE_V1_ASSET_ID = "/api/v1/asset/:id"
ROUTE_V1_ASSET_DOMAIN_CONTEXT = "/api/v1/asset/domain/:domain"
ROUTE_V1_ASSET_GLOBAL_CONTEXT = "/api/v1/asset/domain/"
ROUTE_V1_ASSET_DOMAIN_VIEW = "/api/v1/asset/view/domain/:domain/*path"
// attachments
ROUTE_V1_ATTACHMENT = "/api/v1/attachment"
ROUTE_V1_ATTACHMENT_ID = "/api/v1/attachment/:id"
ROUTE_V1_ATTACHMENT_ID_CONTENT = "/api/v1/attachment/:id/content"
ROUTE_V1_ATTACHMENT_COMPANY_CONTEXT = "/api/v1/attachment/company/:companyID"
ROUTE_V1_ATTACHMENT_GLOBAL_CONTEXT = "/api/v1/attachment/company/"
ROUTE_V1_EMAIL_ATTACHMENT = "/api/v1/email/:id/attachment"
// api sender
ROUTE_V1_API_SENDER = "/api/v1/api-sender"
ROUTE_V1_API_SENDER_OVERVIEW = "/api/v1/api-sender/overview"
ROUTE_V1_API_SENDER_ID = "/api/v1/api-sender/:id"
ROUTE_V1_API_SENDER_ID_TEST = "/api/v1/api-sender/:id/test"
// deny allow
ROUTE_V1_ALLOW_DENY = "/api/v1/allow-deny"
ROUTE_V1_ALLOW_DENY_OVERVIEW = "/api/v1/allow-deny/overview"
ROUTE_V1_ALLOW_DENY_ID = "/api/v1/allow-deny/:id"
// web hooks
ROUTE_V1_WEBHOOK = "/api/v1/webhook"
ROUTE_V1_WEBHOOK_ID = "/api/v1/webhook/:id"
ROUTE_V1_WEBHOOK_ID_TEST = "/api/v1/webhook/:id/test"
// identifiers
ROUTE_V1_IDENTIFIER = "/api/v1/identifier"
// license
ROUTE_V1_LICENSE = "/api/v1/license"
// version
ROUTE_V1_VERSION = "/api/v1/version"
// import
ROUTE_V1_IMPORT = "/api/v1/import"
)
// administrationServer is the administrationServer app
type administrationServer struct {
Server *http.Server
router *gin.Engine
logger *zap.SugaredLogger
production bool
embedBackendFS *embed.FS
certMagicConfig *certmagic.Config
}
// NewAdministrationServer creates a new administration app
func NewAdministrationServer(
router *gin.Engine,
controllers *Controllers,
middlewares *Middlewares,
logger *zap.SugaredLogger,
certMagicConfig *certmagic.Config,
production bool,
) *administrationServer {
router = setupRoutes(router, controllers, middlewares)
return &administrationServer{
router: router,
logger: logger,
production: production,
certMagicConfig: certMagicConfig,
}
}
func (a *administrationServer) Router() *gin.Engine {
return a.router
}
// setupRoutes sets up the routes for the administration app
func setupRoutes(
r *gin.Engine,
controllers *Controllers,
middleware *Middlewares,
) *gin.Engine {
if !build.Flags.Production {
r.
GET("/api/v1/_debug/panic", middleware.SessionHandler, controllers.Log.Panic).
GET("/api/v1/_debug/slow", middleware.SessionHandler, controllers.Log.Slow)
}
r.
// log
GET(ROUTE_V1_LOG, middleware.SessionHandler, controllers.Log.GetLevel).
POST(ROUTE_V1_LOG, middleware.SessionHandler, controllers.Log.SetLevel).
GET(ROUTE_V1_LOG_TEST, middleware.SessionHandler, controllers.Log.TestLog).
// application
GET(ROUTE_V1_UPDATE_AVAILABLE, middleware.SessionHandler, controllers.Update.CheckForUpdate).
GET(ROUTE_V1_UPDATE_AVAILABLE_CACHED, middleware.SessionHandler, controllers.Update.CheckForUpdateCached).
// health
GET(ROUTE_V1_HEALTH, controllers.Health.Health).
GET(ROUTE_V1_LIVE, controllers.Health.Health).
GET(ROUTE_V1_READY, controllers.Health.Health).
// login, logout and session
GET(ROUTE_V1_SESSION_PING, middleware.SessionHandler, controllers.User.SessionPing).
POST(ROUTE_V1_USER_LOGIN, middleware.LoginRateLimiter, controllers.User.Login).
POST(ROUTE_V1_USER_LOGOUT, controllers.User.Logout).
// install
POST(ROUTE_V1_INSTALL, middleware.SessionHandler, controllers.Installer.Install).
// user
GET(ROUTE_V1_USER, middleware.SessionHandler, controllers.User.GetAll).
GET(ROUTE_V1_USER_ID, middleware.SessionHandler, controllers.User.GetByID).
POST(ROUTE_V1_USER_ID, middleware.SessionHandler, controllers.User.UpdateByID).
POST(ROUTE_V1_USER, middleware.SessionHandler, controllers.User.Create).
DELETE(ROUTE_V1_USER_ID, middleware.SessionHandler, controllers.User.Delete).
POST(ROUTE_V1_USER_PASSWORD, middleware.SessionHandler, controllers.User.ChangePasswordOnLoggedInUser).
POST(ROUTE_V1_USER_USERNAME, middleware.SessionHandler, controllers.User.ChangeUsernameOnLoggedInUser).
POST(ROUTE_V1_USER_FULLNAME, middleware.SessionHandler, controllers.User.ChangeFullnameOnLoggedInUser).
POST(ROUTE_V1_USER_EMAIL, middleware.SessionHandler, controllers.User.ChangeEmailOnLoggedInUser).
GET(ROUTE_V1_USER_SESSIONS, middleware.SessionHandler, controllers.User.GetSessionsOnLoggedInUser).
POST(ROUTE_V1_USER_SESSIONS_INVALIDATE, middleware.SessionHandler, controllers.User.InvalidateAllSessionByUserID).
DELETE(ROUTE_V1_SESSION_ID, middleware.SessionHandler, controllers.User.ExpireSessionByID).
GET(ROUTE_V1_USER_API, middleware.SessionHandler, controllers.User.GetMaskedAPIKey).
POST(ROUTE_V1_USER_API, middleware.SessionHandler, controllers.User.UpsertAPIKey).
DELETE(ROUTE_V1_USER_API, middleware.SessionHandler, controllers.User.RemoveAPIKey).
// sso
GET(ROUTE_V1_SSO_ENTRA_ID_ENABLED, controllers.SSO.IsEnabled).
POST(ROUTE_V1_SSO_ENTRA_ID, middleware.SessionHandler, controllers.SSO.Upsert).
GET(ROUTE_V1_SSO_ENTRA_ID_LOGIN, controllers.SSO.EntreIDLogin).
GET(ROUTE_V1_SSO_ENTRA_ID_CALLBACK, controllers.SSO.EntreIDCallBack).
// user mfa
GET(ROUTE_V1_USER_MFA_TOTP, middleware.SessionHandler, controllers.User.IsTOTPEnabled).
POST(ROUTE_V1_USER_MFA_TOTP_SETUP, middleware.LoginRateLimiter, middleware.SessionHandler, controllers.User.SetupTOTP).
POST(ROUTE_V1_USER_MFA_TOTP_SETUP_VERIFY, middleware.LoginRateLimiter, middleware.SessionHandler, controllers.User.SetupVerifyTOTP).
POST(ROUTE_V1_USER_MFA_TOTP_VERIFY, middleware.LoginRateLimiter, middleware.SessionHandler, controllers.User.VerifyTOTP).
POST(ROUTE_V1_USER_MFA_TOTP, middleware.LoginRateLimiter, middleware.SessionHandler, controllers.User.DisableTOTP).
// qr
POST(ROUTE_V1_QR_FROM_TOTP, middleware.SessionHandler, controllers.QR.ToTOTPURL).
POST(ROUTE_V1_QR_URL_TO_HTML, middleware.SessionHandler, controllers.QR.ToHTML).
// company
POST(ROUTE_V1_COMPANY, middleware.SessionHandler, controllers.Company.Create).
POST(ROUTE_V1_COMPANY_ID, middleware.SessionHandler, controllers.Company.ChangeName).
GET(ROUTE_V1_COMPANY, middleware.SessionHandler, controllers.Company.GetAll).
GET(ROUTE_V1_COMPANY_ID_EXPORT, middleware.SessionHandler, controllers.Company.ExportByCompanyID).
GET(ROUTE_V1_COMPANY_ID_EXPORT_SHARED, middleware.SessionHandler, controllers.Company.ExportShared).
GET(ROUTE_V1_COMPANY_ID, middleware.SessionHandler, controllers.Company.GetByID).
DELETE(ROUTE_V1_COMPANY_ID, middleware.SessionHandler, controllers.Company.DeleteByID).
// options
GET(ROUTE_V1_OPTION_GET, middleware.SessionHandler, controllers.Option.Get).
POST(ROUTE_V1_OPTION, middleware.SessionHandler, middleware.SessionHandler, controllers.Option.Update).
// domain
GET(ROUTE_V1_DOMAIN, middleware.SessionHandler, controllers.Domain.GetAll).
GET(ROUTE_V1_DOMAIN_SUBSET, middleware.SessionHandler, controllers.Domain.GetAllOverview).
GET(ROUTE_V1_DOMAIN_ID, middleware.SessionHandler, controllers.Domain.GetByID).
GET(ROUTE_V1_DOMAIN_NAME, middleware.SessionHandler, controllers.Domain.GetByName).
POST(ROUTE_V1_DOMAIN, middleware.SessionHandler, controllers.Domain.Create).
POST(ROUTE_V1_DOMAIN_ID, middleware.SessionHandler, controllers.Domain.UpdateByID).
DELETE(ROUTE_V1_DOMAIN_ID, middleware.SessionHandler, controllers.Domain.DeleteByID).
// recipient
GET(ROUTE_V1_RECIPIENT, middleware.SessionHandler, controllers.Recipient.GetAll).
GET(ROUTE_V1_RECIPIENT_ID, middleware.SessionHandler, controllers.Recipient.GetByID).
GET(ROUTE_V1_RECIPIENT_ID_EVENTS, middleware.SessionHandler, controllers.Recipient.GetCampaignEvents).
GET(ROUTE_V1_RECIPIENT_ID_STATS, middleware.SessionHandler, controllers.Recipient.GetStatsByID).
POST(ROUTE_V1_RECIPIENT, middleware.SessionHandler, controllers.Recipient.Create).
POST(ROUTE_V1_RECIPIENT_IMPORT, middleware.SessionHandler, controllers.Recipient.Import).
GET(ROUTE_V1_RECIPIENT_EXPORT, middleware.SessionHandler, controllers.Recipient.Export).
PATCH(ROUTE_V1_RECIPIENT_ID, middleware.SessionHandler, controllers.Recipient.UpdateByID).
DELETE(ROUTE_V1_RECIPIENT_ID, middleware.SessionHandler, controllers.Recipient.DeleteByID).
GET(ROUTE_V1_RECIPIENT_REPEAT_OFFENDERS, middleware.SessionHandler, controllers.Recipient.GetRepeatOffenderCount).
// recipient group
GET(ROUTE_V1_RECIPIENT_GROUP, middleware.SessionHandler, controllers.RecipientGroup.GetAll).
GET(ROUTE_V1_RECIPIENT_GROUP_ID, middleware.SessionHandler, controllers.RecipientGroup.GetByID).
GET(ROUTE_V1_RECIPIENT_GROUP_RECIPIENTS, middleware.SessionHandler, controllers.RecipientGroup.GetRecipientsByGroupID).
POST(ROUTE_V1_RECIPIENT_GROUP_RECIPIENTS, middleware.SessionHandler, controllers.RecipientGroup.AddRecipients).
DELETE(ROUTE_V1_RECIPIENT_GROUP_RECIPIENTS, middleware.SessionHandler, controllers.RecipientGroup.RemoveRecipients).
POST(ROUTE_V1_RECIPIENT_GROUP, middleware.SessionHandler, controllers.RecipientGroup.Create).
PATCH(ROUTE_V1_RECIPIENT_GROUP_ID, middleware.SessionHandler, controllers.RecipientGroup.UpdateByID).
PUT(ROUTE_V1_RECIPIENT_GROUP_ID_IMPORT, middleware.SessionHandler, controllers.RecipientGroup.Import).
DELETE(ROUTE_V1_RECIPIENT_GROUP_ID, middleware.SessionHandler, controllers.RecipientGroup.DeleteByID).
// page
GET(ROUTE_V1_PAGE, middleware.SessionHandler, controllers.Page.GetAll).
GET(ROUTE_V1_PAGE_OVERVIEW, middleware.SessionHandler, controllers.Page.GetOverview).
GET(ROUTE_V1_PAGE_ID, middleware.SessionHandler, controllers.Page.GetByID).
Any(ROUTE_V1_PAGE_CONTENT_ID, middleware.SessionHandler, controllers.Page.GetContentByID).
POST(ROUTE_V1_PAGE, middleware.SessionHandler, controllers.Page.Create).
PATCH(ROUTE_V1_PAGE_ID, middleware.SessionHandler, controllers.Page.UpdateByID).
DELETE(ROUTE_V1_PAGE_ID, middleware.SessionHandler, controllers.Page.DeleteByID).
// smtp configuration
GET(ROUTE_V1_SMTP_CONFIGURATION, middleware.SessionHandler, controllers.SMTPConfiguration.GetAll).
GET(ROUTE_V1_SMTP_CONFIGURATION_ID, middleware.SessionHandler, controllers.SMTPConfiguration.GetByID).
POST(ROUTE_V1_SMTP_CONFIGURATION, middleware.SessionHandler, controllers.SMTPConfiguration.Create).
POST(ROUTE_V1_SMTP_CONFIGURATION_ID_TEST_EMAIL, middleware.SessionHandler, controllers.SMTPConfiguration.TestEmail).
PATCH(ROUTE_V1_SMTP_CONFIGURATION_ID, middleware.SessionHandler, controllers.SMTPConfiguration.UpdateByID).
DELETE(ROUTE_V1_SMTP_CONFIGURATION_ID, middleware.SessionHandler, controllers.SMTPConfiguration.DeleteByID).
// smtp configuration headers
PATCH(ROUTE_V1_SMTP_CONFIGURATION_HEADERS, middleware.SessionHandler, controllers.SMTPConfiguration.AddHeader).
DELETE(ROUTE_V1_SMTP_HEADER_ID, middleware.SessionHandler, controllers.SMTPConfiguration.RemoveHeader).
// emails
GET(ROUTE_V1_EMAIL, middleware.SessionHandler, controllers.Email.GetAll).
GET(ROUTE_V1_EMAIL_OVERVIEW, middleware.SessionHandler, controllers.Email.GetOverviews).
GET(ROUTE_V1_EMAIL_ID, middleware.SessionHandler, controllers.Email.GetByID).
GET(ROUTE_V1_EMAIL_CONTENT_ID, middleware.SessionHandler, controllers.Email.GetContentByID).
POST(ROUTE_V1_EMAIL_SEND_TEST, middleware.SessionHandler, controllers.Email.SendTestEmail).
POST(ROUTE_V1_EMAIL, middleware.SessionHandler, controllers.Email.Create).
// TODO PATCH
POST(ROUTE_V1_EMAIL_ID, middleware.SessionHandler, controllers.Email.UpdateByID).
DELETE(ROUTE_V1_EMAIL_ID, middleware.SessionHandler, controllers.Email.DeleteByID).
// email attachments
POST(ROUTE_V1_EMAIL_ATTACHMENT, middleware.SessionHandler, controllers.Email.AddAttachments).
DELETE(ROUTE_V1_EMAIL_ATTACHMENT, middleware.SessionHandler, controllers.Email.RemoveAttachment).
// campaign templates
GET(ROUTE_V1_CAMPAIGN_TEMPLATE, middleware.SessionHandler, controllers.CampaignTemplate.GetAll).
GET(ROUTE_v1_CAMPAIGN_TEMPLATE_ID, middleware.SessionHandler, controllers.CampaignTemplate.GetByID).
// TODO PATCH
POST(ROUTE_V1_CAMPAIGN_TEMPLATE, middleware.SessionHandler, controllers.CampaignTemplate.Create).
POST(ROUTE_v1_CAMPAIGN_TEMPLATE_ID, middleware.SessionHandler, controllers.CampaignTemplate.UpdateByID).
DELETE(ROUTE_v1_CAMPAIGN_TEMPLATE_ID, middleware.SessionHandler, controllers.CampaignTemplate.DeleteByID).
// campaigns
GET(ROUTE_V1_CAMPAIGN, middleware.SessionHandler, controllers.Campaign.GetAll).
GET(ROUTE_V1_CAMPAIGN_CALENDAR, middleware.SessionHandler, controllers.Campaign.GetAllWithinDates).
GET(ROUTE_V1_CAMPAIGN_ACTIVE, middleware.SessionHandler, controllers.Campaign.GetAllActive).
GET(ROUTE_V1_CAMPAIGN_UPCOMING, middleware.SessionHandler, controllers.Campaign.GetAllUpcoming).
GET(ROUTE_V1_CAMPAIGN_FINISHED, middleware.SessionHandler, controllers.Campaign.GetAllFinished).
GET(ROUTE_V1_CAMPAIGN_EVENT_NAMES, middleware.SessionHandler, controllers.Campaign.GetAllEventTypes).
GET(ROUTE_V1_CAMPAIGN_EVENTS, middleware.SessionHandler, controllers.Campaign.GetEventsByCampaignID).
GET(ROUTE_V1_CAMPAIGN_STATS, middleware.SessionHandler, controllers.Campaign.GetStats).
GET(ROUTE_V1_CAMPAIGN_RESULT_STATS, middleware.SessionHandler, controllers.Campaign.GetResultStats).
GET(ROUTE_V1_CAMPAIGN_STATS_ID, middleware.SessionHandler, controllers.Campaign.GetCampaignStats).
GET(ROUTE_V1_CAMPAIGN_STATS_ALL, middleware.SessionHandler, controllers.Campaign.GetAllCampaignStats).
GET(ROUTE_V1_CAMPAIGN_ID, middleware.SessionHandler, controllers.Campaign.GetByID).
GET(ROUTE_V1_CAMPAIGN_NAME, middleware.SessionHandler, controllers.Campaign.GetByName).
POST(ROUTE_V1_CAMPAIGN, middleware.SessionHandler, controllers.Campaign.Create).
// TODO PATCH
POST(ROUTE_V1_CAMPAIGN_ID, middleware.SessionHandler, controllers.Campaign.UpdateByID).
POST(ROUTE_V1_CAMPAIGN_CLOSE, middleware.SessionHandler, controllers.Campaign.CloseCampaignByID).
GET(ROUTE_V1_CAMPAIGN_EXPORT_EVENTS, middleware.SessionHandler, controllers.Campaign.ExportEventsAsCSV).
GET(ROUTE_V1_CAMPAIGN_EXPORT_SUBMISSIONS, middleware.SessionHandler, controllers.Campaign.ExportSubmissionsAsCSV).
POST(ROUTE_V1_CAMPAIGN_ANONYMIZE, middleware.SessionHandler, controllers.Campaign.AnonymizeByID).
DELETE(ROUTE_V1_CAMPAIGN_ID, middleware.SessionHandler, controllers.Campaign.DeleteByID).
// campaign-recipient
GET(ROUTE_V1_CAMPAIGN_RECIPIENTS, middleware.SessionHandler, controllers.Campaign.GetRecipientsByCampaignID).
GET(ROUTE_V1_CAMPAIGN_RECIPIENT_EMAIL, middleware.SessionHandler, controllers.Campaign.GetCampaignEmail).
GET(ROUTE_V1_CAMPAIGN_RECIPIENT_URL, middleware.SessionHandler, controllers.Campaign.GetCampaignURL).
POST(ROUTE_V1_CAMPAIGN_RECIPIENT_SET_SENT, middleware.SessionHandler, controllers.Campaign.SetSentAtByCampaignRecipientID).
// asset
GET(ROUTE_V1_ASSET_DOMAIN_VIEW, middleware.SessionHandler, controllers.Asset.GetContentByID).
GET(ROUTE_V1_ASSET_ID, middleware.SessionHandler, controllers.Asset.GetByID).
PATCH(ROUTE_V1_ASSET_ID, middleware.SessionHandler, controllers.Asset.UpdateByID).
GET(ROUTE_V1_ASSET_DOMAIN_CONTEXT, middleware.SessionHandler, controllers.Asset.GetAllForContext).
GET(ROUTE_V1_ASSET_GLOBAL_CONTEXT, middleware.SessionHandler, controllers.Asset.GetAllForContext).
POST(ROUTE_V1_ASSET, middleware.SessionHandler, controllers.Asset.Create).
DELETE(ROUTE_V1_ASSET_ID, middleware.SessionHandler, controllers.Asset.RemoveByID).
// attachments
POST(ROUTE_V1_ATTACHMENT, middleware.SessionHandler, controllers.Attachment.Create).
GET(ROUTE_V1_ATTACHMENT_ID, middleware.SessionHandler, controllers.Attachment.GetByID).
GET(ROUTE_V1_ATTACHMENT_ID_CONTENT, middleware.SessionHandler, controllers.Attachment.GetContentByID).
GET(ROUTE_V1_ATTACHMENT, middleware.SessionHandler, controllers.Attachment.GetAllForContext).
PATCH(ROUTE_V1_ATTACHMENT_ID, middleware.SessionHandler, controllers.Attachment.UpdateByID).
DELETE(ROUTE_V1_ATTACHMENT_ID, middleware.SessionHandler, controllers.Attachment.RemoveByID).
// api sender
GET(ROUTE_V1_API_SENDER, middleware.SessionHandler, controllers.APISender.GetAll).
GET(ROUTE_V1_API_SENDER_OVERVIEW, middleware.SessionHandler, controllers.APISender.GetAllOverview).
GET(ROUTE_V1_API_SENDER_ID, middleware.SessionHandler, controllers.APISender.GetByID).
POST(ROUTE_V1_API_SENDER, middleware.SessionHandler, controllers.APISender.Create).
PATCH(ROUTE_V1_API_SENDER_ID, middleware.SessionHandler, controllers.APISender.UpdateByID).
POST(ROUTE_V1_API_SENDER_ID_TEST, middleware.SessionHandler, controllers.APISender.SendTest).
DELETE(ROUTE_V1_API_SENDER_ID, middleware.SessionHandler, controllers.APISender.DeleteByID).
// allow deny
GET(ROUTE_V1_ALLOW_DENY, middleware.SessionHandler, controllers.AllowDeny.GetAll).
GET(ROUTE_V1_ALLOW_DENY_OVERVIEW, middleware.SessionHandler, controllers.AllowDeny.GetAllOverview).
GET(ROUTE_V1_ALLOW_DENY_ID, middleware.SessionHandler, controllers.AllowDeny.GetByID).
POST(ROUTE_V1_ALLOW_DENY, middleware.SessionHandler, controllers.AllowDeny.Create).
PATCH(ROUTE_V1_ALLOW_DENY_ID, middleware.SessionHandler, controllers.AllowDeny.UpdateByID).
DELETE(ROUTE_V1_ALLOW_DENY_ID, middleware.SessionHandler, controllers.AllowDeny.DeleteByID).
// web hooks
GET(ROUTE_V1_WEBHOOK, middleware.SessionHandler, controllers.Webhook.GetAll).
GET(ROUTE_V1_WEBHOOK_ID, middleware.SessionHandler, controllers.Webhook.GetByID).
POST(ROUTE_V1_WEBHOOK, middleware.SessionHandler, controllers.Webhook.Create).
PATCH(ROUTE_V1_WEBHOOK_ID, middleware.SessionHandler, controllers.Webhook.UpdateByID).
DELETE(ROUTE_V1_WEBHOOK_ID, middleware.SessionHandler, controllers.Webhook.DeleteByID).
POST(ROUTE_V1_WEBHOOK_ID_TEST, middleware.SessionHandler, controllers.Webhook.SendTest).
// identifiers
GET(ROUTE_V1_IDENTIFIER, middleware.SessionHandler, controllers.Identifier.GetAll).
// version
GET(ROUTE_V1_VERSION, middleware.SessionHandler, controllers.Version.Get).
// update
GET(ROUTE_V1_UPDATE, middleware.SessionHandler, controllers.Update.GetUpdateDetails).
POST(ROUTE_V1_UPDATE, middleware.SessionHandler, controllers.Update.RunUpdate).
// import
POST(ROUTE_V1_IMPORT, middleware.SessionHandler, controllers.Import.Import)
return r
}
func (a *administrationServer) handleTLSCertificate(
conf *config.Config,
) error {
publicCertExists := true
privateCertExists := true
if _, err := os.Stat(conf.TLSCertPath()); err != nil {
if !os.IsNotExist(err) {
return err
}
privateCertExists = false
}
if _, err := os.Stat(conf.TLSKeyPath()); err != nil {
if !os.IsNotExist(err) {
return err
}
publicCertExists = false
}
// determine hostnames to include in the certificate
hostnames := []string{}
if h := conf.TLSHost(); len(h) > 0 {
hostnames = append(hostnames, h)
}
// get the address from config
if conf.AdminNetAddress() != "" {
host, _, err := net.SplitHostPort(conf.AdminNetAddress())
if err == nil && host != "" && host != "0.0.0.0" && host != "::" {
hostnames = append(hostnames, host)
}
}
// try to get all non-loopback IP addresses
addrs, err := net.InterfaceAddrs()
if err == nil {
for _, addr := range addrs {
if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
ip := ipnet.IP
// skip private IPs (RFC 1918)
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
continue
}
// only add public IPs to the certificate
hostnames = append(hostnames, ip.String())
}
}
}
needToCreateCert := !privateCertExists || !publicCertExists
// check if we need to recreate the certificate because host/IP has changed
if privateCertExists && publicCertExists {
// read the existing certificate to check the hostnames
certData, err := os.ReadFile(conf.TLSCertPath())
if err == nil {
block, _ := pem.Decode(certData)
if block != nil && block.Type == "CERTIFICATE" {
cert, err := x509.ParseCertificate(block.Bytes)
if err == nil {
// vheck if all requested hostnames are in the certificate
missingHosts := false
hostMap := make(map[string]bool)
// add all current certificate SANs to the map
for _, dnsName := range cert.DNSNames {
hostMap[dnsName] = true
}
for _, ip := range cert.IPAddresses {
hostMap[ip.String()] = true
}
// check if the common name is in our hostnames
if cert.Subject.CommonName != "" {
hostMap[cert.Subject.CommonName] = true
}
// Check if all requested hostnames are covered
for _, host := range hostnames {
if !hostMap[host] {
missingHosts = true
a.logger.Debugw("host not found in existing certificate", "host", host)
break
}
}
// if the TLSHost is specified and not in the certificate, or other hosts are missing, regenerate
if missingHosts {
a.logger.Debug("recreating certificate due to changed host/IP configuration")
needToCreateCert = true
}
} else {
a.logger.Warnw("could not parse existing certificate, will recreate", "error", err)
needToCreateCert = true
}
} else {
a.logger.Warn("invalid certificate format, will recreate")
needToCreateCert = true
}
} else {
a.logger.Warnw("could not read existing certificate, will recreate", "error", err)
needToCreateCert = true
}
}
// create certificates if needed
if needToCreateCert {
a.logger.Debug("creating self signed certificate for administration server")
info := acme.NewInformationWithDefault()
if len(hostnames) > 0 {
info.CommonName = hostnames[0]
}
a.logger.Debugw("generating certificate with hostnames", "hostnames", hostnames)
err = acme.CreateSelfSignedCert(
a.logger,
info,
hostnames,
conf.TLSCertPath(),
conf.TLSKeyPath(),
)
if err != nil {
return fmt.Errorf("failed to create self signed certificate: %s", err)
}
a.logger.Debugw(
"saved self signed certificate for administration servers",
"TLS certificate", conf.TLSCertPath(),
"TLS key path", conf.TLSKeyPath(),
)
} else {
a.logger.Debug("using existing certificate for administration server")
}
return nil
}
// LoadFrontend loads the frontend
// if this is a production build, the fronten will be embedded
// else the routes will be setup to load the frontend resources on every request
func (a *administrationServer) LoadFrontend(
ln net.Listener,
) error {
if build.Flags.Production {
return a.loadEmbeddedFileSystem(
ln,
)
}
return a.loadPerRequestLoading()
}
// loadPerRequestLoading loads the frontend resources on every request
// this is only used in a dev enviroment using nodemon as is a
// backup if the current vite proxy stragegy does not work.
func (a *administrationServer) loadPerRequestLoading() error {
a.router.GET("/", func(c *gin.Context) {
c.File("./frontend/website/build/index.html")
})
// perform manual lookup for the frontend files on each request
// build files might have been added or removed, so each request must
// do a check if the file exists
a.router.NoRoute(func(c *gin.Context) {
// a.logger.Infow("serving frontend file", "path", c.Request.URL.Path)
// check if the request url path exists in the root directory
if _, err := os.Stat("./frontend/website/build" + c.Request.URL.Path); err == nil {
c.File("./frontend/website/build" + c.Request.URL.Path)
return
}
// if the path ends with / or does not have a file extension, then it should fallback to index.html as
// it is a SPA path such as /company/foo/
if c.Request.URL.Path[len(c.Request.URL.Path)-1:] == "/" || !strings.Contains(c.Request.URL.Path, ".") {
c.File("./frontend/website/build/index.html")
}
// file not found - return 404
c.AbortWithStatus(http.StatusNotFound)
})
return nil
}
func (a *administrationServer) loadEmbeddedFileSystem(
ln net.Listener,
) error {
_ = ln
embedFS := frontend.GetEmbededFS()
// make embedded .html work
frontend.LoadHTMLFromEmbedFS(a.router, *embedFS, "build/*.html")
rootDir, err := embedFS.ReadDir("build")
if err != nil {
return errs.Wrap(err)
}
for _, entry := range rootDir {
path := entry.Name()
// add root files
if !entry.IsDir() {
// special case for the frontpage
if path == "index.html" {
a.router.GET("/", func(c *gin.Context) {
c.HTML(http.StatusOK, "build/index.html", nil)
})
continue
}
// any file in the root folder gets server as a file
a.router.GET("/"+path, func(c *gin.Context) {
c.FileFromFS("build/"+path, http.FS(*embedFS))
})
continue
}
// add static folders
staticFS, err := fs.Sub(embedFS, "build/"+path)
if err != nil {
return errs.Wrap(err)
}
switch path {
case ".well-known":
fallthrough
case "_app":
a.router.StaticFS(path, http.FS(staticFS))
}
}
// fall back to the root index.html
a.router.NoRoute(func(c *gin.Context) {
c.HTML(http.StatusOK, "build/index.html", nil)
})
return nil
}
func (a *administrationServer) StartServer(
conf *config.Config,
) (chan server.StartupMessage, net.Listener, error) {
startupMessage := server.NewStartupMessageChannel()
ln, err := net.Listen("tcp", conf.AdminNetAddress())
if err != nil {
return nil, nil, fmt.Errorf("failed to listen on %s due to: %s", conf.AdminNetAddress(), err)
}
err = a.LoadFrontend(ln)
if err != nil {
return nil, nil, errs.Wrap(err)
}
err = a.handleTLSCertificate(conf)
if err != nil {
return nil, nil, errs.Wrap(err)
}
a.Server = &http.Server{
Handler: a.router,
// The maximum duration for reading the entire request, including the request line, headers, and body
ReadTimeout: 15 * time.Second,
// The maximum duration for writing the entire response, including the response headers and body
WriteTimeout: 15 * time.Second, // Timeout for writing the response
// The maximum duration to wait for the next request when the connection is in the idle state
IdleTimeout: 10 * time.Second,
// The maximum duration for reading the request headers.
ReadHeaderTimeout: 2 * time.Second,
// Maximum size of request headers (512 KB)
MaxHeaderBytes: 1 << 19,
}
a.Server.ErrorLog = log.New(
&SkipFirstTlsToZapWriter{
logger: a.logger,
serverPtr: a.Server,
}, "", 0,
)
a.logger.Debugw("TLS settings",
"certPath", conf.TLSCertPath(),
"certKeyPath", conf.TLSKeyPath(),
)
// start the administration server
adminHost := "admin.test"
err = a.certMagicConfig.ManageSync(context.Background(), []string{adminHost})
if err != nil {
a.logger.Errorw("certmagic managesync failed", "error", err)
return nil, nil, errs.Wrap(err)
}
go func() {
if !conf.TLSAuto() {
a.logger.Debugw("starting administration",
"address", ln.Addr().String(),
)
err := a.Server.ServeTLS(
ln,
conf.TLSCertPath(),
conf.TLSKeyPath(),
)
if err != nil && err != http.ErrServerClosed {
log.Fatalf("failed to start administration server due to: %s", err)
}
} else {
// Setup TLS config from CertMagic
tlsConfig := a.certMagicConfig.TLSConfig()
tlsConfig.NextProtos = append([]string{"h2", "http/1.1"}, tlsConfig.NextProtos...)
// Create new TLS listener with the config
tlsLn := tls.NewListener(ln, tlsConfig)
a.logger.Debugw("starting administration with automatic TLS",
"address", ln.Addr().String(),
"domain", adminHost,
)
err := a.Server.Serve(tlsLn)
if err != nil && err != http.ErrServerClosed {
log.Fatalf("failed to start administration server due to: %s", err)
}
}
}()
// test the connection to the administration server
// and send a startup message
// TODO the connectivity check has been disabled as it fucks up the auto tls
// as it calls the certmagic DecisionFunc from addreses such as ::1 and I am not
// sure we it is safe to allow list all of them or if I know all of the potential addresses.
/*
go func() {
a.logger.Debug("testing connectivity to administration server...")
// wait for connection to the server
attempts := 1
for {
dialer := &net.Dialer{
Timeout: time.Second,
KeepAlive: time.Second,
}
conn, err := tls.DialWithDialer(
dialer,
"tcp",
ln.Addr().String(),
&tls.Config{
InsecureSkipVerify: true,
},
)
if err != nil {
a.logger.Debugw("failed to connect to administration server",
"attempt", attempts,
)
time.Sleep(1 * time.Second)
if attempts == 3 {
startupMessage <- server.NewStartupMessage(
false,
fmt.Errorf("failed to connect to administration server"),
)
break
}
attempts += 1
continue
}
conn.Close()
startupMessage <- server.NewStartupMessage(true, nil)
break
}
}()
*/
startupMessage <- server.NewStartupMessage(true, nil)
return startupMessage, ln, nil
}
// https://stackoverflow.com/questions/52294334/net-http-set-custom-logger
type fwdToZapWriter struct {
logger *zap.SugaredLogger
}
func (fw *fwdToZapWriter) Write(p []byte) (n int, err error) {
fw.logger.Errorw(string(p))
return len(p), nil
}
// SkipFirstTlsToZapWriter is a weird Writer that replaces itself
// when it has seen a TLS handshake error it is used for handling
// a special annoying case where a health check on startup creates
// a tls handshake that we want to ignore
type SkipFirstTlsToZapWriter struct {
logger *zap.SugaredLogger
// ignore first tls
serverPtr *http.Server
}
func (fw *SkipFirstTlsToZapWriter) Write(p []byte) (n int, err error) {
if strings.Contains(string(p), "TLS handshake error") {
// After catching the first TLS error, replace the ErrorLog with direct logger
fw.serverPtr.ErrorLog = log.New(
&fwdToZapWriter{
logger: fw.logger,
},
"",
0,
)
return len(p), nil
}
fw.logger.Errorw(string(p))
return len(p), nil
}
+41
View File
@@ -0,0 +1,41 @@
package app
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"github.com/go-errors/errors"
"github.com/phishingclub/phishingclub/config"
"github.com/phishingclub/phishingclub/errs"
)
// SetupConfig sets up the config
func SetupConfig(
enviroment string,
configFilePath string,
) (*config.Config, error) {
configFolder, configFile := filepath.Split(configFilePath)
filesystem := os.DirFS(configFolder)
configDTO, err := config.NewDTOFromFile(filesystem, configFile)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return nil, errs.Wrap(err)
}
if errors.Is(err, fs.ErrNotExist) {
fmt.Printf(" * No config loaded. Creating default config file at %s\n\n", configFilePath)
var conf *config.Config
if enviroment == MODE_DEVELOPMENT {
conf = config.NewDevDefaultConfig()
} else {
conf = config.NewProductionDefaultConfig()
}
err = conf.WriteToFile(configFilePath)
configDTO = conf.ToDTO()
if err != nil {
return nil, errs.Wrap(err)
}
}
return config.FromDTO(configDTO)
}
+200
View File
@@ -0,0 +1,200 @@
package app
import (
"github.com/phishingclub/phishingclub/controller"
"go.uber.org/zap"
"gorm.io/gorm"
)
// Controllers is a collection of controllers
type Controllers struct {
Asset *controller.Asset
Attachment *controller.Attachment
Company *controller.Company
Health *controller.Health
Installer *controller.Install
InitialSetup *controller.InitialSetup
Page *controller.Page
Log *controller.Log
Option *controller.Option
User *controller.User
Domain *controller.Domain
Recipient *controller.Recipient
RecipientGroup *controller.RecipientGroup
SMTPConfiguration *controller.SMTPConfiguration
Email *controller.Email
CampaignTemplate *controller.CampaignTemplate
Campaign *controller.Campaign
QR *controller.QRGenerator
APISender *controller.APISender
AllowDeny *controller.AllowDeny
Webhook *controller.Webhook
Identifier *controller.Identifier
Version *controller.Version
SSO *controller.SSO
Update *controller.Update
Import *controller.Import
}
// NewControllers creates a collection of controllers
func NewControllers(
staticAssetPath string,
attachmentsPath string,
repositories *Repositories,
services *Services,
logger *zap.SugaredLogger,
atomLogger *zap.AtomicLevel,
utillities *Utilities,
db *gorm.DB,
) *Controllers {
common := controller.Common{
SessionService: services.Session,
Logger: logger,
Response: utillities.JSONResponseHandler,
}
asset := &controller.Asset{
Common: common,
StaticAssetPath: staticAssetPath,
AssetService: services.Asset,
OptionService: services.Option,
DomainService: services.Domain,
}
attachment := &controller.Attachment{
Common: common,
StaticAttachmentPath: attachmentsPath,
AttachmentService: services.Attachment,
OptionService: services.Option,
TemplateService: services.Template,
CompanyService: services.Company,
}
company := &controller.Company{
Common: common,
CampaignService: services.Campaign,
CompanyService: services.Company,
RecipientService: services.Recipient,
}
initialSetup := &controller.InitialSetup{
Common: common,
CLIOutputter: utillities.CLIOutputter,
OptionRepository: repositories.Option,
InstallService: services.InstallSetup,
OptionService: services.Option,
}
installer := &controller.Install{
Common: common,
UserRepository: repositories.User,
CompanyRepository: repositories.Company,
OptionRepository: repositories.Option,
PasswordHasher: *utillities.PasswordHasher,
DB: db,
}
health := &controller.Health{}
log := &controller.Log{
Common: common,
OptionService: services.Option,
Database: db,
LoggerAtom: atomLogger,
}
page := &controller.Page{
Common: common,
PageService: services.Page,
TemplateService: services.Template,
}
option := &controller.Option{
Common: common,
OptionService: services.Option,
}
user := &controller.User{
Common: common,
UserService: services.User,
}
domain := &controller.Domain{
Common: common,
DomainService: services.Domain,
}
recipient := &controller.Recipient{
Common: common,
RecipientService: services.Recipient,
}
recipientGroup := &controller.RecipientGroup{
Common: common,
RecipientGroupService: services.RecipientGroup,
}
smtpConfiguration := &controller.SMTPConfiguration{
Common: common,
SMTPConfigurationService: services.SMTPConfiguration,
}
email := &controller.Email{
Common: common,
EmailService: services.Email,
TemplateService: services.Template,
EmailRepository: repositories.Email,
}
campaignTemplate := &controller.CampaignTemplate{
Common: common,
CampaignTemplateService: services.CampaignTemplate,
}
campaign := &controller.Campaign{
Common: common,
CampaignService: services.Campaign,
}
qr := &controller.QRGenerator{
Common: common,
}
apiSender := &controller.APISender{
Common: common,
APISenderService: services.APISender,
}
allowDeny := &controller.AllowDeny{
Common: common,
AllowDenyService: services.AllowDeny,
}
webhook := &controller.Webhook{
Common: common,
WebhookService: services.Webhook,
}
identifier := &controller.Identifier{
Common: common,
IdentifierService: services.Identifier,
}
version := &controller.Version{Common: common}
sso := &controller.SSO{Common: common, SSO: services.SSO}
update := &controller.Update{
Common: common,
UpdateService: services.Update,
OptionService: services.Option,
}
importController := &controller.Import{
Common: common,
ImportService: services.Import,
}
return &Controllers{
Asset: asset,
Attachment: attachment,
Company: company,
Installer: installer,
InitialSetup: initialSetup,
Health: health,
Page: page,
Log: log,
Option: option,
User: user,
Domain: domain,
Recipient: recipient,
RecipientGroup: recipientGroup,
SMTPConfiguration: smtpConfiguration,
Email: email,
CampaignTemplate: campaignTemplate,
Campaign: campaign,
QR: qr,
APISender: apiSender,
AllowDeny: allowDeny,
Webhook: webhook,
Identifier: identifier,
Version: version,
SSO: sso,
Update: update,
Import: importController,
}
}
+16
View File
@@ -0,0 +1,16 @@
package app
import (
"github.com/phishingclub/phishingclub/config"
"github.com/phishingclub/phishingclub/database"
"gorm.io/gorm"
)
// SetupDatabase sets up the database
// this includes creating the database connection
func SetupDatabase(
conf *config.Config,
) (*gorm.DB, error) {
// create db connection
return database.FromConfig(*conf)
}
+76
View File
@@ -0,0 +1,76 @@
package app
import (
"github.com/go-errors/errors"
"github.com/phishingclub/phishingclub/config"
"github.com/phishingclub/phishingclub/log"
"github.com/phishingclub/phishingclub/version"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
const (
MODE_INTEGRATION_TEST = "integration_test"
MODE_DEVELOPMENT = "development"
MODE_PRODUCTION = "production"
)
func createCore(core zapcore.Core) zapcore.Core {
return &stackCore{core}
}
type stackCore struct {
zapcore.Core
}
func (c *stackCore) Check(ent zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry {
// dont add our core again if it's already been added
if ce != nil {
return ce
}
return ce.AddCore(ent, c)
}
func (c *stackCore) Write(ent zapcore.Entry, fields []zapcore.Field) error {
// return c.Core.Write(ent, fields)
// look for error field and enhance the message with stack trace
for _, field := range fields {
if field.Key == "error" {
if err, ok := field.Interface.(error); ok {
if goErr, ok := err.(*errors.Error); ok {
ent.Stack = goErr.ErrorStack()
}
}
}
}
return c.Core.Write(ent, fields)
}
func SetupLogger(loggerType string, conf *config.Config) (*zap.SugaredLogger, *zap.AtomicLevel, error) {
var logger *zap.Logger
var loggerAtom *zap.AtomicLevel
var err error
switch loggerType {
case MODE_DEVELOPMENT:
logger, loggerAtom, err = log.NewDevelopmentLogger(conf)
case MODE_INTEGRATION_TEST:
fallthrough
case MODE_PRODUCTION:
fallthrough
default:
logger, loggerAtom, err = log.NewProductionLogger(conf)
}
if err != nil {
return nil, nil, err
}
// Create new logger with custom core
logger = zap.New(createCore(logger.Core()))
sgr := logger.Sugar()
if loggerType == MODE_PRODUCTION {
sgr = sgr.With("v-debug", version.Get())
}
return sgr, loggerAtom, nil
}
+43
View File
@@ -0,0 +1,43 @@
package app
import (
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/config"
"github.com/phishingclub/phishingclub/middleware"
"go.uber.org/zap"
)
// Middlwares is a collection of middlewares
type Middlewares struct {
IPLimiter gin.HandlerFunc
LoginRateLimiter gin.HandlerFunc
SessionHandler gin.HandlerFunc
}
// NewMiddlewares creates a collection of middlewares
func NewMiddlewares(
requestPerSecond float64,
requestBurst int,
conf *config.Config,
services *Services,
utils *Utilities,
logger *zap.SugaredLogger,
) *Middlewares {
ipLimiter := middleware.NewAllowIPMiddleware(conf, logger)
loginThrottle := middleware.NewIPRateLimiterMiddleware(
requestPerSecond, // requests per second
requestBurst, // burst
)
sessionHandler := middleware.NewSessionHandler(
services.Session,
services.User,
utils.JSONResponseHandler,
logger,
)
return &Middlewares{
IPLimiter: ipLimiter,
LoginRateLimiter: loginThrottle,
SessionHandler: sessionHandler,
}
}
+59
View File
@@ -0,0 +1,59 @@
package app
import (
"github.com/phishingclub/phishingclub/repository"
"gorm.io/gorm"
)
// Repositories is a collection of repositories
type Repositories struct {
Asset *repository.Asset
Attachment *repository.Attachment
Company *repository.Company
Option *repository.Option
Page *repository.Page
Role *repository.Role
Session *repository.Session
User *repository.User
Domain *repository.Domain
Recipient *repository.Recipient
RecipientGroup *repository.RecipientGroup
SMTPConfiguration *repository.SMTPConfiguration
Email *repository.Email
Campaign *repository.Campaign
CampaignRecipient *repository.CampaignRecipient
CampaignTemplate *repository.CampaignTemplate
APISender *repository.APISender
AllowDeny *repository.AllowDeny
Webhook *repository.Webhook
Identifier *repository.Identifier
}
// NewRepositories creates a collection of repositories
func NewRepositories(
db *gorm.DB,
) *Repositories {
option := &repository.Option{DB: db}
return &Repositories{
Asset: &repository.Asset{DB: db},
Attachment: &repository.Attachment{DB: db},
Company: &repository.Company{DB: db},
Option: option,
Page: &repository.Page{DB: db},
Role: &repository.Role{DB: db},
Session: &repository.Session{DB: db},
User: &repository.User{DB: db},
Domain: &repository.Domain{DB: db},
Recipient: &repository.Recipient{DB: db, OptionRepository: option},
RecipientGroup: &repository.RecipientGroup{DB: db},
SMTPConfiguration: &repository.SMTPConfiguration{DB: db},
Email: &repository.Email{DB: db},
Campaign: &repository.Campaign{DB: db},
CampaignRecipient: &repository.CampaignRecipient{DB: db},
CampaignTemplate: &repository.CampaignTemplate{DB: db},
APISender: &repository.APISender{DB: db},
AllowDeny: &repository.AllowDeny{DB: db},
Webhook: &repository.Webhook{DB: db},
Identifier: &repository.Identifier{DB: db},
}
}
File diff suppressed because it is too large Load Diff
+254
View File
@@ -0,0 +1,254 @@
package app
import (
"github.com/caddyserver/certmagic"
"github.com/phishingclub/phishingclub/service"
"go.uber.org/zap"
"gorm.io/gorm"
)
// Services is a collection of services
type Services struct {
Asset *service.Asset
Attachment *service.Attachment
File *service.File
Company *service.Company
InstallSetup *service.InstallSetup
Option *service.Option
Page *service.Page
Session *service.Session
User *service.User
Domain *service.Domain
Recipient *service.Recipient
RecipientGroup *service.RecipientGroup
SMTPConfiguration *service.SMTPConfiguration
Email *service.Email
CampaignTemplate *service.CampaignTemplate
Campaign *service.Campaign
Template *service.Template
APISender *service.APISender
AllowDeny *service.AllowDeny
Webhook *service.Webhook
Identifier *service.Identifier
Version *service.Version
SSO *service.SSO
Update *service.Update
Import *service.Import
}
// NewServices creates a collection of services
func NewServices(
db *gorm.DB,
repositories *Repositories,
logger *zap.SugaredLogger,
utilities *Utilities,
assetPath string,
attachmentPath string,
ownManagedCertificatePath string,
enviroment string,
certMagicConfig *certmagic.Config,
certMagicCache *certmagic.Cache,
licenseServerURL string,
) *Services {
common := service.Common{
Logger: logger,
}
templateService := &service.Template{
Common: common,
}
file := &service.File{
Common: common,
}
asset := &service.Asset{
Common: common,
RootFolder: assetPath,
FileService: file,
AssetRepository: repositories.Asset,
DomainRepository: repositories.Domain,
}
attachment := &service.Attachment{
Common: common,
RootFolder: attachmentPath,
FileService: file,
AttachmentRepository: repositories.Attachment,
EmailRepository: repositories.Email,
}
installSetup := &service.InstallSetup{
Common: common,
UserRepository: repositories.User,
RoleRepository: repositories.Role,
CompanyRepository: repositories.Company,
PasswordHasher: utilities.PasswordHasher,
}
sessionService := &service.Session{
Common: common,
SessionRepository: repositories.Session,
}
optionService := &service.Option{
Common: common,
OptionRepository: repositories.Option,
}
userService := &service.User{
Common: common,
UserRepository: repositories.User,
RoleRepository: repositories.Role,
CompanyRepository: repositories.Company,
PasswordHasher: utilities.PasswordHasher,
}
recipient := &service.Recipient{
Common: common,
RecipientRepository: repositories.Recipient,
RecipientGroupRepository: repositories.RecipientGroup,
CampaignRepository: repositories.Campaign,
CampaignRecipientRepository: repositories.CampaignRecipient,
}
recipientGroup := &service.RecipientGroup{
Common: common,
CampaignRepository: repositories.Campaign,
CampaignRecipientRepository: repositories.CampaignRecipient,
RecipientGroupRepository: repositories.RecipientGroup,
RecipientRepository: repositories.Recipient,
RecipientService: recipient,
DB: db,
}
webhook := &service.Webhook{
Common: common,
CampaignRepository: repositories.Campaign,
WebhookRepository: repositories.Webhook,
}
campaignTemplate := &service.CampaignTemplate{
Common: common,
CampaignTemplateRepository: repositories.CampaignTemplate,
CampaignRepository: repositories.Campaign,
IdentifierRepository: repositories.Identifier,
}
apiSender := &service.APISender{
Common: common,
APISenderRepository: repositories.APISender,
TemplateService: templateService,
CampaignTemplateService: campaignTemplate,
}
smtpConfiguration := &service.SMTPConfiguration{
Common: common,
SMTPConfigurationRepository: repositories.SMTPConfiguration,
CampaignTemplateService: campaignTemplate,
}
page := &service.Page{
Common: common,
CampaignRepository: repositories.Campaign,
PageRepository: repositories.Page,
CampaignTemplateService: campaignTemplate,
}
domain := &service.Domain{
Common: common,
OwnManagedCertificatePath: ownManagedCertificatePath,
CertMagicConfig: certMagicConfig,
CertMagicCache: certMagicCache,
DomainRepository: repositories.Domain,
CompanyRepository: repositories.Company,
CampaignTemplateService: campaignTemplate,
AssetService: asset,
FileService: file,
}
email := &service.Email{
Common: common,
AttachmentPath: attachmentPath,
AttachmentService: attachment,
DomainService: domain,
EmailRepository: repositories.Email,
SMTPService: smtpConfiguration,
RecipientService: recipient,
TemplateService: templateService,
}
campaign := &service.Campaign{
Common: common,
CampaignRepository: repositories.Campaign,
CampaignRecipientRepository: repositories.CampaignRecipient,
RecipientRepository: repositories.Recipient,
RecipientGroupRepository: repositories.RecipientGroup,
AllowDenyRepository: repositories.AllowDeny,
WebhookRepository: repositories.Webhook,
CampaignTemplateService: campaignTemplate,
DomainService: domain,
RecipientService: recipient,
MailService: email,
APISenderService: apiSender,
SMTPConfigService: smtpConfiguration,
WebhookService: webhook,
TemplateService: templateService,
AttachmentPath: attachmentPath,
}
allowDeny := &service.AllowDeny{
Common: common,
AllowDenyRepository: repositories.AllowDeny,
CampaignRepository: repositories.Campaign,
}
identifier := &service.Identifier{
Common: common,
IdentifierRepository: repositories.Identifier,
}
companyService := &service.Company{
Common: common,
DomainService: domain,
PageService: page,
EmailService: email,
SMTPConfigurationService: smtpConfiguration,
APISenderService: apiSender,
RecipientService: recipient,
RecipientGroupService: recipientGroup,
CampaignService: campaign,
CampaignTemplate: campaignTemplate,
AllowDenyService: allowDeny,
WebhookService: webhook,
CompanyRepository: repositories.Company,
}
versionService := &service.Version{Common: common}
ssoService := &service.SSO{
Common: common,
OptionsService: optionService,
UserService: userService,
SessionService: sessionService,
// MSALClient: msalClient, this dependency is set AFTER this function
}
updateService := &service.Update{
Common: common,
OptionService: optionService,
}
importService := &service.Import{
Common: common,
Asset: asset,
Page: page,
Email: email,
File: file,
EmailRepository: repositories.Email,
PageRepository: repositories.Page,
}
return &Services{
Asset: asset,
Attachment: attachment,
Company: companyService,
File: file,
InstallSetup: installSetup,
Option: optionService,
Page: page,
Session: sessionService,
User: userService,
Domain: domain,
Recipient: recipient,
RecipientGroup: recipientGroup,
SMTPConfiguration: smtpConfiguration,
Email: email,
Template: templateService,
CampaignTemplate: campaignTemplate,
Campaign: campaign,
APISender: apiSender,
AllowDeny: allowDeny,
Webhook: webhook,
Identifier: identifier,
Version: versionService,
SSO: ssoService,
Update: updateService,
Import: importService,
}
}
+23
View File
@@ -0,0 +1,23 @@
package app
import (
"github.com/phishingclub/phishingclub/api"
"github.com/phishingclub/phishingclub/cli"
"github.com/phishingclub/phishingclub/password"
)
// Utilities is a collection of utils
type Utilities struct {
CLIOutputter cli.Outputter
PasswordHasher *password.Argon2Hasher
JSONResponseHandler api.JSONResponseHandler
}
// NewUtils creates a collection of utils
func NewUtils() *Utilities {
return &Utilities{
CLIOutputter: cli.NewCLIOutputter(),
PasswordHasher: password.NewHasherWithDefaultValues(),
JSONResponseHandler: api.NewJSONResponseHandler(),
}
}
+10
View File
@@ -0,0 +1,10 @@
package build
type flags struct {
Production bool
}
// Flags is a global variable for build flags
var Flags = flags{
Production: false,
}
+7
View File
@@ -0,0 +1,7 @@
//go:build production
package build
func init() {
Flags.Production = true
}
+32
View File
@@ -0,0 +1,32 @@
#!/bin/sh
echo "### Building frontend"
# remove any old builds
rm -rf phishingclub/frontend/frontend/build
mkdir -p phishingclub/frontend/frontend/build
sudo docker run --rm \
-v "$(pwd)":/app \
-w /app/phishingclub/frontend \
node:alpine \
sh -c "npm ci && npm run build-production"
# Get current user and group IDs
USER_ID=$(id -u)
GROUP_ID=$(id -g)
sudo chown -R $USER_ID:$GROUP_ID phishingclub/frontend/build
sudo mv phishingclub/frontend/build ./phishingclub/frontend/frontend/
echo "### Building backend"
HASH=$(git rev-parse --short HEAD)
echo "Building with hash: $HASH"
echo "building..."
sudo docker run --rm \
-v "$(pwd)":/app \
-w /app/phishingclub/frontend \
golang:alpine \
go build -trimpath \
-ldflags="-X github.com/phishingclub/phishingclub/version.hash=ph$HASH" \
-tags production -o ../build/phishingclub main.go
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
HASH=$(git rev-parse --short HEAD)
echo "Building backend with hash: $HASH"
sudo docker run --rm \
-v "$(pwd)":/app \
-w /app/phishingclub/frontend \
golang \
go build -trimpath \
-ldflags="-X github.com/phishingclub/phishingclub/version.hash=ph$HASH" \
-tags production -o ../build/phishingclub main.go
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
echo "### Building frontend"
sudo docker run --rm \
-v "$(pwd)":/app \
-w /app/phishingclub/frontend \
node:alpine \
sh -c "npm ci && npm run build-production"
+66
View File
@@ -0,0 +1,66 @@
#!/bin/bash
set -e
# Get the current version from the VERSION file
VERSION=$(cat phishingclub/frontend/version/VERSION | tr -d '\n\r ')
# Check if version is valid
if [[ ! $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: Invalid version format. Expected semver format (e.g., 0.9.0)"
exit 1
fi
# Get current git hash
GIT_HASH=$(git rev-parse --short HEAD)
# Create build directory
mkdir -p build
# Prompt for confirmation
echo "Ready to build and tag release v$VERSION ($GIT_HASH)"
read -p "Continue? (y/n): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Operation cancelled"
exit 1
fi
# Build frontend
echo "Building frontend..."
# remove any old builds
rm -rf phishingclub/frontend/frontend/build
mkdir -p phishingclub/frontend/frontend/build
sudo docker run --rm \
-v "$(pwd)":/app \
-w /app/phishingclub/frontend \
node:alpine \
sh -c "npm ci && npm run build-production"
# Get current user and group IDs
USER_ID=$(id -u)
GROUP_ID=$(id -g)
sudo chown -R $USER_ID:$GROUP_ID phishingclub/frontend/build
mv phishingclub/frontend/build ./phishingclub/frontend/frontend/
# Build the application
echo "Building application..."
sudo docker run --rm \
-v "$(pwd)":/app \
-w /app/phishingclub/frontend \
golang:alpine \
go build -trimpath \
-ldflags="-X github.com/phishingclub/phishingclub/version.hash=ph$GIT_HASH" \
-tags production -o ../build/phishingclub_${VERSION} main.go
echo "Build completed successfully: build/phishingclub_${VERSION}"
echo "Build completed successfully!"
echo "Created files:"
ls -lh build/
cd ..
echo "Release tagged as v$VERSION"
+31
View File
@@ -0,0 +1,31 @@
#!/bin/bash
# Exit on any error
set -e
echo "Generating licenses..."
# Create temp directory if it doesn't exist
mkdir -p /tmp/licenses
# Generate backend licenses
echo "Generating backend licenses..."
sudo docker compose exec -T backend bash -c "go install github.com/google/go-licenses@latest && \
go-licenses report --ignore github.com/phishingclub/phishingclub --template ./utils/ossTemplate.tpl ./... > /tmp/backend-licenses.md 2> /dev/null"
sudo docker compose cp backend:/tmp/backend-licenses.md /tmp/licenses/
# Generate frontend licenses
echo "Generating frontend licenses..."
sudo docker compose exec -T frontend bash -c "npm run --silent license-report > /tmp/frontend-licenses.json 2>/dev/null"
sudo docker compose cp frontend:/tmp/frontend-licenses.json /tmp/licenses/
# Combine licenses
echo "Combining licenses..."
cat /tmp/licenses/backend-licenses.md > phishingclub/frontend/static/licenses.txt
echo -e "\n\n" >> phishingclub/frontend/static/licenses.txt
cat /tmp/licenses/frontend-licenses.json >> phishingclub/frontend/static/licenses.txt
# Cleanup
rm -rf /tmp/licenses
echo "License file generated at phishingclub/frontend/static/licenses.txt"
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
# generate private key
openssl genrsa -out test.key 2048
# generate self-signed certificate
# generate certificate
openssl req -new -x509 -key test.key -out test.pem -days 365
+80
View File
@@ -0,0 +1,80 @@
package cache
import (
"sync/atomic"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/data"
)
// EventIDByName is a map of event names to event IDs
var EventIDByName = map[string]*uuid.UUID{}
// EventNameByID is a map of event ids to names
// this is not safe before the API is up an running entirely
var EventNameByID = map[string]string{}
var isUpdateAvailable atomic.Bool
func init() {
for _, name := range data.Events {
EventIDByName[name] = nil
}
isUpdateAvailable.Store(false)
}
func SetUpdateAvailable(updateAvailable bool) {
isUpdateAvailable.Store(updateAvailable)
}
func IsUpdateAvailable() bool {
return isUpdateAvailable.Load()
}
// TODO all priority event functions should be in utils or something, and the priority in the data package.
// var CampaignEventPriority = map[]
// Add priority rankings (higher number = higher priority)
// readonly
var CampaignEventPriority = map[string]int{
// campaign recipient events
data.EVENT_CAMPAIGN_RECIPIENT_CANCELLED: 80,
data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA: 70,
data.EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED: 60,
data.EVENT_CAMPAIGN_RECIPIENT_BEFORE_PAGE_VISITED: 40,
data.EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED: 50,
data.EVENT_CAMPAIGN_RECIPIENT_MESSAGE_READ: 30,
data.EVENT_CAMPAIGN_RECIPIENT_MESSAGE_FAILED: 20,
data.EVENT_CAMPAIGN_RECIPIENT_MESSAGE_SENT: 20,
data.EVENT_CAMPAIGN_RECIPIENT_SCHEDULED: 10,
// campaign events
data.EVENT_CAMPAIGN_CLOSED: 30,
data.EVENT_CAMPAIGN_ACTIVE: 20,
data.EVENT_CAMPAIGN_SELF_MANAGED: 20,
data.EVENT_CAMPAIGN_SCHEDULED: 10,
}
// IsMoreNotableCampaignRecipientEvent returns true if newEvent is more notable than currentEvent
func IsMoreNotableCampaignRecipientEvent(newEvent, currentEvent string) bool {
newPriority, newExists := CampaignEventPriority[newEvent]
currentPriority, currentExists := CampaignEventPriority[currentEvent]
// If either event doesn't exist in our priority map, treat it as lowest priority
if !newExists || !currentExists {
return false
}
return newPriority > currentPriority
}
func IsMoreNotableCampaignRecipientEventID(currentID, newID *uuid.UUID) bool {
if currentID == nil || currentID.String() == uuid.Nil.String() {
return true
}
if newID == nil {
return false
}
newEventName := EventNameByID[newID.String()]
currentEventName := EventNameByID[currentID.String()]
return IsMoreNotableCampaignRecipientEvent(newEventName, currentEventName)
}
+17
View File
@@ -0,0 +1,17 @@
package cli
import (
"fmt"
)
// OutputEnv outputs the available environment variables
// These are used for CI or similar enviroment tests
func OutputEnv() {
fmt.Println("Available environment variables:")
fmt.Println("APP_MODE = production, development, integration_test")
fmt.Println("TEST_DB_LOG_LEVEL = silent, debug, error, warn, info")
fmt.Println("HTTP_PROXY - sets outgoing http proxy")
fmt.Println("HTTPS_PROXY - sets outgoing https proxy")
fmt.Println("NO_PROXY - hosts that should not be proxied")
}
+44
View File
@@ -0,0 +1,44 @@
package cli
import (
"fmt"
"github.com/fatih/color"
)
// PrintVersion outputs the version of the application
func PrintVersion(
name,
version string,
) {
fmt.Printf("%s (%s)\n", name, version)
}
// PrintBanner outputs the banner for the application
func PrintBanner() {
blue := color.New(color.FgBlue)
_, _ = blue.Println(`
--:
.@@@@@*-.
.@@@@@@@@++:
.+*=. .@@@@@@@@@@@@*-.
+@@@@++- .+@@@@@@@@@@@@@@#=:
*@@@@@@@@#=. .=#@@@@@@@@@@@@@@@+*-
*@@@@@@@@@@@+- :#@@@@@@@@@@@@@@@@#.
*@@@@@@@@@@@@= +@@@@@@@@@@@@@@@@@=
*@@@@@@@@++: .=#@@@@@@@@@@@@@@@@++:
*@@@@@*=. .+@@@@@@@@@@@@@@@@#=.
.*#+: .@@@@@@@@@@@@@+*-
.@@@@@@@@@@#=.
.@@@@@@+*-
++@#=. `)
_, _ = fmt.Println()
_, _ = fmt.Println()
}
func PrintServerStarted(
name string,
address string,
) {
fmt.Printf("%s available:\nhttps://%s\n\n", name, address)
}
+37
View File
@@ -0,0 +1,37 @@
package cli
import (
"github.com/fatih/color"
)
type Outputter interface {
PrintInitialAdminAccount(username, password string)
}
type cliOutputter struct {
color *color.Color
}
// NewCLIOutputter creates a new CLIOutputter
func NewCLIOutputter() Outputter {
return &cliOutputter{
color: color.New(),
}
}
func (c *cliOutputter) PrintInitialAdminAccount(
username,
password string,
) {
bold := color.New(color.Bold)
italic := color.New(color.Bold)
_, _ = italic.Println("One time credentials for account setup")
_, _ = c.color.Println()
_, _ = c.color.Print("Username: ")
_, _ = bold.Println(username)
_, _ = c.color.Printf("Password: ")
_, _ = bold.Println(password)
_, _ = bold.Println()
_, _ = c.color.Println()
c.color.DisableColor()
}
+21
View File
@@ -0,0 +1,21 @@
{
"acme": {
"email": ""
},
"administration": {
"tls_host": "phish.test",
"tls_auto": false,
"tls_cert_path": ".dev/certs/self-signed/admin-public.pem",
"tls_key_path": ".dev/certs/self-signed/admin-private.pem",
"address": "0.0.0.0:8002"
},
"phishing": {
"http": "0.0.0.0:8000",
"https": "0.0.0.0:8001"
},
"database": {
"engine": "sqlite3",
"dsn": "file:/app/.dev/db.sqlite3"
},
"ip_allow_list": []
}
+25
View File
@@ -0,0 +1,25 @@
{
"acme": {
"email": ""
},
"administration": {
"tls_host": "phish.test",
"tls_auto": false,
"tls_cert_path": "certs/admin/public.pem",
"tls_key_path": "certs/admin/private.pem",
"address": "127.0.0.1:8002"
},
"phishing": {
"http": "127.0.0.1:8000",
"https": "127.0.0.1:8001"
},
"database": {
"engine": "sqlite3",
"dsn": "file:./db.sqlite3"
},
"log": {
"path": "",
"errorPath": ""
},
"ip_allow_list": []
}
+522
View File
@@ -0,0 +1,522 @@
package config
import (
"encoding/json"
"fmt"
"io/fs"
"net"
"os"
"strconv"
"github.com/go-errors/errors"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/file"
)
var (
ErrMissingIP = errors.New("missing IP")
ErrMissingPort = errors.New("missing port")
ErrMissingDatabaseDSN = errors.New("missing database DSN")
ErrInvalidIP = errors.New("invalid IP")
ErrInvalidPort = errors.New("invalid port")
ErrInvalidDatabase = errors.New("invalid database")
ErrWriterIsNil = errors.New("writer is nil")
)
const (
DefaultACMEEmail = ""
DefaultDevACMEEmail = ""
DatabaseUsePostgres = "postgres"
DefaultAdministrationUseSqlite = "sqlite3"
DefaultDatabase = DefaultAdministrationUseSqlite
DefaultAdministrationDSN = "file:./db.sqlite3"
DefaultDevAdministrationPort = 0 // 0 uses ephemeral port, random available port
DefaultDevHTTPPhishingPort = 8080
DefaultDevHTTPSPhishingPort = 8443
DefaultProductionAdministrationPort = 0 // 0 uses ephemeral port, random available port
DefaultProductionHTTPPhishingPort = 80
DefaultProductionHTTPSPhishingPort = 443
// empty is none
DefaultLogFilePath = ""
DefaultErrLogFilePath = ""
DefaultTrustedIPHeader = ""
DefaultAdminHost = ""
DefaultAdminAutoTLS = true
DefaultAdminAutoTLSString = "true"
)
var (
defaultTrustedProxies = []string{}
defaultAdminAllowed = []string{}
)
type (
// Config config
Config struct {
acme ACME
tlsHost string
tlsAuto bool
tlsCertPath string
tlsKeyPath string
adminNetAddress net.TCPAddr
phishingHTTPNetAddress net.TCPAddr
phishingHTTPSNetAddress net.TCPAddr
database Database
fileWriter file.Writer
LogPath string
ErrLogPath string
IPSecurity IPSecurityConfig
}
// ConfigDTO config DTO
ConfigDTO struct {
ACME ACME `json:"acme"`
AdministrationServer AdministrationServer `json:"administration"`
PhishingServer PhishingServer `json:"phishing"`
Database Database `json:"database"`
Log Log `json:"log"`
IPSecurity IPSecurityConfig `json:"ip_security"`
}
Log struct {
Path string `json:"path"`
ErrorPath string `json:"errorPath"`
}
// AdministrationServer ConfigDTO administration
AdministrationServer struct {
TLSHost string `json:"tls_host"`
TLSAuto bool `json:"tls_auto"`
TLSCertPath string `json:"tls_cert_path"`
TLSKeyPath string `json:"tls_key_path"`
Address string `json:"address"`
AllowList []string `json:"ip_allow_list"`
}
// PhishingServer ConfigDTO phishing
PhishingServer struct {
Http string `json:"http"`
Https string `json:"https"`
}
// Database ConfigDTO database
Database struct {
Engine string `json:"engine"`
DSN string `json:"dsn"`
}
// ACME ConfigDTO acme
ACME struct {
Email string `json:"email"`
}
)
type IPSecurityConfig struct {
// ip/cidr that are allowed to access the admin interface
AdminAllowed []string `json:"admin_allowed"`
// ip/cidr of legitimate reverse proxies (e.g., Nginx, HAProxy, Cloudflare edges)
TrustedProxies []string `json:"trusted_proxies"`
// headers to check for real client IP
// examples: CF-Connecting-IP, X-Real-IP, True-Client-IP, X-Forwarded-For
TrustedIPHeader string `json:"trusted_ip_header"`
}
// ValidateFileWriter validates the file writer
func ValidateFileWriter(fileWriter file.Writer) error {
if fileWriter == nil {
return ErrWriterIsNil
}
return nil
}
// NewConfig factory
func NewConfig(
acmeEmail string,
tlsHost string,
tlsAuto bool,
adminPublicCertPath string,
adminPrivateCertKey string,
adminAddress string,
phishingHTTPAddress string,
phishingHTTPSAddress string,
database Database,
fileWriter file.Writer,
logPath string,
errLogPath string,
ipSecurity IPSecurityConfig,
) (*Config, error) {
if err := ValidateFileWriter(fileWriter); err != nil {
return nil, errs.Wrap(err)
}
adminNetAddress, err := StringAddressToTCPAddr(adminAddress)
if err != nil {
return nil, errs.Wrap(err)
}
phishingHTTPNetAddress, err := StringAddressToTCPAddr(phishingHTTPAddress)
if err != nil {
return nil, errs.Wrap(err)
}
phishingHTTPSNetAddress, err := StringAddressToTCPAddr(phishingHTTPSAddress)
if err != nil {
return nil, errs.Wrap(err)
}
switch database.Engine {
case DatabaseUsePostgres:
case DefaultAdministrationUseSqlite:
default:
return nil, ErrInvalidDatabase
}
return &Config{
acme: ACME{
Email: acmeEmail,
},
tlsHost: tlsHost,
tlsAuto: tlsAuto,
tlsCertPath: adminPublicCertPath,
tlsKeyPath: adminPrivateCertKey,
adminNetAddress: *adminNetAddress,
phishingHTTPNetAddress: *phishingHTTPNetAddress,
phishingHTTPSNetAddress: *phishingHTTPSNetAddress,
database: Database{
Engine: database.Engine,
DSN: database.DSN,
},
fileWriter: &file.FileWriter{},
LogPath: logPath,
ErrLogPath: errLogPath,
IPSecurity: ipSecurity,
}, nil
}
// NewDevDefaultConfig returns a default config
func NewDevDefaultConfig() *Config {
tlsHost := "phish.test"
tlsAuto := false
publicCertPath := fmt.Sprintf(
"%s/%s",
data.DefaultAdminCertDir,
data.DefaultAdminPublicCertFileName,
)
privateCertPath := fmt.Sprintf(
"%s/%s",
data.DefaultAdminCertDir,
data.DefaultAdminPrivateCertFileName,
)
return &Config{
acme: ACME{
Email: DefaultACMEEmail,
},
tlsHost: tlsHost,
tlsAuto: tlsAuto,
tlsCertPath: publicCertPath,
tlsKeyPath: privateCertPath,
adminNetAddress: net.TCPAddr{
IP: net.IPv4(0, 0, 0, 0),
Port: DefaultDevAdministrationPort,
},
phishingHTTPNetAddress: net.TCPAddr{
IP: net.IPv4(0, 0, 0, 0),
Port: DefaultDevHTTPPhishingPort,
},
phishingHTTPSNetAddress: net.TCPAddr{
IP: net.IPv4(0, 0, 0, 0),
Port: DefaultDevHTTPSPhishingPort,
},
database: Database{
Engine: DefaultAdministrationUseSqlite,
DSN: DefaultAdministrationDSN,
},
fileWriter: &file.FileWriter{},
LogPath: DefaultLogFilePath,
ErrLogPath: DefaultErrLogFilePath,
IPSecurity: IPSecurityConfig{
AdminAllowed: []string{},
TrustedProxies: []string{},
TrustedIPHeader: "",
},
}
}
// NewDevDefaultConfig returns a default config
func NewProductionDefaultConfig() *Config {
tlsHost := "localhost"
tlsAuto := DefaultAdminAutoTLS
publicCertPath := fmt.Sprintf(
"%s/%s",
data.DefaultAdminCertDir,
data.DefaultAdminPublicCertFileName,
)
privateCertPath := fmt.Sprintf(
"%s/%s",
data.DefaultAdminCertDir,
data.DefaultAdminPrivateCertFileName,
)
return &Config{
acme: ACME{
Email: DefaultACMEEmail,
},
tlsHost: tlsHost,
tlsAuto: tlsAuto,
tlsCertPath: publicCertPath,
tlsKeyPath: privateCertPath,
adminNetAddress: net.TCPAddr{
IP: net.IPv4(0, 0, 0, 0),
Port: DefaultProductionAdministrationPort,
},
phishingHTTPNetAddress: net.TCPAddr{
IP: net.IPv4(0, 0, 0, 0),
Port: DefaultProductionHTTPPhishingPort,
},
phishingHTTPSNetAddress: net.TCPAddr{
IP: net.IPv4(0, 0, 0, 0),
Port: DefaultProductionHTTPSPhishingPort,
},
database: Database{
Engine: DefaultAdministrationUseSqlite,
DSN: DefaultAdministrationDSN,
},
fileWriter: &file.FileWriter{},
IPSecurity: IPSecurityConfig{
AdminAllowed: []string{},
TrustedProxies: []string{},
TrustedIPHeader: "",
},
}
}
// ACMEEmail returns the acme email
func (c *Config) ACMEEmail() string {
return c.acme.Email
}
// SetACMEEmail sets the acme email
func (c *Config) SetACMEEmail(email string) {
c.acme.Email = email
}
// TLSHost returns the host to use for admin server
func (c *Config) TLSHost() string {
return c.tlsHost
}
// TLSAuto returns if ACME service should handle TLS for the admin server
func (c *Config) TLSAuto() bool {
return c.tlsAuto
}
// TLSCertPath returns the cert path
func (c *Config) TLSCertPath() string {
return c.tlsCertPath
}
// TLSKeyPath returns the private key
func (c *Config) TLSKeyPath() string {
return c.tlsKeyPath
}
// SetTLSCertPath returns the admin host
func (c *Config) SetTLSHost(host string) {
c.tlsHost = host
}
// SetTLSAuto sets if a ACME service should handle TLS for the admin server
func (c *Config) SetTLSAuto(auto bool) {
c.tlsAuto = auto
}
// SetAdminNetAddress sets the administration network address
func (c *Config) SetAdminNetAddress(adminNetAddress string) error {
newAddr, err := StringAddressToTCPAddr(adminNetAddress)
if err != nil {
return err
}
c.adminNetAddress = *newAddr
return nil
}
// SetPhishingHTTPNetAddress sets the phishing network address
func (c *Config) SetPhishingHTTPNetAddress(addr string) error {
newAddr, err := StringAddressToTCPAddr(addr)
if err != nil {
return err
}
c.phishingHTTPNetAddress = *newAddr
return nil
}
// SetPhishingHTTPNetAddress sets the phishing network address
func (c *Config) SetPhishingHTTPSNetAddress(addr string) error {
newAddr, err := StringAddressToTCPAddr(addr)
if err != nil {
return err
}
c.phishingHTTPSNetAddress = *newAddr
return nil
}
// SetFileWriter sets the file writer
func (c *Config) SetFileWriter(fileWriter file.Writer) error {
if err := ValidateFileWriter(fileWriter); err != nil {
return fmt.Errorf("failed to set file writer on config: %w", err)
}
c.fileWriter = fileWriter
return nil
}
// Write writes the config to a writer
func (c *Config) WriteToFile(filepath string) error {
dto := c.ToDTO()
conf, err := json.MarshalIndent(dto, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
// Write the content to the writer
if _, err := c.fileWriter.Write(filepath, conf, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644); err != nil {
return fmt.Errorf("failed to write config: %w", err)
}
return nil
}
// StringAddressToTCPAddr converts a string address to a TCPAddr
func StringAddressToTCPAddr(address string) (*net.TCPAddr, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, errs.Wrap(err)
}
ip := net.ParseIP(host)
if ip == nil {
return nil, ErrInvalidIP
}
// convert port to int
p, err := strconv.Atoi(port)
if err != nil {
return nil, errs.Wrap(err)
}
if p < 0 || p > 65535 {
return nil, ErrInvalidPort
}
return &net.TCPAddr{
IP: ip,
Port: p,
}, nil
}
// FromMap creates a *Config from a DTO
func FromDTO(dto *ConfigDTO) (*Config, error) {
return NewConfig(
dto.ACME.Email,
dto.AdministrationServer.TLSHost,
dto.AdministrationServer.TLSAuto,
dto.AdministrationServer.TLSCertPath,
dto.AdministrationServer.TLSKeyPath,
dto.AdministrationServer.Address,
dto.PhishingServer.Http,
dto.PhishingServer.Https,
dto.Database,
file.FileWriter{},
dto.Log.Path,
dto.Log.ErrorPath,
dto.IPSecurity,
)
}
// ToDTO converts a *Config to a *ConfigDTO
func (c *Config) ToDTO() *ConfigDTO {
allowList := make([]string, 0)
return &ConfigDTO{
ACME: ACME{
Email: c.acme.Email,
},
AdministrationServer: AdministrationServer{
TLSHost: c.TLSHost(),
TLSAuto: c.TLSAuto(),
TLSCertPath: c.TLSCertPath(),
TLSKeyPath: c.TLSKeyPath(),
Address: c.AdminNetAddress(),
AllowList: allowList,
},
PhishingServer: PhishingServer{
Http: c.phishingHTTPNetAddress.String(),
Https: c.phishingHTTPSNetAddress.String(),
},
Database: Database{
Engine: c.database.Engine,
DSN: c.database.DSN,
},
Log: Log{
Path: c.LogPath,
ErrorPath: c.ErrLogPath,
},
IPSecurity: c.IPSecurity,
}
}
// AdminNetAddress returns the administration network address
func (c *Config) AdminNetAddress() string {
return c.adminNetAddress.String()
}
// AdminNetAddressPort returns the administration network address port
func (c *Config) AdminNetAddressPort() int {
return c.adminNetAddress.Port
}
// PhishingHTTPNetAddress returns the phishing network address
func (c *Config) PhishingHTTPNetAddress() string {
return c.phishingHTTPNetAddress.String()
}
// PhishingHTTPNetAddressPort returns the phishing network address port
func (c *Config) PhishingHTTPNetAddressPort() int {
return c.phishingHTTPNetAddress.Port
}
// PhishingHTTPSNetAddress returns the phishing network address
func (c *Config) PhishingHTTPSNetAddress() string {
return c.phishingHTTPSNetAddress.String()
}
// PhishingHTTPSNetAddressPort returns the phishing network address port
func (c *Config) PhishingHTTPSNetAddressPort() int {
return c.phishingHTTPSNetAddress.Port
}
// Database returns the database
func (c *Config) Database() Database {
return c.database
}
// NewDTOFromFile creates a *ConfigDTO from a file
func NewDTOFromFile(filesystem fs.FS, path string) (*ConfigDTO, error) {
var conf ConfigDTO
f, err := filesystem.Open(path)
if err != nil {
return nil, errs.Wrap(err)
}
dec := json.NewDecoder(f)
err = dec.Decode(&conf)
if err != nil {
return nil, errs.Wrap(err)
}
return &conf, nil
}
+455
View File
@@ -0,0 +1,455 @@
package config
import (
"encoding/json"
"fmt"
"io/fs"
"net"
"os"
"reflect"
"testing"
"testing/fstest"
"github.com/go-errors/errors"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/file"
"github.com/phishingclub/phishingclub/file/filemock"
)
const (
DEFAULT_ADMIN_ADDR = "127.0.0.1:8002"
DEFAULT_PHISHING_HTTP_ADDR = "127.0.0.1:8000"
DEFAULT_PHISHING_HTTPS_ADDR = "127.0.0.1:8001"
DEFAULT_ACME_EMAIL = ""
)
var (
adminHost = "phish.test"
adminTLS = false
adminPublicCertPath = fmt.Sprintf(
"%s/%s",
data.DefaultAdminCertDir,
data.DefaultAdminPublicCertFileName,
)
adminPrivateCertPath = fmt.Sprintf(
"%s/%s",
data.DefaultAdminCertDir,
data.DefaultAdminPrivateCertFileName,
)
configFileOK = []byte(`{
"administration": {
"address": "127.0.0.1:4000"
}
}`)
configFileEmpty = []byte("{")
databaseOK = Database{
Engine: DefaultAdministrationUseSqlite,
DSN: DefaultAdministrationDSN,
}
)
func newTestConfig() *Config {
return &Config{
acme: ACME{
Email: DEFAULT_ACME_EMAIL,
},
tlsCertPath: adminPublicCertPath,
tlsKeyPath: adminPrivateCertPath,
adminNetAddress: net.TCPAddr{
IP: net.IPv4(127, 0, 0, 1),
Port: DefaultDevAdministrationPort,
},
phishingHTTPNetAddress: net.TCPAddr{
IP: net.IPv4(127, 0, 0, 1),
Port: DefaultDevHTTPPhishingPort,
},
phishingHTTPSNetAddress: net.TCPAddr{
IP: net.IPv4(127, 0, 0, 1),
Port: DefaultDevHTTPSPhishingPort,
},
database: databaseOK,
fileWriter: &filemock.Writer{},
}
}
func TestNewConfig(t *testing.T) {
t.Run("happy path", testNewConfigHappyPath)
t.Run("invalid administration address and port split", testNewConfigInvalidAdministrationAddress)
t.Run("invalid administration ip", testNewConfigInvalidAdministrationIP)
t.Run("invalid administration port", testNewConfigInvalidAdministrationPort)
t.Run("invalid administration port string", testNewConfigInvalidAdministrationPortString)
t.Run("invalid database", testNewConfigInvalidDatabase)
t.Run("writer with nil", testNewConfigWithNilWriter)
}
func testNewConfigWithNilWriter(t *testing.T) {
_, err := NewConfig(
DEFAULT_ACME_EMAIL,
adminHost,
adminTLS,
adminPublicCertPath,
adminPrivateCertPath,
"127.0.0.1:8080",
DEFAULT_PHISHING_HTTP_ADDR,
DEFAULT_PHISHING_HTTPS_ADDR,
databaseOK,
nil,
"",
"",
IPSecurityConfig{
AdminAllowed: defaultAdminAllowed,
TrustedProxies: defaultTrustedProxies,
TrustedIPHeader: DefaultTrustedIPHeader,
},
)
if err == nil {
if !errors.Is(err, ErrWriterIsNil) {
t.Error("expected ErrWriterIsNil error from nil writer")
}
t.Error("expected error from nil writer")
return
}
}
func testNewConfigInvalidAdministrationAddress(t *testing.T) {
_, err := NewConfig(
DEFAULT_ACME_EMAIL,
adminHost,
adminTLS,
adminPublicCertPath,
adminPrivateCertPath,
"foobar",
DEFAULT_PHISHING_HTTP_ADDR,
DEFAULT_PHISHING_HTTPS_ADDR,
databaseOK,
&filemock.Writer{},
"",
"",
IPSecurityConfig{
AdminAllowed: defaultAdminAllowed,
TrustedProxies: defaultTrustedProxies,
TrustedIPHeader: DefaultTrustedIPHeader,
},
)
if err == nil {
t.Error("expected error from invalid address")
return
}
}
func testNewConfigInvalidAdministrationIP(t *testing.T) {
_, err := NewConfig(
DEFAULT_ACME_EMAIL,
adminHost,
adminTLS,
adminPublicCertPath,
adminPrivateCertPath,
"999.00.999.999:1234",
DEFAULT_PHISHING_HTTP_ADDR,
DEFAULT_PHISHING_HTTPS_ADDR,
databaseOK,
&filemock.Writer{},
"",
"",
IPSecurityConfig{
AdminAllowed: defaultAdminAllowed,
TrustedProxies: defaultTrustedProxies,
TrustedIPHeader: DefaultTrustedIPHeader,
},
)
if !errors.Is(err, ErrInvalidIP) {
t.Error(err)
return
}
}
func testNewConfigHappyPath(t *testing.T) {
addr := "127.0.0.1:1234"
c, err := NewConfig(
DEFAULT_ACME_EMAIL,
adminHost,
adminTLS,
adminPublicCertPath,
adminPrivateCertPath,
addr,
DEFAULT_PHISHING_HTTP_ADDR,
DEFAULT_PHISHING_HTTPS_ADDR,
databaseOK,
&filemock.Writer{},
"",
"",
IPSecurityConfig{
AdminAllowed: defaultAdminAllowed,
TrustedProxies: defaultTrustedProxies,
TrustedIPHeader: DefaultTrustedIPHeader,
},
)
if err != nil {
t.Error(err)
return
}
if c.AdminNetAddress() != addr {
t.Errorf("expected %s but got %s", addr, c.AdminNetAddress())
return
}
if c.database.DSN != databaseOK.DSN {
t.Errorf("expected %s but got %s", databaseOK.DSN, c.database.DSN)
return
}
if c.database.Engine != databaseOK.Engine {
t.Errorf("expected %s but got %s", databaseOK.Engine, c.database.Engine)
return
}
}
func testNewConfigInvalidAdministrationPort(t *testing.T) {
_, err := NewConfig(
DEFAULT_ACME_EMAIL,
adminHost,
adminTLS,
adminPublicCertPath,
adminPrivateCertPath,
"127.0.0.1:-1",
DEFAULT_PHISHING_HTTP_ADDR,
DEFAULT_PHISHING_HTTPS_ADDR,
databaseOK,
&filemock.Writer{},
"",
"",
IPSecurityConfig{
AdminAllowed: defaultAdminAllowed,
TrustedProxies: defaultTrustedProxies,
TrustedIPHeader: DefaultTrustedIPHeader,
},
)
if !errors.Is(err, ErrInvalidPort) {
t.Error(err)
return
}
}
func testNewConfigInvalidAdministrationPortString(t *testing.T) {
_, err := NewConfig(
DEFAULT_ACME_EMAIL,
adminHost,
adminTLS,
adminPublicCertPath,
adminPrivateCertPath,
"127.0.0.1:999999999999999999999999999999999999999999",
DEFAULT_PHISHING_HTTP_ADDR,
DEFAULT_PHISHING_HTTPS_ADDR,
databaseOK,
&filemock.Writer{},
"",
"",
IPSecurityConfig{
AdminAllowed: defaultAdminAllowed,
TrustedProxies: defaultTrustedProxies,
TrustedIPHeader: DefaultTrustedIPHeader,
},
)
if err == nil {
t.Error("expected error from invalid string port")
return
}
}
func testNewConfigInvalidDatabase(t *testing.T) {
_, err := NewConfig(
DEFAULT_ACME_EMAIL,
adminHost,
adminTLS,
adminPublicCertPath,
adminPrivateCertPath,
"127.0.0.1:1234",
DEFAULT_PHISHING_HTTP_ADDR,
DEFAULT_PHISHING_HTTPS_ADDR,
Database{
Engine: "foobar",
DSN: "file:./data.db?cache=shared&mode=rwc&_fk=1",
}, &filemock.Writer{},
"",
"",
IPSecurityConfig{
AdminAllowed: defaultAdminAllowed,
TrustedProxies: defaultTrustedProxies,
TrustedIPHeader: DefaultTrustedIPHeader,
},
)
if err == nil {
t.Errorf("expected %s but got %s", ErrInvalidDatabase, err)
return
}
}
func TestSetFileWriter(t *testing.T) {
t.Run("happypath", func(t *testing.T) {
c := newTestConfig()
err := c.SetFileWriter(&filemock.Writer{})
if err != nil {
t.Error(err)
return
}
})
t.Run("nil writer", func(t *testing.T) {
c := newTestConfig()
err := c.SetFileWriter(nil)
if err == nil {
if !errors.Is(err, ErrWriterIsNil) {
t.Error("expected ErrWriterIsNil error from nil writer")
}
t.Error("expected error from nil writer")
return
}
})
}
func TestWriteToFile(t *testing.T) {
filepath := "./testFile"
c := newTestConfig()
m := filemock.Writer{}
dto := c.ToDTO()
conf, err := json.MarshalIndent(dto, "", " ")
if err != nil {
t.Error(err)
return
}
m.
On("Write", filepath, conf, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, os.FileMode(0644)).
Return(0, nil)
err = c.SetFileWriter(&m)
if err != nil {
t.Error(err)
return
}
err = c.WriteToFile(filepath)
if err != nil {
t.Error(err)
return
}
}
func TestToDTO(t *testing.T) {
addr := "127.0.0.1:1234"
c, err := NewConfig(
DEFAULT_ACME_EMAIL,
adminHost,
adminTLS,
adminPublicCertPath,
adminPrivateCertPath,
addr,
DEFAULT_PHISHING_HTTP_ADDR,
DEFAULT_PHISHING_HTTPS_ADDR,
databaseOK,
&filemock.Writer{},
"",
"",
IPSecurityConfig{
AdminAllowed: defaultAdminAllowed,
TrustedProxies: defaultTrustedProxies,
TrustedIPHeader: DefaultTrustedIPHeader,
},
)
if err != nil {
t.Error(err)
return
}
dto := c.ToDTO()
if dto.AdministrationServer.Address != addr {
t.Errorf("expected %s but got %s", addr, dto.AdministrationServer.Address)
return
}
}
func TestNewDTOFromFile(t *testing.T) {
t.Run("happypath", testNewDTOFromFileHappyPath)
t.Run("file error", testNewDTOFromFileFileError)
t.Run("bad content", testNewDTOFromFileBadContent)
}
func testNewDTOFromFileHappyPath(t *testing.T) {
filesystem := fstest.MapFS{}
path := "config.json"
filesystem[path] = &fstest.MapFile{
Data: configFileOK,
}
dto, err := NewDTOFromFile(filesystem, path)
if err != nil {
t.Error(err)
return
}
if dto.AdministrationServer.Address != "127.0.0.1:4000" {
t.Errorf("Expected %s Got %s", "127.0.0.1:4000", dto.AdministrationServer.Address)
return
}
}
func testNewDTOFromFileFileError(t *testing.T) {
filesystem := fstest.MapFS{}
path := "config.json"
_, err := NewDTOFromFile(filesystem, path)
if !errors.Is(err, fs.ErrNotExist) {
t.Errorf("expected %s but got %s", fs.ErrNotExist, err)
return
}
}
func testNewDTOFromFileBadContent(t *testing.T) {
filesystem := fstest.MapFS{}
path := "config.json"
filesystem[path] = &fstest.MapFile{
Data: configFileEmpty,
}
_, err := NewDTOFromFile(filesystem, path)
if err == nil {
t.Error("expected error from invalid file contents")
return
}
}
func TestNewDefaultConfig(t *testing.T) {
tests := []struct {
name string
want *Config
}{
{
name: "happypath",
want: &Config{
tlsCertPath: adminPublicCertPath,
tlsKeyPath: adminPrivateCertPath,
adminNetAddress: net.TCPAddr{
IP: net.IPv4(127, 0, 0, 1),
Port: DefaultDevAdministrationPort,
},
database: Database{
Engine: DefaultAdministrationUseSqlite,
DSN: DefaultAdministrationDSN,
},
fileWriter: &file.FileWriter{},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := NewDevDefaultConfig(); !reflect.DeepEqual(got, tt.want) {
t.Errorf("NewDefaultConfig() = %v, want %v", got, tt.want)
}
})
}
}
func TestConfig_Database(t *testing.T) {
t.Run("happypath", func(t *testing.T) {
c := newTestConfig()
if !reflect.DeepEqual(c.Database(), databaseOK) {
t.Errorf("expected %v but got %v", databaseOK, c.Database())
return
}
})
}
+200
View File
@@ -0,0 +1,200 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
)
// AllowDenyColumnsMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var AllowDenyColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.ALLOW_DENY_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.ALLOW_DENY_TABLE, "updated_at"),
"hosting_website": repository.TableColumn(database.ALLOW_DENY_TABLE, "host_website"),
"redirects": repository.TableColumn(database.ALLOW_DENY_TABLE, "redirect_url"),
}
// AllowDeny is a controller
type AllowDeny struct {
Common
AllowDenyService *service.AllowDeny
}
// Create creates a new AllowDeny
func (c *AllowDeny) Create(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
var req model.AllowDeny
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// save
id, err := c.AllowDenyService.Create(g, session, &req)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
gin.H{
"id": id.String(),
},
)
}
// GetAll gets AllowDenies
func (c *AllowDeny) GetAll(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByName()
companyID := companyIDFromRequestQuery(g)
// get
allowDenies, err := c.AllowDenyService.GetAll(
g,
session,
companyID,
&repository.AllowDenyOption{
QueryArgs: queryArgs,
},
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
allowDenies,
)
}
// GetAllOverview gets AllowDenies
func (c *AllowDeny) GetAllOverview(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByName()
companyID := companyIDFromRequestQuery(g)
allowDenies, err := c.AllowDenyService.GetAll(
g,
session,
companyID,
&repository.AllowDenyOption{
Fields: []string{
"id",
"created_at",
"updated_at",
"company_id",
"name",
"allowed",
},
QueryArgs: queryArgs,
},
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
allowDenies,
)
}
// GetByID gets an AllowDeny by ID
func (c *AllowDeny) GetByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// get
allowDeny, err := c.AllowDenyService.GetByID(
g,
session,
id,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
allowDeny,
)
}
// UpdateByID updates an AllowDeny
func (c *AllowDeny) UpdateByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
var req model.AllowDeny
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// update
err := c.AllowDenyService.Update(g, session, id, &req)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
nil,
)
}
// DeleteByID deletes an AllowDeny
func (c *AllowDeny) DeleteByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// delete
err := c.AllowDenyService.DeleteByID(g, session, id)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
nil,
)
}
+196
View File
@@ -0,0 +1,196 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
)
// APISenderColumnsMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var APISenderColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.API_SENDER_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.API_SENDER_TABLE, "updated_at"),
"name": repository.TableColumn(database.API_SENDER_TABLE, "name"),
}
// APISender is a API sender controller
type APISender struct {
Common
APISenderService *service.APISender
}
// Create creates a new api sender
func (a *APISender) Create(g *gin.Context) {
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
var req model.APISender
if ok := a.handleParseRequest(g, &req); !ok {
return
}
id, err := a.APISenderService.Create(g, session, &req)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, gin.H{"id": id.String()})
}
// GetAll gets all api senders
func (a *APISender) GetAll(g *gin.Context) {
session, _, ok := a.handleSession(g)
if !ok {
return
}
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := a.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
queryArgs.RemapOrderBy(APISenderColumnsMap)
apiSenders, err := a.APISenderService.GetAll(
g.Request.Context(),
session,
companyID,
repository.APISenderOption{
QueryArgs: queryArgs,
},
)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, apiSenders)
}
// GetAllOverview gets all api senders with limited data
func (a *APISender) GetAllOverview(g *gin.Context) {
session, _, ok := a.handleSession(g)
if !ok {
return
}
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := a.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
queryArgs.RemapOrderBy(APISenderColumnsMap)
apiSenders, err := a.APISenderService.GetAllOverview(
g.Request.Context(),
session,
companyID,
repository.APISenderOption{
QueryArgs: queryArgs,
},
)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, apiSenders)
}
// GetByID gets a api sender by ID
func (a *APISender) GetByID(g *gin.Context) {
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse reqeuest
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
// get api sender
apiSender, err := a.APISenderService.GetByID(
g,
session,
id,
&repository.APISenderOption{},
)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, apiSender)
}
// Update updates a api sender
func (a *APISender) UpdateByID(g *gin.Context) {
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
var req model.APISender
if ok := a.handleParseRequest(g, &req); !ok {
return
}
err := a.APISenderService.UpdateByID(
g,
session,
id,
&req,
)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, gin.H{})
}
// DeletebyID deletes a api sender by ID
func (a *APISender) DeleteByID(g *gin.Context) {
// handle session
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
err := a.APISenderService.DeleteByID(
g.Request.Context(),
session,
id,
)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, gin.H{})
}
// SendTest sends a api request test and outputs the api sender and response
func (a *APISender) SendTest(g *gin.Context) {
// handle session
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
data, err := a.APISenderService.SendTest(
g.Request.Context(),
session,
id,
)
// output the error
if err != nil {
a.Response.BadRequestMessage(g, err.Error())
return
}
a.Response.OK(g, data)
}
+487
View File
@@ -0,0 +1,487 @@
package controller
import (
"encoding/base64"
"fmt"
"io/fs"
"net/http"
"net/url"
"os"
"path/filepath"
"github.com/go-errors/errors"
securejoin "github.com/cyphar/filepath-securejoin"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/utils"
"github.com/phishingclub/phishingclub/vo"
)
// AssetOrderByMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var AssetsColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.ASSET_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.ASSET_TABLE, "updated_at"),
"name": repository.TableColumn(database.ASSET_TABLE, "name"),
"description": repository.TableColumn(database.ASSET_TABLE, "description"),
"path": repository.TableColumn(database.ASSET_TABLE, "path"),
}
// Asset is an static Asset controller
type Asset struct {
Common
StaticAssetPath string
DomainService *service.Domain
OptionService *service.Option
AssetService *service.Asset
}
// GetContentByID get the content and mime type of an asset
func (a *Asset) GetContentByID(g *gin.Context) {
// handle session
session, _, ok := a.handleSession(g)
if !ok {
return
}
// check permissions
isAuthorized, err := service.IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
_ = handleServerError(g, a.Response, err)
return
}
if !isAuthorized {
a.Response.Unauthorized(g)
return
}
// get domain
domain, err := vo.NewString255(g.Param("domain"))
if err != nil {
a.Logger.Errorw("invalid domain",
"domain", domain,
)
a.Response.ValidationFailed(g, "Domain", err)
return
}
// if the target is the global folder, use the global folder
if domain.String() == data.ASSET_GLOBAL_FOLDER {
// TODO this shold require special permissions or be prefixed with a special path
// such as the company name or something that is prefixed
_ = data.ASSET_GLOBAL_FOLDER
}
staticPath, err := securejoin.SecureJoin(a.StaticAssetPath, domain.String())
if err != nil {
a.Logger.Debugw("insecure path",
"path", a.StaticAssetPath,
"domain", domain.String(),
"error", err,
)
return
}
// get the file path
pathDecoded, err := url.QueryUnescape(g.Param("path"))
if err != nil {
a.Logger.Debugw("failed to decode path",
"error", err,
)
a.Response.BadRequest(g)
return
}
filePath, err := securejoin.SecureJoin(staticPath, pathDecoded)
if err != nil {
a.Logger.Debugw("insecure path",
"path", pathDecoded,
"error", err,
)
a.Response.BadRequest(g)
return
}
// check if the file exists
a.Logger.Debugw("checking if asset exists",
"path", filePath,
)
_, err = os.Stat(filePath)
if errors.Is(err, fs.ErrNotExist) {
a.Logger.Debugw("asset not found",
"path", filePath,
)
a.Response.NotFound(g)
return
}
if err != nil {
a.Logger.Errorw("failed to get asset path info",
"path", filePath,
"error", err,
)
a.Response.ServerError(g)
return
}
// serve the file
// #nosec
content, err := os.ReadFile(filePath)
if err != nil {
a.Logger.Errorw("failed to read asset",
"path", filePath,
"error", err,
)
a.Response.ServerError(g)
return
}
fileExt := filepath.Ext(filePath)
mimeType := ""
switch fileExt {
case ".html":
mimeType = "text/html"
case ".htm":
mimeType = "text/html"
case ".xhtml":
mimeType = "application/xhtml+xml"
default:
mimeType = http.DetectContentType(content)
}
encodedContent := base64.StdEncoding.EncodeToString(content)
a.Response.OK(g, gin.H{
"mimeType": mimeType,
"file": encodedContent,
})
}
// GetAllForContext gets all static assets for a domain
// and has a special case 'shared' to get all global assets
func (a *Asset) GetAllForContext(g *gin.Context) {
// handle session
session, _, ok := a.handleSession(g)
if !ok {
return
}
// check permissions
isAuthorized, err := service.IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
_ = handleServerError(g, a.Response, err)
return
}
if !isAuthorized {
a.Response.Unauthorized(g)
return
}
// parse request
var domainID *uuid.UUID
companyID := companyIDFromRequestQuery(g)
domainParam := g.Param("domain")
queryArgs, ok := a.handleQueryArgs(g)
if !ok {
return
}
// set default sort by
queryArgs.RemapOrderBy(AssetsColumnsMap)
queryArgs.DefaultSortByUpdatedAt()
a.Logger.Debugw("getting assets for domain",
"domain", domainParam,
"companyID", companyID,
)
// if there is no domain then it is a global asset request
// else the domain name is the asset scope
if len(domainParam) > 0 {
domainName, err := vo.NewString255(domainParam)
if err != nil {
a.Logger.Errorw("invalid domain",
"domain", domainName,
)
a.Response.ValidationFailed(g, "Domain", err)
return
}
// get the domains id and also check if the user has permission to retrieve it
domain, err := a.DomainService.GetByName(
g.Request.Context(),
session,
domainName,
&repository.DomainOption{},
)
if ok := a.handleErrors(g, err); !ok {
return
}
did := domain.ID.MustGet()
domainID = &did
}
// get assets
a.Logger.Debugw("getting assets for domain by ID",
"domainID", domainID,
)
assets, err := a.AssetService.GetAll(
g,
session,
domainID,
companyID,
queryArgs,
)
// handle responses
a.handleErrors(g, err)
a.Response.OK(g, assets)
}
// Create uploads an static asset
func (a *Asset) Create(g *gin.Context) {
// handle session
session, _, ok := a.handleSession(g)
if !ok {
return
}
// this is a form data request, so we must handle all fields manually as is it not parsed from the struct
multipartData, err := g.MultipartForm()
if err != nil {
a.Logger.Errorw("failed to get multipart form",
"error", err,
)
a.Response.BadRequest(g)
return
}
if len(multipartData.File["files"]) == 0 {
a.Logger.Debug("no files to upload")
a.Response.BadRequestMessage(g, "No files selected")
return
}
contextParam := g.PostForm("domain")
// if no domain is set, use the global folder
var domain *model.Domain
// if a domain is supplied we look for its assets
if len(contextParam) > 0 {
// check that the domain exists
name, err := vo.NewString255(contextParam)
if err != nil {
a.Logger.Errorw("invalid domain name",
"error", err,
)
a.Response.ValidationFailed(g, "Domain", err)
return
}
d, err := a.DomainService.GetByName(
g,
session,
name,
&repository.DomainOption{},
)
if ok := a.handleErrors(g, err); !ok {
return
}
domain = d
a.Logger.Debugw("uploading assets to domain",
"domain", contextParam,
)
} else {
a.Logger.Debug("uploading shared assets")
}
// map files to assets
assets := []*model.Asset{}
for _, file := range multipartData.File["files"] {
// check max file size
maxFile, err := a.OptionService.GetOption(g, session, data.OptionKeyMaxFileUploadSizeMB)
if ok := a.handleErrors(g, err); !ok {
return
}
ok, err := utils.CompareFileSizeFromString(file.Size, maxFile.Value.String())
if err != nil {
a.Logger.Errorw("failed to compare file size",
"error", err,
)
}
if !ok {
a.Logger.Debugw("file too large",
"filename", file.Filename,
"size", file.Size,
"maxSize", maxFile.Value.String(),
)
a.Response.ValidationFailed(
g,
"File",
fmt.Errorf("file '%s' is too large", utils.ReadableFileName(file.Filename)),
)
return
}
// TODO multi user validate that the company id is the same as the session company id or that the session is a super admin
// TODO can the creation of the ID be moved to the repo
var domainID string
if domain != nil {
did := domain.ID.MustGet()
domainID = did.String()
}
name, err := vo.NewOptionalString127(g.Request.PostFormValue("name"))
if err != nil {
a.Logger.Debugw("failed to parse name",
"error", err,
)
a.Response.ValidationFailed(g, "Name", err)
return
}
description, err := vo.NewOptionalString255(g.Request.PostFormValue("description"))
if err != nil {
a.Logger.Debugw("failed to parse description",
"error", err,
)
a.Response.ValidationFailed(g, "Description", err)
return
}
path, err := vo.NewRelativeFilePath(g.Request.PostFormValue("path"))
if err != nil {
a.Logger.Debugw("failed to parse path",
"error", err,
)
a.Response.ValidationFailed(g, "Path", err)
return
}
companyID := nullable.NewNullNullable[uuid.UUID]()
companyIDParam := g.PostForm("companyID")
if len(companyIDParam) > 0 {
cid, err := uuid.Parse(companyIDParam)
if err != nil {
a.Logger.Debugw("failed to parse company id",
"error", err,
)
a.Response.ValidationFailed(g, "CompanyID", err)
}
companyID.Set(cid)
} else {
companyID.SetNull()
}
assetName := nullable.NewNullableWithValue(*name)
assetDescription := nullable.NewNullableWithValue(*description)
assetPath := nullable.NewNullableWithValue(*path)
assetDomainID := nullable.NewNullNullable[uuid.UUID]()
if len(domainID) > 0 {
did, err := uuid.Parse(domainID)
if err != nil {
a.Logger.Debugw("failed to parse domain id",
"error", err,
)
a.Response.ValidationFailed(g, "DomainID", err)
return
}
assetDomainID.Set(did)
// if the asset belongs to a domain it must not be 'global' context
if !companyID.IsSpecified() {
a.Logger.Debugw(
"cant add a shared asset to a company owned domain",
"domainID", domainID,
"domainOwnerCompanyID", companyID,
)
a.Response.ValidationFailed(
g,
"domainID",
errors.New("cant add a shared asset to a company owned domain"),
)
return
}
}
asset := model.Asset{
Name: assetName,
Description: assetDescription,
Path: assetPath,
File: *file,
DomainID: assetDomainID,
CompanyID: companyID,
}
if domain != nil {
asset.DomainName = domain.Name
}
assets = append(assets, &asset)
}
// store the files on disk and in database
ids, err := a.AssetService.Create(g, session, assets)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, gin.H{
"ids": ids,
"files_uploaded": len(assets),
})
}
// GetByID gets an static asset by id
func (a *Asset) GetByID(g *gin.Context) {
// handle session
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
// get the asset
ctx := g.Request.Context()
asset, err := a.AssetService.GetByID(ctx, session, id)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, asset)
}
// UpdateByID updates an static asset by id
func (a *Asset) UpdateByID(g *gin.Context) {
// handle session
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
var req model.Asset
if ok := a.handleParseRequest(g, &req); !ok {
return
}
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
// update the asset
ctx := g.Request.Context()
err := a.AssetService.UpdateByID(
ctx,
session,
id,
req.Name,
req.Description,
)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, gin.H{})
}
// RemoveByID removes an static asset
// if the asset is a directory, it will be removed recursively
func (a *Asset) RemoveByID(g *gin.Context) {
// handle session
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
// remove the asset
ctx := g.Request.Context()
err := a.AssetService.DeleteByID(
ctx,
session,
id,
)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, gin.H{})
}
+404
View File
@@ -0,0 +1,404 @@
package controller
import (
"encoding/base64"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/go-errors/errors"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/utils"
"github.com/phishingclub/phishingclub/vo"
)
// AttachmentColumnsMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var AttachmentColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.ATTACHMENT_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.ATTACHMENT_TABLE, "updated_at"),
"name": repository.TableColumn(database.ATTACHMENT_TABLE, "name"),
"description": repository.TableColumn(database.ATTACHMENT_TABLE, "description"),
"embedded content": repository.TableColumn(database.ATTACHMENT_TABLE, "embeddedContent"),
"filename": repository.TableColumn(database.ATTACHMENT_TABLE, "filename"),
}
// Attachment is an static Attachment controller
type Attachment struct {
Common
StaticAttachmentPath string
TemplateService *service.Template
AttachmentService *service.Attachment
OptionService *service.Option
CompanyService *service.Company
}
// GetContentByID returns the content and mime type of an attachment
func (a *Attachment) GetContentByID(g *gin.Context) {
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
// get the attachment
ctx := g.Request.Context()
attachment, err := a.AttachmentService.GetByID(
ctx,
session,
id,
)
if ok := a.handleErrors(g, err); !ok {
return
}
p := attachment.Path.MustGet().String()
// serve the file
// #nosec
content, err := os.ReadFile(p)
if err != nil {
a.Logger.Errorw("failed to read file",
"path", p,
"error", err,
)
a.Response.ServerError(g)
return
}
fileExt := filepath.Ext(p)
mimeType := ""
switch fileExt {
case ".html":
mimeType = "text/html"
case ".htm":
mimeType = "text/html"
case ".xhtml":
mimeType = "application/xhtml+xml"
default:
mimeType = http.DetectContentType(content)
}
// get by id is only used for admin viewing of an attachemnt, so all
// embedded content must contain example data
if attachment.EmbeddedContent.MustGet() {
// build email
domain := &model.Domain{
Name: nullable.NewNullableWithValue(
*vo.NewString255Must("example.test"),
),
}
recipient := model.NewRecipientExample()
campaignRecipient := model.CampaignRecipient{
ID: nullable.NewNullableWithValue(
uuid.New(),
),
Recipient: recipient,
}
email := model.NewEmailExample()
// hacky
email.Content = nullable.NewNullableWithValue(
*vo.NewUnsafeOptionalString1MB(string(content)),
)
apiSender := model.NewAPISenderExample()
b, err := a.TemplateService.CreateMailBody(
"id",
"/foo",
domain,
&campaignRecipient,
email,
apiSender,
)
if err != nil {
a.Logger.Errorw("failed to appy template to attachment",
"error", err,
)
a.Response.ServerError(g)
return
}
content = []byte(b)
}
a.Response.OK(g, gin.H{
"mimeType": mimeType,
"file": base64.StdEncoding.EncodeToString(content),
})
}
// GetAllForContext gets all attachments for a domain
// and has a special case 'shared' to get all global attachments
func (a *Attachment) GetAllForContext(g *gin.Context) {
session, _, ok := a.handleSession(g)
if !ok {
return
}
// check permissions
isAuthorized, err := service.IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
a.Logger.Errorw("failed to check permissions",
"error", err,
)
a.Response.ServerError(g)
return
}
if !isAuthorized {
// TODO audit log
_ = handleAuthorizationError(g, a.Response, errs.ErrAuthorizationFailed)
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
// if there is no companyID then it is a global attachment request
// else the company context name is the attachment scope
if companyID != nil {
// get the company id and to check if the user has permission to retrieve it
_, err := a.CompanyService.GetByID(
g.Request.Context(),
session,
companyID,
)
if ok := a.handleErrors(g, err); !ok {
return
}
}
queryArgs, ok := a.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
queryArgs.RemapOrderBy(AttachmentColumnsMap)
// get attachments
a.Logger.Debugw("getting attachments for company ID",
"companyID", companyID,
)
attachments, err := a.AttachmentService.GetAll(
g,
session,
companyID,
queryArgs,
)
// handle responses
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, attachments)
}
// Create uploads an attachment
func (a *Attachment) Create(g *gin.Context) {
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
multipartData, err := g.MultipartForm()
if err != nil {
a.Logger.Errorw("failed to get multipart form",
"error", err,
)
a.Response.BadRequest(g)
return
}
if len(multipartData.File["files"]) == 0 {
a.Logger.Debug("no files to upload")
a.Response.BadRequestMessage(g, "No files selected")
return
}
companyID := nullable.NewNullNullable[uuid.UUID]()
companyIDParam := g.PostForm("companyID")
if len(companyIDParam) > 0 {
cid, err := uuid.Parse(companyIDParam)
if err != nil {
a.Logger.Debugw("failed to parse company id",
"error", err,
)
a.Response.ValidationFailed(g, "companyID", err)
return
}
companyID.Set(cid)
}
nameParam, err := vo.NewOptionalString127(g.PostForm("name"))
if err != nil {
a.Logger.Debugw("failed to parse name",
"name", g.PostForm("name"),
"error", err,
)
a.Response.ValidationFailed(g, "name", err)
return
}
name := nullable.NewNullableWithValue(*nameParam)
descriptionParam, err := vo.NewOptionalString255(g.PostForm("description"))
if err != nil {
a.Logger.Debugw("failed to parse description",
"error", err,
)
a.Response.ValidationFailed(g, "description", err)
return
}
description := nullable.NewNullableWithValue(*descriptionParam)
embeddedContent := nullable.NewNullableWithValue(false)
embeddedContentString := g.PostForm("embeddedContent")
if strings.ToLower(embeddedContentString) == "true" {
embeddedContent.Set(true)
}
attachments := []*model.Attachment{}
for _, file := range multipartData.File["files"] {
// TODO multi user validate that the company id is the same as the session company id or that the session is a super admin
// check max file size
maxFile, err := a.OptionService.GetOption(g, session, data.OptionKeyMaxFileUploadSizeMB)
if ok := a.handleErrors(g, err); !ok {
return
}
ok, err := utils.CompareFileSizeFromString(file.Size, maxFile.Value.String())
if err != nil {
a.Logger.Errorw("failed to compare file size",
"error", err,
)
}
if !ok {
a.Logger.Debugw("file too large",
"filename", file.Filename,
"size", file.Size,
"maxSize", maxFile.Value.String(),
)
a.Response.ValidationFailed(
g,
"File",
fmt.Errorf("'%s' is too large", utils.ReadableFileName(file.Filename)),
)
return
}
fileNameParam, err := vo.NewFileName(file.Filename)
if err != nil {
a.Logger.Debugw("failed to parse filename",
"error", err,
)
a.Response.ValidationFailed(g, "filename", err)
return
}
fileName := nullable.NewNullableWithValue(*fileNameParam)
attachment := model.Attachment{
CompanyID: companyID,
Name: name,
Description: description,
EmbeddedContent: embeddedContent,
File: file,
FileName: fileName,
}
if err := attachment.Validate(); err != nil {
a.Logger.Debugw("failed to validate attachment",
"attachmentName", name,
"error", err,
)
a.Response.ValidationFailed(g, "attachment", err)
return
}
attachments = append(attachments, &attachment)
}
// store the files on disk and in database
createdIDs, err := a.AttachmentService.Create(
g,
session,
attachments,
)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, gin.H{
"ids": createdIDs,
"files_uploaded": len(attachments),
})
}
// GetByID gets an static attachment by id
func (a *Attachment) GetByID(g *gin.Context) {
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
// get the attachment
ctx := g.Request.Context()
attachment, err := a.AttachmentService.GetByID(
ctx,
session,
id,
)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, attachment)
}
// UpdateByID updates an static attachment by id
func (a *Attachment) UpdateByID(g *gin.Context) {
// handle session
session, _, ok := a.handleSession(g)
if !ok {
return
}
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
// parse request
var req model.Attachment
if ok := a.handleParseRequest(g, &req); !ok {
return
}
// update the attachment
ctx := g.Request.Context()
err := a.AttachmentService.UpdateByID(
ctx,
session,
id,
&req,
)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, gin.H{})
}
// RemoveByID removes an static attachment
// if the attachment is a directory, it will be removed recursively
func (a *Attachment) RemoveByID(g *gin.Context) {
// handle session
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
// remove the attachment
ctx := g.Request.Context()
err := a.AttachmentService.DeleteByID(
ctx,
session,
id,
)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, gin.H{})
}
+927
View File
@@ -0,0 +1,927 @@
package controller
import (
"bytes"
"encoding/csv"
"time"
"github.com/go-errors/errors"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/build"
"github.com/phishingclub/phishingclub/cache"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/embedded"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/utils"
)
// allowedCampaignColumns is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var allowedCampaignColumns = map[string]string{
"created_at": repository.TableColumn(database.CAMPAIGN_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.CAMPAIGN_TABLE, "updated_at"),
"closed_at": repository.TableColumn(database.CAMPAIGN_TABLE, "closed_at"),
"close_at": repository.TableColumn(database.CAMPAIGN_TABLE, "close_at"),
"anonymized_at": repository.TableColumn(database.CAMPAIGN_TABLE, "anonymized_at"),
"is_test": repository.TableColumn(database.CAMPAIGN_TABLE, "is_test"),
"send_start_at": repository.TableColumn(database.CAMPAIGN_TABLE, "send_start_at"),
"send_end_at": repository.TableColumn(database.CAMPAIGN_TABLE, "send_end_at"),
"template": repository.TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "name"),
"name": repository.TableColumn(database.CAMPAIGN_TABLE, "name"),
}
// campaignEventColumns is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var campaignEventColumns = map[string]string{
"created_at": repository.TableColumn(database.CAMPAIGN_EVENT_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.CAMPAIGN_EVENT_TABLE, "updated_at"),
"details": repository.TableColumn(database.CAMPAIGN_EVENT_TABLE, "data"),
"ip": repository.TableColumn(database.CAMPAIGN_EVENT_TABLE, "ip_address"),
"user-agent": repository.TableColumn(database.CAMPAIGN_EVENT_TABLE, "user_agent"),
"email": repository.TableColumn(database.RECIPIENT_TABLE, "email"),
"first_name": repository.TableColumn(database.RECIPIENT_TABLE, "first_name"),
"last_name": repository.TableColumn(database.RECIPIENT_TABLE, "last_name"),
"event": repository.TableColumn(database.EVENT_TABLE, "name"),
}
// allowedCampaignRecipientColumns is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var allowedCampaignRecipientColumns = map[string]string{
"created_at": "campaign_recipients.created_at",
"updated_at": "campaign_recipients.updated_at",
"send_at": "campaign_recipients.send_at",
"sent_at": "campaign_recipients.sent_at",
"cancelled_at": "campaign_recipients.cancelled_at",
"status": "campaign_recipients.notable_event_id",
"first_name": "recipients.first_name",
"last_name": "recipients.last_name",
"email": "recipients.email",
}
// Campaign is a Campaign controller
type Campaign struct {
Common
CampaignService *service.Campaign
}
// CloseCampaignByID closes campaign
func (c *Campaign) CloseCampaignByID(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// close campaigns
err := c.CampaignService.CloseCampaignByID(
g.Request.Context(),
session,
id,
)
// handle responses
if errors.Is(err, errs.ErrCampaignAlreadyClosed) {
c.Response.ValidationFailed(g, "", err)
return
}
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// Create creates a new campaign
func (c *Campaign) Create(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse req
var req model.Campaign
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// create and schedule the campaign
id, err := c.CampaignService.Create(g.Request.Context(), session, &req)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{
"id": id.String(),
})
}
// GetAllEventTypes gets all event types
func (c *Campaign) GetAllEventTypes(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// check permissions
isAuthorized, err := service.IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
_ = handleServerError(g, c.Response, err)
return
}
if !isAuthorized {
c.Response.Unauthorized(g)
return
}
// get all event names
// we pick them out from the in memory cache
ev := []gin.H{}
for name, id := range cache.EventIDByName {
ev = append(ev, gin.H{
"id": id,
"name": name,
})
}
c.Response.OK(g, ev)
}
// GetByID gets a campaign by its id
func (c *Campaign) GetByID(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// get the campaign that needs to be updated
campaign, err := c.CampaignService.GetByID(
g.Request.Context(),
session,
id,
&repository.CampaignOption{
WithRecipientGroups: true,
WithAllowDeny: true,
WithDenyPage: true,
},
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, campaign)
}
// GetByName gets a campaign by name
func (c *Campaign) GetByName(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
name := g.Param("name")
if !ok {
return
}
// get the campaign that needs to be updated
campaign, err := c.CampaignService.GetByName(
g,
session,
name,
companyID,
&repository.CampaignOption{
WithRecipientGroups: true,
WithAllowDeny: true,
WithDenyPage: true,
},
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, campaign)
}
// GetResultStats get campaign result stats
func (c *Campaign) GetResultStats(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// get
stats, err := c.CampaignService.GetResultStats(
g.Request.Context(),
session,
id,
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, stats)
}
// GetCampaignStats get campaign stats
// if no company id is provided it gets the global stats including all companies
func (c *Campaign) GetStats(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
// get
stats, err := c.CampaignService.GetStats(
g.Request.Context(),
session,
companyID,
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, stats)
}
// GetAll gets all campaigns with pagination
func (c *Campaign) GetAll(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.RemapOrderBy(allowedCampaignColumns)
queryArgs.DefaultSortByUpdatedAt()
// get all campaigns
campaigns, err := c.CampaignService.GetAll(
g.Request.Context(),
session,
companyID,
&repository.CampaignOption{
QueryArgs: queryArgs,
WithCampaignTemplate: true,
},
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, campaigns)
}
// GetAll gets all campaigns within dates
func (c *Campaign) GetAllWithinDates(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.RemapOrderBy(allowedCampaignColumns)
queryArgs.DefaultSortByUpdatedAt()
// get start and end date for query
startDate, err := time.Parse(time.RFC3339Nano, g.Query("start"))
if err != nil {
c.Response.ValidationFailed(g, "start", err)
return
}
endDate, err := time.Parse(time.RFC3339Nano, g.Query("end"))
if err != nil {
c.Response.ValidationFailed(g, "end", err)
return
}
// get all campaigns
campaigns, err := c.CampaignService.GetAllWithinDates(
g.Request.Context(),
session,
startDate,
endDate,
companyID,
&repository.CampaignOption{
QueryArgs: queryArgs,
WithCampaignTemplate: true,
},
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, campaigns)
}
// GetAllActive gets all active campaigns with pagination
// if no company id is given it gets all globals including company
func (c *Campaign) GetAllActive(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.RemapOrderBy(allowedCampaignColumns)
if queryArgs.OrderBy == "" {
queryArgs.OrderBy = "send_start_at"
queryArgs.Desc = false
}
// get all campaigns
campaigns, err := c.CampaignService.GetAllActive(
g.Request.Context(),
session,
companyID,
&repository.CampaignOption{
QueryArgs: queryArgs,
WithCompany: true,
WithCampaignTemplate: true,
},
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, campaigns)
}
// GetAllUpcoming gets all upcoming campaigns with pagination
// if no company id is given it gets all globals including company
func (c *Campaign) GetAllUpcoming(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.RemapOrderBy(allowedCampaignColumns)
if queryArgs.OrderBy == "" {
queryArgs.OrderBy = "send_start_at"
queryArgs.Desc = false
}
// get all campaigns
campaigns, err := c.CampaignService.GetAllUpcoming(
g.Request.Context(),
session,
companyID,
&repository.CampaignOption{
QueryArgs: queryArgs,
WithCompany: true,
WithCampaignTemplate: true,
},
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, campaigns)
}
// GetAllFinished gets all finished campaigns with pagination
// if no company id is given it gets all globals including company
func (c *Campaign) GetAllFinished(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.RemapOrderBy(allowedCampaignColumns)
if queryArgs.OrderBy == "" {
queryArgs.OrderBy = "send_start_at"
queryArgs.Desc = true
}
// get all campaigns
campaigns, err := c.CampaignService.GetAllFinished(
g.Request.Context(),
session,
companyID,
&repository.CampaignOption{
QueryArgs: queryArgs,
WithCompany: true,
WithCampaignTemplate: true,
},
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, campaigns)
}
// GetEventsByCampaignID gets events by campaign id
func (c *Campaign) GetEventsByCampaignID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
// remap query args
queryArgs.RemapOrderBy(campaignEventColumns)
// set default sort order to desc
sortOrder := g.DefaultQuery("sortOrder", "desc")
if sortOrder == "desc" {
queryArgs.Desc = true
}
var since *time.Time
s, err := time.Parse(time.RFC3339Nano, g.Query("since"))
if err == nil {
since = &s
}
// get events by campaign id
events, err := c.CampaignService.GetEventsByCampaignID(
g.Request.Context(),
session,
id,
queryArgs,
since,
nil,
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, events)
}
// ExportEventsAsCSV exports a all campaign events as a CSV
func (c *Campaign) ExportEventsAsCSV(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByCreatedAt()
queryArgs.RemapOrderBy(campaignEventColumns)
sortOrder := g.DefaultQuery("sortOrder", "desc")
if sortOrder == "desc" {
queryArgs.Desc = true
}
// get all rows
queryArgs.Limit = 0
queryArgs.Offset = 0
// get events by campaign id
events, err := c.CampaignService.GetEventsByCampaignID(
g.Request.Context(),
session,
id,
queryArgs,
nil,
nil,
)
if ok := c.handleErrors(g, err); !ok {
return
}
buffer := &bytes.Buffer{}
writer := csv.NewWriter(buffer)
headers := []string{
"Created at",
"Recipient name",
"Recipient email",
"Event name",
"Event Details",
"User-Agent",
"IP",
}
err = writer.Write(headers)
if ok := c.handleErrors(g, err); !ok {
return
}
for _, event := range events.Rows {
row := []string{}
// if the recipient has been deleted or anonymized
if event.Recipient == nil {
row = []string{
utils.CSVFromDate(event.CreatedAt),
"anonymized",
"anonymized",
utils.CSVRemoveFormulaStart(cache.EventNameByID[event.EventID.String()]),
utils.CSVRemoveFormulaStart(event.Data.String()),
utils.CSVRemoveFormulaStart(event.UserAgent.String()),
utils.CSVRemoveFormulaStart(event.IP.String()),
}
} else {
row = []string{
utils.CSVFromDate(event.CreatedAt),
utils.CSVRemoveFormulaStart(event.Recipient.FirstName.MustGet().String()),
utils.CSVRemoveFormulaStart(event.Recipient.LastName.MustGet().String()),
utils.CSVRemoveFormulaStart(event.Recipient.Email.MustGet().String()),
utils.CSVRemoveFormulaStart(cache.EventNameByID[event.EventID.String()]),
utils.CSVRemoveFormulaStart(event.Data.String()),
utils.CSVRemoveFormulaStart(event.UserAgent.String()),
utils.CSVRemoveFormulaStart(event.IP.String()),
}
}
err = writer.Write(row)
if ok := c.handleErrors(g, err); !ok {
return
}
}
c.responseWithCSV(g, buffer, writer, "campaign_events.csv")
}
// ExportSubmissionsAsCSV exports all campaign submissions as a CSV
func (c *Campaign) ExportSubmissionsAsCSV(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByCreatedAt()
queryArgs.RemapOrderBy(campaignEventColumns)
sortOrder := g.DefaultQuery("sortOrder", "desc")
if sortOrder == "desc" {
queryArgs.Desc = true
}
// get all rows
queryArgs.Limit = 0
queryArgs.Offset = 0
// filter for submission events only
submissionEventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA]
eventTypeFilter := []string{submissionEventID.String()}
// get submission events by campaign id
events, err := c.CampaignService.GetEventsByCampaignID(
g.Request.Context(),
session,
id,
queryArgs,
nil,
eventTypeFilter,
)
if ok := c.handleErrors(g, err); !ok {
return
}
buffer := &bytes.Buffer{}
writer := csv.NewWriter(buffer)
headers := []string{
"Submitted at",
"Recipient first name",
"Recipient last name",
"Recipient email",
"Submitted data",
"User-Agent",
"IP",
}
err = writer.Write(headers)
if ok := c.handleErrors(g, err); !ok {
return
}
for _, event := range events.Rows {
row := []string{}
// if the recipient has been deleted or anonymized
if event.Recipient == nil {
row = []string{
utils.CSVFromDate(event.CreatedAt),
"anonymized",
"anonymized",
"anonymized",
utils.CSVRemoveFormulaStart(event.Data.String()),
utils.CSVRemoveFormulaStart(event.UserAgent.String()),
utils.CSVRemoveFormulaStart(event.IP.String()),
}
} else {
row = []string{
utils.CSVFromDate(event.CreatedAt),
utils.CSVRemoveFormulaStart(event.Recipient.FirstName.MustGet().String()),
utils.CSVRemoveFormulaStart(event.Recipient.LastName.MustGet().String()),
utils.CSVRemoveFormulaStart(event.Recipient.Email.MustGet().String()),
utils.CSVRemoveFormulaStart(event.Data.String()),
utils.CSVRemoveFormulaStart(event.UserAgent.String()),
utils.CSVRemoveFormulaStart(event.IP.String()),
}
}
err = writer.Write(row)
if ok := c.handleErrors(g, err); !ok {
return
}
}
c.responseWithCSV(g, buffer, writer, "campaign_submissions.csv")
}
func (c *Campaign) GetCampaignEmail(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// get email
email, err := c.CampaignService.GetCampaignEmailBody(
g.Request.Context(),
session,
id,
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, email)
}
// GetCampaignURL gets a recipient landing page URL
func (c *Campaign) GetCampaignURL(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
url, err := c.CampaignService.GetLandingPageURLByCampaignRecipientID(
g.Request.Context(),
session,
id,
)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, url)
}
// GetRecipientsByCampaignID gets recipients by campaign id
func (c *Campaign) GetRecipientsByCampaignID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// endpoints is handled a bit differently and allows to
// fetch an unlimited amount of rows if no offset and limit is set.
// TODO this endpoint should be changed to a Result<T> so we fetch the rows as needed.
offset := g.DefaultQuery("offset", "")
limit := g.DefaultQuery("limit", "")
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
// special case to retrieve ALL rows
if offset == "" && limit == "" {
queryArgs.Offset = 0
queryArgs.Limit = 0
}
// remap query args
queryArgs.DefaultSortBy("created_at")
queryArgs.RemapOrderBy(allowedCampaignRecipientColumns)
// get recipients by campaign id
recipients, err := c.CampaignService.GetRecipientsByCampaignID(
g.Request.Context(),
session,
id,
&repository.CampaignRecipientOption{
QueryArgs: queryArgs,
WithRecipient: true,
},
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, recipients)
}
// TrackingPixel returns a tracking pixel
func (c *Campaign) TrackingPixel(g *gin.Context) {
// get the campaign recipient id from the query
campaignRecipientID := g.Query("upn") // expect the campaign recipient id to be in here
if campaignRecipientID == "" {
c.Response.NotFound(g)
return
}
campaignRecipientUUID, err := uuid.Parse(campaignRecipientID)
if err != nil {
c.Logger.Debugw(errs.MsgFailedToParseRequest,
"error", err,
)
c.Response.NotFound(g)
return
}
err = c.CampaignService.SaveTrackingPixelLoaded(
g,
&campaignRecipientUUID,
)
if err != nil {
c.Logger.Debugw("failed to save tracking pixel loaded event",
"error", err,
)
c.Response.NotFound(g)
return
}
g.Header("Content-Type", "image/gif")
if !build.Flags.Production {
g.File("./embedded/tracking-pixel/sendgrid/open.gif")
return
}
_, err = g.Writer.Write(embedded.TrackingPixel)
if err != nil {
c.Logger.Errorw("failed to write tracking pixel", "error", err)
}
}
// UpdateByID updates a campaign by its id
func (c *Campaign) UpdateByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
var req model.Campaign
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// update the campaign
err := c.CampaignService.UpdateByID(g.Request.Context(), session, id, &req)
if ok := c.handleErrors(g, err); !ok {
return
}
// handle responses
c.Response.OK(g, gin.H{})
}
// SetSentAtByCampaignRecipientID sets the sent at time for a campaign recipient
func (c *Campaign) SetSentAtByCampaignRecipientID(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// set sent at time
err := c.CampaignService.SetSentAtByCampaignRecipientID(g.Request.Context(), session, id)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// DeleteByID deletes a campaign by its id
func (c *Campaign) DeleteByID(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// delete
err := c.CampaignService.DeleteByID(g, session, id)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// AnonymizeByID anonymizes a campaign by its id
func (c *Campaign) AnonymizeByID(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// anonymize
err := c.CampaignService.AnonymizeByID(g, session, id)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// GetCampaignStats gets campaign statistics by campaign ID
func (c *Campaign) GetCampaignStats(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// get stats
stats, err := c.CampaignService.GetCampaignStats(g.Request.Context(), session, id)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, stats)
}
// GetAllCampaignStats gets all campaign statistics with pagination
func (c *Campaign) GetAllCampaignStats(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.RemapOrderBy(allowedCampaignColumns)
companyID := companyIDFromRequestQuery(g)
// get stats
stats, err := c.CampaignService.GetAllCampaignStats(g.Request.Context(), session, queryArgs, companyID)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, stats)
}
+199
View File
@@ -0,0 +1,199 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
)
// CampaignTemplateColumnsMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var CampaignTemplateColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "updated_at"),
"name": repository.TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "name"),
"after_landing_page_redirect_url": repository.TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "after_landing_page_redirect_url"),
"is_complete": repository.TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "is_usable"),
"domain": repository.TableColumn(database.DOMAIN_TABLE, "name"),
"before_landing_page": repository.TableColumn("before_landing_page", "name"),
"landing_page": repository.TableColumn("landing_page", "name"),
"after_landing_page": repository.TableColumn("after_landing_page", "name"),
"smtp": repository.TableColumn(database.SMTP_CONFIGURATION_TABLE, "name"),
"api_sender": repository.TableColumn(database.API_SENDER_TABLE, "name"),
"email": repository.TableColumn(database.EMAIL_TABLE, "name"),
}
// CampaignTemplate is a campaign template controller
type CampaignTemplate struct {
Common
CampaignTemplateService *service.CampaignTemplate
}
// Create creates a campaign template
func (c *CampaignTemplate) Create(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
var req model.CampaignTemplate
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// save
ctx := g.Request.Context()
id, err := c.CampaignTemplateService.Create(ctx, session, &req)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
gin.H{
"id": id.String(),
},
)
}
// GetByID gets a campaign template by id
func (c *CampaignTemplate) GetByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// check if full data set should be loaded
options := &repository.CampaignTemplateOption{}
_, ok = g.GetQuery("full")
if ok {
options = &repository.CampaignTemplateOption{
WithDomain: true,
WithSMTPConfiguration: true,
WithAPISender: true,
WithEmail: true,
WithLandingPage: true,
WithBeforeLandingPage: true,
WithAfterLandingPage: true,
WithIdentifier: true,
}
}
// get
ctx := g.Request.Context()
campaignTemplate, err := c.CampaignTemplateService.GetByID(
ctx,
session,
id,
options,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, campaignTemplate)
}
// GetAll gets all campaign templates
func (c *CampaignTemplate) GetAll(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
pagination, ok := c.handlePagination(g)
if !ok {
return
}
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
usableOnlyQuery := g.Query("usableOnly")
usableOnly := false
if usableOnlyQuery == "true" {
usableOnly = true
}
queryArgs.DefaultSortByUpdatedAt()
queryArgs.RemapOrderBy(CampaignTemplateColumnsMap)
columns := repository.SelectTable(database.CAMPAIGN_TEMPLATE_TABLE)
templates, err := c.CampaignTemplateService.GetAll(
g,
session,
companyID,
pagination,
&repository.CampaignTemplateOption{
QueryArgs: queryArgs,
Columns: columns,
WithDomain: true,
WithSMTPConfiguration: true,
WithAPISender: true,
WithEmail: true,
WithLandingPage: true,
WithBeforeLandingPage: true,
WithAfterLandingPage: true,
UsableOnly: usableOnly,
},
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, templates)
}
// UpdateByID updates a campaign template by id
func (c *CampaignTemplate) UpdateByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
var req model.CampaignTemplate
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// update
err := c.CampaignTemplateService.UpdateByID(
g.Request.Context(),
session,
id,
&req,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// DeleteByID deletes a campaign template by id
func (c *CampaignTemplate) DeleteByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// delete
err := c.CampaignTemplateService.DeleteByID(g, session, id)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
+594
View File
@@ -0,0 +1,594 @@
package controller
import (
"archive/zip"
"bytes"
"encoding/csv"
"fmt"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/api"
"github.com/phishingclub/phishingclub/cache"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/utils"
"github.com/phishingclub/phishingclub/vo"
)
// DomainColumnsMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var CompanyColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.COMPANY_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.COMPANY_TABLE, "updated_at"),
"name": repository.TableColumn(database.COMPANY_TABLE, "name"),
}
// Company is a Company controller
type Company struct {
Common
CompanyService *service.Company
CampaignService *service.Campaign
RecipientService *service.Recipient
}
// GetByID gets a company by id
func (c *Company) GetByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
companyID, err := uuid.Parse(g.Param("id"))
if err != nil {
// ignore err as caused by bad user input
_ = err
c.Response.BadRequestMessage(g, api.InvalidCompanyID)
return
}
// get company
ctx := g.Request.Context()
company, err := c.CompanyService.GetByID(
ctx,
session,
&companyID,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, company)
}
// ExportByCompanyID outputs a CSV with all events related to the recipient
func (c *Company) ExportByCompanyID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
companyID, ok := c.handleParseIDParam(g)
if !ok {
return
}
// get the company exported
company, err := c.CompanyService.GetByID(
g,
session,
companyID,
)
// create ZIP file in memory
zipBuffer := new(bytes.Buffer)
zipWriter := zip.NewWriter(zipBuffer)
zipFileName := fmt.Sprintf("company_export_%s.zip", company.Name.MustGet().String())
// add company data to zip
{
buffer := &bytes.Buffer{}
writer := csv.NewWriter(buffer)
headers := []string{
"Created at",
"Updated at",
"Name",
}
err = writer.Write(headers)
if ok := c.handleErrors(g, err); !ok {
return
}
row := []string{
utils.CSVFromDate(company.CreatedAt),
utils.CSVFromDate(company.UpdatedAt),
utils.CSVRemoveFormulaStart(utils.NullableToString(company.Name)),
}
err = writer.Write(row)
if ok := c.handleErrors(g, err); !ok {
return
}
writer.Flush()
// add to zip
f, err := zipWriter.Create("company.csv")
if ok := c.handleErrors(g, err); !ok {
return
}
_, err = f.Write(buffer.Bytes())
if ok := c.handleErrors(g, err); !ok {
return
}
}
// add recipients to zip
{
// get the recipients
recipients, err := c.RecipientService.GetByCompanyID(
g,
session,
companyID,
&repository.RecipientOption{
WithCompany: true,
WithGroups: true,
},
)
if ok := c.handleErrors(g, err); !ok {
return
}
// write a csv buffer with all recipient and their groups
buffer := &bytes.Buffer{}
writer := csv.NewWriter(buffer)
headers := []string{
"Created at",
"Updated at",
"Email",
"Phone",
"Extra Identifier",
"Name",
"Position",
"Department",
"City",
"Country",
"Misc",
}
// find the recipient with the most groups and add that number of
// extra headers for groups
maxGroups := 0
for _, recipient := range recipients.Rows {
groups, _ := recipient.Groups.Get()
if groupLen := len(groups); groupLen > maxGroups {
maxGroups = groupLen
}
}
for i := 1; i <= maxGroups; i++ {
headers = append(headers, fmt.Sprintf("Group %d", i))
}
err = writer.Write(headers)
if ok := c.handleErrors(g, err); !ok {
return
}
for _, recipient := range recipients.Rows {
groups, _ := recipient.Groups.Get()
row := []string{
utils.CSVFromDate(recipient.CreatedAt),
utils.CSVFromDate(recipient.UpdatedAt),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Email)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Phone)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.ExtraIdentifier)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.FirstName)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.LastName)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Position)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Department)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.City)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Country)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Misc)),
}
for _, group := range groups {
row = append(row, group.Name.MustGet().String())
}
err = writer.Write(row)
if ok := c.handleErrors(g, err); !ok {
return
}
writer.Flush()
}
// add to zip
f, err := zipWriter.Create("recipients.csv")
if ok := c.handleErrors(g, err); !ok {
return
}
_, err = f.Write(buffer.Bytes())
if ok := c.handleErrors(g, err); !ok {
return
}
}
// get all campaigns all recipient events
{
campaigns, err := c.CampaignService.GetByCompanyID(
g,
session,
companyID,
&repository.CampaignOption{},
)
for _, campaign := range campaigns.Rows {
headers := []string{
"Campaign",
"Created at",
"Recipient name",
"Recipient email",
"Event name",
"Event Details",
"User-Agent",
"IP",
}
buffer := &bytes.Buffer{}
writer := csv.NewWriter(buffer)
err = writer.Write(headers)
if ok := c.handleErrors(g, err); !ok {
return
}
queryArgs := vo.QueryArgs{}
queryArgs.OrderBy = repository.TableColumn(
database.CAMPAIGN_EVENT_TABLE,
"created_at",
)
sortOrder := g.DefaultQuery("sortOrder", "desc")
if sortOrder == "desc" {
queryArgs.Desc = true
}
// get all rows
queryArgs.Limit = 0
queryArgs.Offset = 0
// get events by campaign id
cid := campaign.ID.MustGet()
events, err := c.CampaignService.GetEventsByCampaignID(
g.Request.Context(),
session,
&cid,
&queryArgs,
nil,
nil,
)
if ok := c.handleErrors(g, err); !ok {
return
}
for _, event := range events.Rows {
firstName := "anonymized"
lastName := "anonymized"
recpEmail := "anonymized"
if event.Recipient != nil {
firstName = event.Recipient.FirstName.MustGet().String()
lastName = event.Recipient.LastName.MustGet().String()
recpEmail = event.Recipient.Email.MustGet().String()
}
row := []string{
utils.CSVRemoveFormulaStart(campaign.Name.MustGet().String()),
utils.CSVFromDate(event.CreatedAt),
utils.CSVRemoveFormulaStart(firstName),
utils.CSVRemoveFormulaStart(lastName),
utils.CSVRemoveFormulaStart(recpEmail),
utils.CSVRemoveFormulaStart(cache.EventNameByID[event.EventID.String()]),
utils.CSVRemoveFormulaStart(event.Data.String()),
utils.CSVRemoveFormulaStart(event.UserAgent.String()),
utils.CSVRemoveFormulaStart(event.IP.String()),
}
err = writer.Write(row)
if ok := c.handleErrors(g, err); !ok {
return
}
}
// add a new subdirectory wit the event file in the zip
writer.Flush()
// add to zip
filename := fmt.Sprintf("campaign_events/%s.csv", campaign.Name.MustGet().String())
f, err := zipWriter.Create(filename)
if ok := c.handleErrors(g, err); !ok {
return
}
_, err = f.Write(buffer.Bytes())
if ok := c.handleErrors(g, err); !ok {
return
}
}
}
// close zip
err = zipWriter.Close()
if ok := c.handleErrors(g, err); !ok {
return
}
c.responseWithZIP(g, zipBuffer, zipFileName)
}
// ExportShared outputs a CSV with all shared recipients and events
func (c *Company) ExportShared(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// create ZIP file in memory
zipBuffer := new(bytes.Buffer)
zipWriter := zip.NewWriter(zipBuffer)
zipFileName := "shared_export_%s.zip"
// add recipients to zip
{
// get the recipients
recipients, err := c.RecipientService.GetByCompanyID(
g,
session,
nil,
&repository.RecipientOption{
WithCompany: true,
WithGroups: true,
},
)
if ok := c.handleErrors(g, err); !ok {
return
}
// write a csv buffer with all recipient and their groups
buffer := &bytes.Buffer{}
writer := csv.NewWriter(buffer)
headers := []string{
"Created at",
"Updated at",
"Email",
"Phone",
"Extra Identifier",
"Name",
"Position",
"Department",
"City",
"Country",
"Misc",
}
// find the recipient with the most groups and add that number of
// extra headers for groups
maxGroups := 0
for _, recipient := range recipients.Rows {
groups, _ := recipient.Groups.Get()
if groupLen := len(groups); groupLen > maxGroups {
maxGroups = groupLen
}
}
for i := 1; i <= maxGroups; i++ {
headers = append(headers, fmt.Sprintf("Group %d", i))
}
err = writer.Write(headers)
if ok := c.handleErrors(g, err); !ok {
return
}
for _, recipient := range recipients.Rows {
groups, _ := recipient.Groups.Get()
row := []string{
utils.CSVFromDate(recipient.CreatedAt),
utils.CSVFromDate(recipient.UpdatedAt),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Email)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Phone)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.ExtraIdentifier)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.FirstName)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.LastName)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Position)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Department)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.City)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Country)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Misc)),
}
for _, group := range groups {
row = append(row, group.Name.MustGet().String())
}
err = writer.Write(row)
if ok := c.handleErrors(g, err); !ok {
return
}
writer.Flush()
}
// add to zip
f, err := zipWriter.Create("recipients.csv")
if ok := c.handleErrors(g, err); !ok {
return
}
_, err = f.Write(buffer.Bytes())
if ok := c.handleErrors(g, err); !ok {
return
}
}
// get all campaigns all recipient events
{
campaigns, err := c.CampaignService.GetByCompanyID(
g,
session,
nil,
&repository.CampaignOption{},
)
for _, campaign := range campaigns.Rows {
headers := []string{
"Campaign",
"Created at",
"Recipient name",
"Recipient email",
"Event name",
"Event Details",
"User-Agent",
"IP",
}
buffer := &bytes.Buffer{}
writer := csv.NewWriter(buffer)
err = writer.Write(headers)
if ok := c.handleErrors(g, err); !ok {
return
}
queryArgs := vo.QueryArgs{}
queryArgs.OrderBy = repository.TableColumn(
database.CAMPAIGN_EVENT_TABLE,
"created_at",
)
sortOrder := g.DefaultQuery("sortOrder", "desc")
if sortOrder == "desc" {
queryArgs.Desc = true
}
// get all rows
queryArgs.Limit = 0
queryArgs.Offset = 0
// get events by campaign id
cid := campaign.ID.MustGet()
events, err := c.CampaignService.GetEventsByCampaignID(
g.Request.Context(),
session,
&cid,
&queryArgs,
nil,
nil,
)
if ok := c.handleErrors(g, err); !ok {
return
}
for _, event := range events.Rows {
firstName := "anonymized"
lastName := "anonymized"
recpEmail := "anonymized"
if event.Recipient != nil {
firstName = event.Recipient.FirstName.MustGet().String()
lastName = event.Recipient.LastName.MustGet().String()
recpEmail = event.Recipient.Email.MustGet().String()
}
row := []string{
utils.CSVRemoveFormulaStart(campaign.Name.MustGet().String()),
utils.CSVFromDate(event.CreatedAt),
utils.CSVRemoveFormulaStart(firstName),
utils.CSVRemoveFormulaStart(lastName),
utils.CSVRemoveFormulaStart(recpEmail),
utils.CSVRemoveFormulaStart(cache.EventNameByID[event.EventID.String()]),
utils.CSVRemoveFormulaStart(event.Data.String()),
utils.CSVRemoveFormulaStart(event.UserAgent.String()),
utils.CSVRemoveFormulaStart(event.IP.String()),
}
err = writer.Write(row)
if ok := c.handleErrors(g, err); !ok {
return
}
}
// add a new subdirectory wit the event file in the zip
writer.Flush()
// add to zip
filename := fmt.Sprintf("campaign_events/%s.csv", campaign.Name.MustGet().String())
f, err := zipWriter.Create(filename)
if ok := c.handleErrors(g, err); !ok {
return
}
_, err = f.Write(buffer.Bytes())
if ok := c.handleErrors(g, err); !ok {
return
}
}
}
// close zip
err := zipWriter.Close()
if ok := c.handleErrors(g, err); !ok {
return
}
c.responseWithZIP(g, zipBuffer, zipFileName)
}
// ChangeName changes a company name
func (c *Company) ChangeName(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
var req model.Company
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// change company name
err := c.CompanyService.UpdateByID(
g,
session,
id,
&req,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, nil)
}
// SoftDelete soft deletes a company
func (c *Company) DeleteByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// TODO company delete should FAIL if it has any relations to anything
// delete company
_, err := c.CompanyService.DeleteByID(g, session, id)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// Create creates a company
func (c *Company) Create(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse req
var req model.Company
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// save company
ctx := g.Request.Context()
company, err := c.CompanyService.Create(
ctx,
session,
&req,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{
"id": company.ID,
})
}
// GetAll gets all companies with pagination
func (c *Company) GetAll(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
queryArgs.RemapOrderBy(CompanyColumnsMap)
// get companies
ctx := g.Request.Context()
companies, err := c.CompanyService.GetAll(
ctx,
session,
queryArgs,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, companies)
}
+219
View File
@@ -0,0 +1,219 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/vo"
)
// DomainColumnsMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var DomainColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.DOMAIN_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.DOMAIN_TABLE, "updated_at"),
"hosting_website": repository.TableColumn(database.DOMAIN_TABLE, "host_website"),
"redirects": repository.TableColumn(database.DOMAIN_TABLE, "redirect_url"),
}
// Domain
type Domain struct {
Common
DomainService *service.Domain
}
// Create creates a domain
func (d *Domain) Create(g *gin.Context) {
session, _, ok := d.handleSession(g)
if !ok {
return
}
// parse request
var req model.Domain
if ok := d.handleParseRequest(g, &req); !ok {
return
}
// save domain
id, err := d.DomainService.Create(g, session, &req)
// handle response
if ok := d.handleErrors(g, err); !ok {
return
}
d.Response.OK(
g,
gin.H{
"id": id,
},
)
}
// GetAll gets domains
func (d *Domain) GetAll(g *gin.Context) {
// handle session
session, _, ok := d.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := d.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
queryArgs.RemapOrderBy(DomainColumnsMap)
// get domain
domains, err := d.DomainService.GetAll(
companyID,
g.Request.Context(),
session,
queryArgs,
true, // TODO there might not be any reason to retrieve the full relation here - optimize by removing it (false)
)
if ok := d.handleErrors(g, err); !ok {
return
}
d.Response.OK(g, domains)
}
// GetAllOverview gets domains with limited data
func (d *Domain) GetAllOverview(g *gin.Context) {
// handle session
session, _, ok := d.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := d.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
queryArgs.RemapOrderBy(DomainColumnsMap)
// get domains
domains, err := d.DomainService.GetAllOverview(
companyID,
g.Request.Context(),
session,
queryArgs,
)
if ok := d.handleErrors(g, err); !ok {
return
}
d.Response.OK(g, domains)
}
// GetByID gets a domain by id
func (d *Domain) GetByID(g *gin.Context) {
// handle session
session, _, ok := d.handleSession(g)
if !ok {
return
}
// parse request
id, ok := d.handleParseIDParam(g)
if !ok {
return
}
// get domain
ctx := g.Request.Context()
domain, err := d.DomainService.GetByID(
ctx,
session,
id,
&repository.DomainOption{
WithCompany: true,
},
)
if ok := d.handleErrors(g, err); !ok {
return
}
d.Response.OK(g, domain)
}
// GetByName gets a domain by name
func (d *Domain) GetByName(g *gin.Context) {
// handle session
session, _, ok := d.handleSession(g)
if !ok {
return
}
// parse request
name, err := vo.NewString255(g.Param("domain"))
if ok := d.handleErrors(g, err); !ok {
return
}
// get domain
ctx := g.Request.Context()
domain, err := d.DomainService.GetByName(
ctx,
session,
name,
&repository.DomainOption{},
)
if ok := d.handleErrors(g, err); !ok {
return
}
d.Response.OK(g, domain)
}
// UpdateByID updates a domain by id
func (d *Domain) UpdateByID(g *gin.Context) {
// handle session
session, _, ok := d.handleSession(g)
if !ok {
return
}
// parse request
id, ok := d.handleParseIDParam(g)
if !ok {
return
}
var req model.Domain
if ok := d.handleParseRequest(g, &req); !ok {
return
}
// update domain
err := d.DomainService.UpdateByID(
g,
session,
id,
&req,
)
// handle response
if ok := d.handleErrors(g, err); !ok {
return
}
d.Response.OK(g, gin.H{})
}
// DeleteByID deletes a domain by id
func (d *Domain) DeleteByID(g *gin.Context) {
// handle session
session, _, ok := d.handleSession(g)
if !ok {
return
}
// parse request
id, ok := d.handleParseIDParam(g)
if !ok {
return
}
// delete domain
err := d.DomainService.DeleteByID(
g,
session,
id,
)
// handle response
if ok := d.handleErrors(g, err); !ok {
return
}
d.Response.OK(g, gin.H{})
}
+375
View File
@@ -0,0 +1,375 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/vo"
)
// EmailOrderByMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var EmailOrderByMap = map[string]string{
"created_at": repository.TableColumn(database.EMAIL_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.EMAIL_TABLE, "created_at"),
"name": repository.TableColumn(database.EMAIL_TABLE, "name"),
"mail_from": repository.TableColumn(database.EMAIL_TABLE, "mail_from"),
"from": repository.TableColumn(database.EMAIL_TABLE, "from"),
"subject": repository.TableColumn(database.EMAIL_TABLE, "subject"),
"tracking_pixel": repository.TableColumn(database.EMAIL_TABLE, "add_tracking_pixel"),
}
// AddAttachmentsToEmailRequest is a request to add attachments to a message
type AddAttachmentsToEmailRequest struct {
Attachments []string `json:"ids"` // attachment IDs
}
// RemoveAttachmentFromEmailRequest is a request to remove an attachment from a message
type RemoveAttachmentFromEmailRequest struct {
AttachmentID string `json:"attachmentID"`
}
// SendTestEmailRequest is a request for sending a test of an e-mail
type SendTestEmailRequest struct {
SMTPID *uuid.UUID
DomainID *uuid.UUID
RecipientID *uuid.UUID
}
// Email is a Email controller
type Email struct {
Common
EmailService *service.Email
TemplateService *service.Template
EmailRepository *repository.Email
}
// AddAttachments adds attachments to a email
func (m *Email) AddAttachments(g *gin.Context) {
session, _, ok := m.handleSession(g)
if !ok {
return
}
// parse request
var request AddAttachmentsToEmailRequest
if ok := m.handleParseRequest(g, &request); !ok {
return
}
id, ok := m.handleParseIDParam(g)
if !ok {
return
}
if len(request.Attachments) == 0 {
m.Response.BadRequestMessage(g, "No attachments provided")
return
}
attachmentIDs := []*uuid.UUID{}
for _, idParam := range request.Attachments {
id, err := uuid.Parse(idParam)
if err != nil {
m.Logger.Debugw(errs.MsgFailedToParseUUID,
"error", err,
)
m.Response.BadRequestMessage(g, "Invalid attachment ID")
return
}
attachmentIDs = append(attachmentIDs, &id)
}
// add attachments to email
err := m.EmailService.AddAttachments(
g.Request.Context(),
session,
id,
attachmentIDs,
)
// handle responses
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, gin.H{})
}
// RemoveAttachment removes an attachment from a email
func (m *Email) RemoveAttachment(g *gin.Context) {
// handle session
session, _, ok := m.handleSession(g)
if !ok {
return
}
// parse req
var req RemoveAttachmentFromEmailRequest
if ok := m.handleParseRequest(g, &req); !ok {
return
}
attachmentID, err := uuid.Parse(req.AttachmentID)
if err != nil {
m.Logger.Debugw(errs.MsgFailedToParseUUID,
"error", err,
)
m.Response.BadRequestMessage(g, "Invalid attachment ID")
return
}
emailID, err := uuid.Parse(g.Param("id"))
if err != nil {
m.Logger.Debugw(errs.MsgFailedToParseUUID, "error", err)
m.Response.BadRequestMessage(g, "Invalid message ID")
return
}
// remove attachment from email
err = m.EmailService.RemoveAttachment(
g.Request.Context(),
session,
&emailID,
&attachmentID,
)
// handle responses
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, gin.H{})
}
// Create creates a email
func (m *Email) Create(g *gin.Context) {
session, _, ok := m.handleSession(g)
if !ok {
return
}
// parse req
var req model.Email
if ok := m.handleParseRequest(g, &req); !ok {
return
}
// save email
id, err := m.EmailService.Create(
g,
session,
&req,
)
// handle responses
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, gin.H{
"id": id,
})
}
// SendTestEmail
func (m *Email) SendTestEmail(g *gin.Context) {
session, _, ok := m.handleSession(g)
if !ok {
return
}
// parse request
id, ok := m.handleParseIDParam(g)
if !ok {
return
}
var req SendTestEmailRequest
if ok := m.handleParseRequest(g, &req); !ok {
return
}
// send test email
err := m.EmailService.SendTestEmail(
g,
session,
id,
req.SMTPID,
req.DomainID,
req.RecipientID,
)
// handle responses
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, gin.H{})
}
// GetByID gets a email by ID
func (m *Email) GetByID(g *gin.Context) {
session, _, ok := m.handleSession(g)
if !ok {
return
}
// parse request
id, ok := m.handleParseIDParam(g)
if !ok {
return
}
// get email
email, err := m.EmailService.GetByID(
g.Request.Context(),
session,
id,
)
// handle responses
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, email)
}
// GetContentByID gets a email content by ID
func (m *Email) GetContentByID(g *gin.Context) {
session, _, ok := m.handleSession(g)
if !ok {
return
}
// parse request
id, ok := m.handleParseIDParam(g)
if !ok {
return
}
// get
email, err := m.EmailService.GetByID(
g.Request.Context(),
session,
id,
)
if ok := m.handleErrors(g, err); !ok {
return
}
// build email
domain := &model.Domain{
Name: nullable.NewNullableWithValue(
*vo.NewString255Must("example.test"),
),
}
recipient := model.NewRecipientExample()
campaignRecipient := model.CampaignRecipient{
ID: nullable.NewNullableWithValue(
uuid.New(),
),
Recipient: recipient,
}
apiSender := model.NewAPISenderExample()
emailBody, err := m.TemplateService.CreateMailBody(
"id",
"/foo",
domain,
&campaignRecipient,
email,
apiSender,
)
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, emailBody)
}
// GetAll gets all emails using pagination
func (m *Email) GetAll(g *gin.Context) {
// handle session
session, _, ok := m.handleSession(g)
if !ok {
return
}
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := m.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByName()
queryArgs.RemapOrderBy(EmailOrderByMap)
emails, err := m.EmailService.GetAll(
g.Request.Context(),
session,
companyID,
queryArgs,
)
// handle responses
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, emails)
}
// GetOverviews gets all email overviews using pagination
func (m *Email) GetOverviews(g *gin.Context) {
// handle session
session, _, ok := m.handleSession(g)
if !ok {
return
}
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := m.handleQueryArgs(g)
if !ok {
return
}
queryArgs.RemapOrderBy(EmailOrderByMap)
queryArgs.DefaultSortByName()
emails, err := m.EmailService.GetOverviews(
g.Request.Context(),
session,
companyID,
queryArgs,
)
// handle responses
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, emails)
}
// UpdateByID updates a message by ID
func (m *Email) UpdateByID(g *gin.Context) {
session, _, ok := m.handleSession(g)
if !ok {
return
}
// parse request
id, ok := m.handleParseIDParam(g)
if !ok {
return
}
var email model.Email
if ok := m.handleParseRequest(g, &email); !ok {
return
}
// update message
err := m.EmailService.UpdateByID(
g.Request.Context(),
session,
id,
&email,
)
// handle response
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, gin.H{})
}
// DeleteByID deletes a message by ID
func (m *Email) DeleteByID(g *gin.Context) {
session, _, ok := m.handleSession(g)
if !ok {
return
}
// parse request
id, ok := m.handleParseIDParam(g)
if !ok {
return
}
// delete message
err := m.EmailService.DeleteByID(
g.Request.Context(),
session,
id,
)
// handle response
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, gin.H{})
}
+15
View File
@@ -0,0 +1,15 @@
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
)
// Health is the Health controller
type Health struct{}
// Health returns a 200 OK
func (c *Health) Health(g *gin.Context) {
g.Status(http.StatusOK)
}
+51
View File
@@ -0,0 +1,51 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
)
// IdentifierColumnsMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var IdentifierColumnsMap = map[string]string{
"name": repository.TableColumn(database.IDENTIFIER_TABLE, "name"),
}
type Identifier struct {
Common
IdentifierService *service.Identifier
}
// GetAll gets all identifiers
func (c *Identifier) GetAll(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByName()
// get
identifiers, err := c.IdentifierService.GetAll(
g,
session,
&repository.IdentifierOption{
QueryArgs: queryArgs,
},
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
identifiers,
)
}
+47
View File
@@ -0,0 +1,47 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/service"
)
// Import handles import for templates like emails, landing pages and so on
type Import struct {
Common
ImportService *service.Import
}
// Import imports a .zip file
func (im *Import) Import(g *gin.Context) {
session, _, ok := im.handleSession(g)
if !ok {
return
}
// parse request
f, err := g.FormFile("file")
// handle responses
if ok := im.handleErrors(g, err); !ok {
return
}
// Read forCompany flag from form (treat "1" or "true" as true)
forCompany := false
if v := g.PostForm("forCompany"); v == "1" || v == "true" {
forCompany = true
}
// Read companyID from form data if provided
var companyID *uuid.UUID
if companyIDStr := g.PostForm("companyID"); companyIDStr != "" {
if cid, err := uuid.Parse(companyIDStr); err == nil {
companyID = &cid
}
}
summary, err := im.ImportService.Import(g, session, f, forCompany, companyID)
if ok := im.handleErrors(g, err); !ok {
return
}
im.Response.OK(g, summary)
}
+329
View File
@@ -0,0 +1,329 @@
package controller
import (
"fmt"
"github.com/go-errors/errors"
"github.com/google/uuid"
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/cli"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/password"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/vo"
"golang.org/x/net/context"
"gorm.io/gorm"
)
// SetupAdminRequest is the request for the install action
type SetupAdminRequest struct {
Username string `json:"username" binding:"required"`
UserFullname string `json:"userFullname" binding:"required"`
NewPassword string `json:"newPassword" binding:"required"`
}
// InitialSetup is a controller used by the CLI in the
// initial setup process - it is not an API controller
type InitialSetup struct {
Common
CLIOutputter cli.Outputter
OptionRepository *repository.Option
InstallService *service.InstallSetup
OptionService *service.Option
}
// IsInstalled checks if the application is installed
// not as a
func (is *InitialSetup) IsInstalled(ctx context.Context) (bool, error) {
isInstalledOption, err := is.OptionRepository.GetByKey(
ctx,
data.OptionKeyIsInstalled,
)
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("could not get '%s' option: %w", data.OptionKeyIsInstalled, err)
}
return isInstalledOption.Value.String() == data.OptionValueIsInstalled, nil
}
// HandleInitialSetup handles the initial setup of the application
// this includes inserting the isInstalled option to not installed
// and making or updating the sacrificial admin account
func (is *InitialSetup) HandleInitialSetup(ctx context.Context) error {
// setup option for is installed
isInstalledOption, err := is.OptionRepository.GetByKey(
ctx,
data.OptionKeyIsInstalled,
)
// if the option does not exist, create it
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("%w: could not get '%s' option", err, data.OptionKeyIsInstalled)
}
key := vo.NewString64Must(data.OptionKeyIsInstalled)
value := vo.NewOptionalString1MBMust(data.OptionValueIsNotInstalled)
isInstalledOptionWithoutID := model.Option{
Key: *key,
Value: *value,
}
_, err = is.OptionRepository.Insert(
ctx,
&isInstalledOptionWithoutID,
)
if err != nil {
return fmt.Errorf("%w: could not insert entity for option '%s'", err, data.OptionKeyIsInstalled)
}
isInstalledOption, err = is.OptionRepository.GetByKey(
ctx,
isInstalledOptionWithoutID.Key.String(),
)
if err != nil {
return fmt.Errorf("%w: could not get created '%s' option", err, data.OptionKeyIsInstalled)
}
}
// if no instance ID exists, add it
instanceIDOption, err := is.OptionRepository.GetByKey(
ctx,
data.OptionKeyInstanceID,
)
// if the instance id option does not exist, create it
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("%w: could not get '%s' option", err, data.OptionKeyInstanceID)
}
key := vo.NewString64Must(data.OptionKeyInstanceID)
instanceID := uuid.New()
value := vo.NewOptionalString1MBMust(instanceID.String())
instanceIDOption = &model.Option{
Key: *key,
Value: *value,
}
_, err = is.OptionRepository.Insert(
ctx,
instanceIDOption,
)
if err != nil {
return fmt.Errorf("could not insert instance ID: %w", err)
}
}
// if installation is already complete, return error
if isInstalledOption.Value.String() == data.OptionValueIsInstalled {
return errs.ErrAlreadyInstalled
}
// setup accounts
admin, password, err := is.InstallService.SetupAccounts(ctx)
if err != nil {
return fmt.Errorf("could not setup initial admin account: %w", err)
}
is.CLIOutputter.PrintInitialAdminAccount(
admin.Username.MustGet().String(),
password.String(),
)
return nil
}
// Install is the Install controller used by the API
type Install struct {
Common
UserRepository *repository.User
CompanyRepository *repository.Company
OptionRepository *repository.Option
DB *gorm.DB
PasswordHasher password.Argon2Hasher
}
// Install completes the installation by setting the initial administrators and options
func (in *Install) Install(g *gin.Context) {
tx := in.DB.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
ok := in.install(g, tx)
if !ok {
if tx.Rollback().Error != nil {
in.Logger.Errorw("failed to install - could not rollback transaction",
"error", tx.Rollback().Error,
)
}
return
}
result := tx.Commit()
if result.Error != nil {
in.Logger.Errorw("failed to install - could not commit transaction",
"error", result.Error,
)
in.Response.ServerError(g)
return
}
// the admin user changed username and password
// however as the install process is a special case, we wont
// require re-authentication
in.Response.OK(g, gin.H{})
}
// Install completes the installation by setting the initial administrators
// username, password, email, name and company name
func (in *Install) install(g *gin.Context, tx *gorm.DB) bool {
// handle session
_, user, ok := in.handleSession(g)
if !ok {
return false
}
role := user.Role
if role == nil {
in.Logger.Error("failed to install - session contain no role")
in.Response.ServerError(g)
return false
}
if !role.IsSuperAdministrator() {
in.Logger.Info("failed to install - not super admin")
// TODO add audit log
in.Response.Forbidden(g)
return false
}
// defer rollback or commit tx
var request SetupAdminRequest
if err := g.ShouldBindJSON(&request); err != nil {
in.Logger.Debugw("failed to parse request",
"error", err,
)
in.Response.BadRequest(g)
return false
}
ctx := g.Request.Context()
// check if already installed
isInstalled, err := in.OptionRepository.GetByKey(ctx, data.OptionKeyIsInstalled)
if err != nil {
in.Logger.Errorw("failed to install - could not get option",
"optionKey", data.OptionKeyIsInstalled,
"error", err,
)
in.Response.ServerError(g)
return false
}
if isInstalled.Value.String() == data.OptionValueIsInstalled {
in.Logger.Info("failed to install - already installed")
in.Response.ServerErrorMessage(
g,
"Installation is already complete",
)
return false
}
// update the username
newUsername, err := vo.NewUsername(request.Username)
if err != nil {
in.Logger.Infow("failed to install - invalid username",
"username", request.Username,
"error", err,
)
in.Response.ValidationFailed(g, "Username", err)
return false
}
if newUsername.String() == user.Username.MustGet().String() {
in.Logger.Infow("failed to install - new username is the same as the current",
"username", newUsername.String(),
"error", err,
)
in.Response.BadRequestMessage(
g,
"Username may not be the same as the current",
)
return false
}
userID := user.ID.MustGet()
err = in.UserRepository.UpdateUsernameByIDWithTransaction(
ctx,
tx,
&userID,
newUsername,
)
if err != nil {
in.Logger.Infow("failed to install - could not update username",
"username", newUsername.String(),
"error", err,
)
in.Response.ServerError(g)
return false
}
// update the password
newPassword, err := vo.NewReasonableLengthPassword(request.NewPassword)
if err != nil {
in.Logger.Infow("failed to install - invalid password",
"error", err,
)
in.Response.BadRequestMessage(g, "invalid password")
return false
}
hash, err := in.PasswordHasher.Hash(newPassword.String())
if err != nil {
in.Logger.Errorw("failed to install - could not hash password",
"error", err,
)
in.Response.ServerError(g)
return false
}
err = in.UserRepository.UpdatePasswordHashByIDWithTransaction(
ctx,
tx,
&userID,
hash,
)
if err != nil {
in.Logger.Errorw("failed to install - could not update password",
"error", err,
)
in.Response.ServerError(g)
return false
}
// update the name
newName, err := vo.NewUserFullname(request.UserFullname)
if err != nil {
in.Logger.Infow("failed to install - invalid name",
"error", err,
)
in.Response.ValidationFailed(g, "Name", err)
return false
}
err = in.UserRepository.UpdateFullNameByIDWithTransaction(
ctx,
tx,
&userID,
newName,
)
if err != nil {
in.Logger.Infow("failed to install - could not update name",
"error", err,
)
in.Response.ServerError(g)
return false
}
// update installed option to installed
option := model.Option{
Key: *vo.NewString64Must(data.OptionKeyIsInstalled),
Value: *vo.NewOptionalString1MBMust(data.OptionValueIsInstalled),
}
err = in.OptionRepository.UpdateByKeyWithTransaction(
ctx,
tx,
&option,
)
if err != nil {
in.Logger.Errorw("failed to install - could not create install option",
"error", err,
)
in.Response.ServerErrorMessage(g, "failed to create install option")
return false
}
return true
}
+214
View File
@@ -0,0 +1,214 @@
package controller
import (
"context"
"time"
"github.com/go-errors/errors"
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/vo"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type SetLevelRequest struct {
Level string `json:"level"`
DBLevel string `json:"dbLevel"`
}
type Log struct {
Common
OptionService *service.Option
Database *gorm.DB
LoggerAtom *zap.AtomicLevel
}
// Panic is a test utility
func (c *Log) Panic(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
if session == nil {
if ok := c.handleErrors(g, errors.New("no session")); !ok {
return
}
}
c.Deeper()
}
func (c *Log) Deeper() {
panic("panic test")
}
// Slow is a test utility
func (c *Log) Slow(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
if session == nil {
if ok := c.handleErrors(g, errors.New("no session")); !ok {
return
}
}
c.Logger.Debugf("Slow request testing start")
time.Sleep(10 * time.Second)
c.Logger.Debugf("Slow request testing stop")
c.Response.OK(g, gin.H{})
}
// GetLevel gets the log level
func (c *Log) GetLevel(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// get the log levels
logLevelOption, err := c.OptionService.GetOption(
g,
session,
data.OptionKeyLogLevel,
)
// handle errors
if ok := c.handleErrors(g, err); !ok {
return
}
dbLogLevelOption, err := c.OptionService.GetOption(g, session, data.OptionKeyDBLogLevel)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{
"level": logLevelOption.Value,
"dbLevel": dbLogLevelOption.Value,
})
}
// SetLevel sets the log level
func (c *Log) SetLevel(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
var request SetLevelRequest
if ok := c.handleParseRequest(g, &request); !ok {
return
}
if request.Level == "" && request.DBLevel == "" {
c.Response.BadRequestMessage(g, "level or dbLevel is required")
return
}
if request.DBLevel != "" {
switch request.DBLevel {
case "silent":
c.Database.Logger = c.Database.Logger.LogMode(logger.Silent)
case "info":
c.Database.Logger = c.Database.Logger.LogMode(logger.Info)
case "warn":
c.Database.Logger = c.Database.Logger.LogMode(logger.Warn)
case "error":
c.Database.Logger = c.Database.Logger.LogMode(logger.Error)
default:
c.Logger.Debugw("invalid db log level",
"level", request.DBLevel,
)
c.Response.BadRequestMessage(g, "unknown DB log level")
return
}
// set db log level in database
dbLevel := vo.NewOptionalString1MBMust(request.DBLevel)
dbLogLevelOption := model.Option{
Key: *vo.NewString64Must(data.OptionKeyDBLogLevel),
Value: *dbLevel,
}
err := c.persist(
g,
session,
&dbLogLevelOption,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
}
if request.Level != "" {
switch request.Level {
case "debug":
c.LoggerAtom.SetLevel(zap.DebugLevel)
case "info":
c.LoggerAtom.SetLevel(zap.InfoLevel)
case "warn":
c.LoggerAtom.SetLevel(zap.WarnLevel)
case "error":
c.LoggerAtom.SetLevel(zap.ErrorLevel)
default:
c.Logger.Debugw("invalid log level",
"level", request.Level,
)
c.Response.BadRequestMessage(g, "Unknown log level")
return
}
// set log level in in memory logger struct
logLevel := model.Option{
Key: *vo.NewString64Must(data.OptionKeyLogLevel),
Value: *vo.NewOptionalString1MBMust(request.Level),
}
err := c.persist(
g,
session,
&logLevel,
)
if ok := c.handleErrors(g, err); !ok {
return
}
}
c.Response.OK(g, nil)
}
// TestLog tests the log
// Sends a log message for each log level debug, info, warn, error
func (c *Log) TestLog(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// check permissions
isAuthorized, err := service.IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
handleServerError(g, c.Response, err)
return
}
if !isAuthorized {
// TODO audit log
c.Response.Unauthorized(g)
return
}
c.Logger.Debug("Log: DEBUG Test")
c.Logger.Info("Log: INFO Test")
c.Logger.Warn("Log: WARN Test")
c.Logger.Error("Log: ERROR Test")
c.Response.OK(g, nil)
}
// persit saves the log level
// TODO this has become empty and superflous
func (c *Log) persist(
ctx context.Context,
session *model.Session,
logLevel *model.Option,
) error {
return c.OptionService.SetOptionByKey(
ctx,
session,
logLevel,
)
}
+67
View File
@@ -0,0 +1,67 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/service"
)
// Option is a Option controller
type Option struct {
Common
OptionService *service.Option
}
// Get a update option
func (c *Option) Get(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
key := g.Param("key")
if key == "" {
c.Response.BadRequestMessage(g, "option is required")
return
}
ctx := g.Request.Context()
option, err := c.OptionService.GetOption(
ctx,
session,
key,
)
if ok := handleServerError(g, c.Response, err); !ok {
return
}
if key == data.OptionKeyAdminSSOLogin {
option, err = c.OptionService.MaskSSOSecret(option)
if ok := handleServerError(g, c.Response, err); !ok {
return
}
}
c.Response.OK(g, option)
}
// Update sets a option
func (c *Option) Update(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse req
var req model.Option
if ok := c.handleParseRequest(g, &req); !ok {
return
}
err := c.OptionService.SetOptionByKey(g, session, &req)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
gin.H{},
)
}
+230
View File
@@ -0,0 +1,230 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
)
// PageColumnsMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var PageColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.PAGE_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.PAGE_TABLE, "updated_at"),
"name": repository.TableColumn(database.PAGE_TABLE, "name"),
}
// Page is a Page controller
type Page struct {
Common
PageService *service.Page
TemplateService *service.Template
}
// Create creates a page
func (p *Page) Create(g *gin.Context) {
// handle session
session, _, ok := p.handleSession(g)
if !ok {
return
}
// parse req
var req model.Page
if ok := p.handleParseRequest(g, &req); !ok {
return
}
// save page
id, err := p.PageService.Create(
g.Request.Context(),
session,
&req,
)
// handle response
if ok := p.handleErrors(g, err); !ok {
return
}
p.Response.OK(
g,
gin.H{
"id": id.String(),
},
)
}
// GetContentByID serves a page by id
func (p *Page) GetContentByID(g *gin.Context) {
session, _, ok := p.handleSession(g)
if !ok {
return
}
// parse request
id, ok := p.handleParseIDParam(g)
if !ok {
return
}
// get page
page, err := p.PageService.GetByID(
g,
session,
id,
&repository.PageOption{},
)
// handle response
if ok := p.handleErrors(g, err); !ok {
return
}
content, err := page.Content.Get()
if ok := p.handleErrors(g, err); !ok {
return
}
// build response
phishingPage, err := p.TemplateService.ApplyPageMock(content.String())
if ok := p.handleErrors(g, err); !ok {
return
}
p.Response.OK(g, phishingPage.String())
}
// GetAll gets pages using pagination
func (p *Page) GetAll(g *gin.Context) {
session, _, ok := p.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := p.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
companyID := companyIDFromRequestQuery(g)
// get pages
pages, err := p.PageService.GetAll(
g,
session,
companyID,
&repository.PageOption{
QueryArgs: queryArgs,
},
)
// handle response
if ok := p.handleErrors(g, err); !ok {
return
}
p.Response.OK(g, pages)
}
// GetOverview gets pages overview using pagination
func (p *Page) GetOverview(g *gin.Context) {
session, _, ok := p.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := p.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
companyID := companyIDFromRequestQuery(g)
// get pages
pages, err := p.PageService.GetAll(
g,
session,
companyID,
&repository.PageOption{
Fields: []string{"id", "created_at", "updated_at", "name", "company_id"},
QueryArgs: queryArgs,
},
)
// handle response
if ok := p.handleErrors(g, err); !ok {
return
}
p.Response.OK(g, pages)
}
// GetByID gets a page by id
func (p *Page) GetByID(g *gin.Context) {
session, _, ok := p.handleSession(g)
if !ok {
return
}
// parse request
id, ok := p.handleParseIDParam(g)
if !ok {
return
}
// get page
page, err := p.PageService.GetByID(
g.Request.Context(),
session,
id,
// do I really need to preload this?
&repository.PageOption{
WithCompany: true,
},
)
// handle response
if ok := p.handleErrors(g, err); !ok {
return
}
p.Response.OK(g, page)
}
// UpdateByID updates a page by id
func (p *Page) UpdateByID(g *gin.Context) {
session, _, ok := p.handleSession(g)
if !ok {
return
}
// parse request
id, ok := p.handleParseIDParam(g)
if !ok {
return
}
var req model.Page
if ok := p.handleParseRequest(g, &req); !ok {
return
}
// update page
err := p.PageService.UpdateByID(
g.Request.Context(),
session,
id,
&req,
)
// handle response
if ok := p.handleErrors(g, err); !ok {
return
}
p.Response.OK(g, gin.H{})
}
// DeleteByID deletes a page by id
func (p *Page) DeleteByID(g *gin.Context) {
session, _, ok := p.handleSession(g)
if !ok {
return
}
// parse request
id, ok := p.handleParseIDParam(g)
if !ok {
return
}
// delete page
err := p.PageService.DeleteByID(
g.Request.Context(),
session,
id,
)
// handle response
if ok := p.handleErrors(g, err); !ok {
return
}
p.Response.OK(g, gin.H{})
}
+92
View File
@@ -0,0 +1,92 @@
package controller
import (
"image/png"
"net/http"
"github.com/boombuler/barcode"
"github.com/boombuler/barcode/qr"
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/service"
)
// QRCodeRequest is the request to generate a QR code from a TOTP URL
type QRCodeRequest struct {
URL string `json:"url"`
DotSize int `json:"dotSize"`
}
// QRGenerator is the QR controller
type QRGenerator struct {
Common
}
// QRGenerator creates a HTML QR code
// It is returned in an JSON response
func (q *QRGenerator) ToHTML(g *gin.Context) {
_, _, ok := q.handleSession(g)
if !ok {
return
}
// parse request
var req QRCodeRequest
if ok := q.handleParseRequest(g, &req); !ok {
return
}
// generate QR code
qrCodeBuf, err := service.GenerateQRCode(req.URL, req.DotSize)
if err != nil {
q.Logger.Debugw("failed to genereate QR code",
"error", err,
)
q.Response.ServerError(g)
return
}
q.Response.OK(g, qrCodeBuf)
}
// ToTOTPURL generates a QR code from a TOTP URL
func (q *QRGenerator) ToTOTPURL(g *gin.Context) {
_, _, ok := q.handleSession(g)
if !ok {
return
}
// parse request
var req QRCodeRequest
if ok := q.handleParseRequest(g, &req); !ok {
return
}
// generate QR code
qrCode, err := qr.Encode(
req.URL,
qr.M,
qr.Auto,
)
if err != nil {
q.Logger.Debugw("failed to generate QR code",
"error", err,
)
q.Response.ServerError(g)
return
}
qrCode, err = barcode.Scale(qrCode, 200, 200)
if err != nil {
q.Logger.Debugw("failed to scale QR code",
"error", err,
)
q.Response.ServerError(g)
return
}
// output QR code as png
g.Writer.Header().Set("Content-Type", "image/png")
err = png.Encode(g.Writer, qrCode)
if err == nil {
q.Logger.Debugw("failed to encode QR code",
"error", err,
)
q.Response.ServerError(g)
return
}
// respond
g.Status(http.StatusOK)
}
+470
View File
@@ -0,0 +1,470 @@
package controller
import (
"archive/zip"
"bytes"
"encoding/csv"
"fmt"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/cache"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/utils"
)
// recipientColumnByMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var recipientColumnByMap = map[string]string{
"created_at": repository.TableColumn(database.RECIPIENT_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.RECIPIENT_TABLE, "updated_at"),
"email": repository.TableColumn(database.RECIPIENT_TABLE, "email"),
"phone": repository.TableColumn(database.RECIPIENT_TABLE, "phone"),
"extra identifier": repository.TableColumn(database.RECIPIENT_TABLE, "extra_identifier"),
"first_name": repository.TableColumn(database.RECIPIENT_TABLE, "first_name"),
"last_name": repository.TableColumn(database.RECIPIENT_TABLE, "last_name"),
"position": repository.TableColumn(database.RECIPIENT_TABLE, "position"),
"department": repository.TableColumn(database.RECIPIENT_TABLE, "department"),
"city": repository.TableColumn(database.RECIPIENT_TABLE, "city"),
"country": repository.TableColumn(database.RECIPIENT_TABLE, "country"),
"misc": repository.TableColumn(database.RECIPIENT_TABLE, "misc"),
"repeat_offender": "is_repeat_offender", // Special case - don't use TableColumn
}
var recipientCampaignEventColumnMap = utils.MergeStringMaps(
campaignEventColumns,
map[string]string{
"event": repository.TableColumnName(database.EVENT_TABLE),
"created": repository.TableColumn(database.CAMPAIGN_EVENT_TABLE, "created_at"),
"campaign": repository.TableColumn(database.CAMPAIGN_TABLE, "name"),
},
)
// Recipient is a Recipient controller
type Recipient struct {
Common
RecipientService *service.Recipient
}
// Create inserts a new recipient
func (r *Recipient) Create(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse request
var req model.Recipient
if ok := r.handleParseRequest(g, &req); !ok {
return
}
// save recipient
id, err := r.RecipientService.Create(
g.Request.Context(),
session,
&req,
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(
g,
gin.H{
"id": id.String(),
},
)
}
// GetCampaignEvents gets all campaign events by recipient id and campaign id
// gets all events if campaign id is nil
func (r *Recipient) GetCampaignEvents(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse request
recipientID, ok := r.handleParseIDParam(g)
if !ok {
return
}
// optional param
var campaignID *uuid.UUID
cid, err := uuid.Parse(g.Query("campaignID"))
if err == nil {
campaignID = &cid
}
queryArgs, ok := r.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByCreatedAt()
// remap query args
queryArgs.RemapOrderBy(recipientCampaignEventColumnMap)
// get events
events, err := r.RecipientService.GetAllCampaignEvents(
g.Request.Context(),
session,
recipientID,
campaignID,
queryArgs,
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, events)
}
// Export outputs a zip with recipient, groups and all events related to the recipient
func (r *Recipient) Export(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse request
recipientID, ok := r.handleParseIDParam(g)
if !ok {
return
}
// get the recipient
recp, err := r.RecipientService.GetByID(
g,
session,
recipientID,
&repository.RecipientOption{
WithCompany: true,
WithGroups: true,
},
)
if ok := r.handleErrors(g, err); !ok {
return
}
recipientBuffer := &bytes.Buffer{}
recipientWriter := csv.NewWriter(recipientBuffer)
recpHeaders := []string{
"Created at",
"Updated at",
"Email",
"Phone",
"Extra Identifier",
"Name",
"Position",
"Department",
"City",
"Country",
"Misc",
}
groups, _ := recp.Groups.Get()
for i := range groups {
recpHeaders = append(recpHeaders, fmt.Sprintf("Group %d", i+1))
}
err = recipientWriter.Write(recpHeaders)
if ok := r.handleErrors(g, err); !ok {
return
}
row := []string{
utils.CSVFromDate(recp.CreatedAt),
utils.CSVFromDate(recp.UpdatedAt),
utils.CSVRemoveFormulaStart(utils.NullableToString(recp.Email)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recp.Phone)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recp.ExtraIdentifier)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recp.FirstName)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recp.LastName)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recp.Position)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recp.Department)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recp.City)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recp.Country)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recp.Misc)),
}
for _, group := range groups {
row = append(row, group.Name.MustGet().String())
}
err = recipientWriter.Write(row)
if ok := r.handleErrors(g, err); !ok {
return
}
recipientWriter.Flush()
queryArgs, ok := r.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByCreatedAt()
// remap query args
queryArgs.RemapOrderBy(recipientCampaignEventColumnMap)
sortOrder := g.DefaultQuery("sortOrder", "desc")
if sortOrder == "desc" {
queryArgs.Desc = true
}
// get all rows
queryArgs.Limit = 0
queryArgs.Offset = 0
// get events
events, err := r.RecipientService.GetAllCampaignEvents(
g.Request.Context(),
session,
recipientID,
nil,
queryArgs,
)
// handle response
eventsBuffer := &bytes.Buffer{}
eventsWriter := csv.NewWriter(eventsBuffer)
headers := []string{
"Created at",
"Campaign",
"IP",
"User-Agent",
"Event Details",
"Event",
}
err = eventsWriter.Write(headers)
if ok := r.handleErrors(g, err); !ok {
return
}
for _, event := range events.Rows {
row := []string{}
row = []string{
utils.CSVFromDate(event.CreatedAt),
utils.CSVRemoveFormulaStart(event.CampaignName),
utils.CSVRemoveFormulaStart(event.IP.String()),
utils.CSVRemoveFormulaStart(event.UserAgent.String()),
utils.CSVRemoveFormulaStart(event.Data.String()),
utils.CSVRemoveFormulaStart(cache.EventNameByID[event.EventID.String()]),
}
err = eventsWriter.Write(row)
if ok := r.handleErrors(g, err); !ok {
return
}
}
eventsWriter.Flush()
// create ZIP file in memory
zipBuffer := new(bytes.Buffer)
zipWriter := zip.NewWriter(zipBuffer)
zipFileName := fmt.Sprintf("recipient_export_%s.zip", recp.Email.MustGet().String())
// add events to zip
{
f, err := zipWriter.Create("recipient.csv")
if ok := r.handleErrors(g, err); !ok {
return
}
_, err = f.Write(recipientBuffer.Bytes())
if ok := r.handleErrors(g, err); !ok {
return
}
}
// add events to zip
{
f, err := zipWriter.Create("events.csv")
if ok := r.handleErrors(g, err); !ok {
return
}
_, err = f.Write(eventsBuffer.Bytes())
if ok := r.handleErrors(g, err); !ok {
return
}
}
// close zip
err = zipWriter.Close()
if ok := r.handleErrors(g, err); !ok {
return
}
r.responseWithZIP(g, zipBuffer, zipFileName)
}
// GetRepeatOffenderCount gets the repeat offender count
func (r *Recipient) GetRepeatOffenderCount(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
// get count
count, err := r.RecipientService.GetRepeatOffenderCount(
g.Request.Context(),
session,
companyID,
)
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, count)
}
// GetAll gets all recipients
func (r *Recipient) GetAll(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := r.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortBy("first_name")
// remap query args
queryArgs.RemapOrderBy(recipientColumnByMap)
// get recipients
recipients, err := r.RecipientService.GetAll(
g.Request.Context(),
companyID,
session,
&repository.RecipientOption{
QueryArgs: queryArgs,
},
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, recipients)
}
// GetByID gets a recipient by id
func (r *Recipient) GetByID(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse id
id, ok := r.handleParseIDParam(g)
if !ok {
return
}
// get recipient
recipient, err := r.RecipientService.GetByID(
g.Request.Context(),
session,
id,
&repository.RecipientOption{
WithCompany: true,
WithGroups: true,
},
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, recipient)
}
// GetStatsByID gets a recipient campaign stats by id
func (r *Recipient) GetStatsByID(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse id
id, ok := r.handleParseIDParam(g)
if !ok {
return
}
// get recipient stats
stats, err := r.RecipientService.GetStatsByID(
g.Request.Context(),
session,
id,
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, stats)
}
// UpdateByID updates a recipient by id
func (r *Recipient) UpdateByID(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse request
id, ok := r.handleParseIDParam(g)
if !ok {
return
}
var req model.Recipient
if ok := r.handleParseRequest(g, &req); !ok {
return
}
err := r.RecipientService.UpdateByID(
g.Request.Context(),
session,
id,
&req,
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, gin.H{})
}
// Import imports recipients
func (r *Recipient) Import(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse request
var req struct {
Recipients []*model.Recipient `json:"recipients"`
CompanyID *uuid.UUID `json:"companyID"`
IgnoreOverwriteEmptyFields nullable.Nullable[bool] `json:"ignoreOverwriteEmptyFields"`
}
if ok := r.handleParseRequest(g, &req); !ok {
return
}
// IgnoreOverwriteEmptyFields default value is true
if !req.IgnoreOverwriteEmptyFields.IsSpecified() || req.IgnoreOverwriteEmptyFields.IsNull() {
req.IgnoreOverwriteEmptyFields = nullable.NewNullableWithValue(true)
}
_, err := r.RecipientService.Import(
g,
session,
req.Recipients,
req.IgnoreOverwriteEmptyFields.MustGet(),
req.CompanyID,
)
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, &gin.H{})
}
// DeleteByID deletes a recipient by id
func (r *Recipient) DeleteByID(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse id
id, ok := r.handleParseIDParam(g)
if !ok {
return
}
// delete recipient
err := r.RecipientService.DeleteByID(g, session, id)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, gin.H{})
}
+351
View File
@@ -0,0 +1,351 @@
package controller
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
)
// RecipientGroupColumnsMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var RecipientGroupColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.RECIPIENT_GROUP_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.RECIPIENT_GROUP_TABLE, "updated_at"),
"name": repository.TableColumn(database.RECIPIENT_GROUP_TABLE, "name"),
}
// AddRecipientRequest is a request to add recipients to a recipient group
type AddRecipientRequest struct {
RecipientIDs []string `json:"recipientIDs"`
}
// RemoveRecipientRequest is a request to remove recipients from a recipient group
type RemoveRecipientRequest struct {
RecipientIDs []string `json:"recipientIDs"`
}
// RecipientGroup is a recipient group controller
type RecipientGroup struct {
Common
RecipientGroupService *service.RecipientGroup
}
// Create creates a new recipient group
func (r *RecipientGroup) Create(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse request
var req model.RecipientGroup
if ok := r.handleParseRequest(g, &req); !ok {
return
}
// save recipient group
recipientGroupID, err := r.RecipientGroupService.Create(
g.Request.Context(),
session,
&req,
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(
g,
&gin.H{
"id": recipientGroupID.String(),
},
)
}
// GetAll returns all recipient groups using pagination
func (r *RecipientGroup) GetAll(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := r.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByName()
queryArgs.RemapOrderBy(RecipientGroupColumnsMap)
companyContextID := companyIDFromRequestQuery(g)
// get recipient groups
recipientGroups, err := r.RecipientGroupService.GetAll(
g,
session,
companyContextID,
&repository.RecipientGroupOption{
QueryArgs: queryArgs,
WithCompany: true,
WithRecipientCount: true,
},
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, recipientGroups)
}
// GetByID gets a recipient group by id
func (r *RecipientGroup) GetByID(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse id
id, ok := r.handleParseIDParam(g)
if !ok {
return
}
recipientGroup, err := r.RecipientGroupService.GetByID(
g.Request.Context(),
session,
id,
&repository.RecipientGroupOption{
WithCompany: true,
},
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, recipientGroup)
}
// GetRecipientsByGroupID gets recipients by recipient group id
func (r *RecipientGroup) GetRecipientsByGroupID(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse id
id, ok := r.handleParseIDParam(g)
if !ok {
return
}
queryArgs, ok := r.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortBy("email")
// remap query args
queryArgs.RemapOrderBy(recipientColumnByMap)
if !ok {
return
}
// get recipients
ctx := g.Request.Context()
recipients, err := r.RecipientGroupService.GetRecipientsByGroupID(
ctx,
session,
id,
&repository.RecipientOption{
QueryArgs: queryArgs,
WithCompany: true,
},
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, recipients)
}
// UpdateByID updates a recipient group by id
// updates only the name and company relations
func (r *RecipientGroup) UpdateByID(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse id
id, ok := r.handleParseIDParam(g)
if !ok {
return
}
// parse request
var req model.RecipientGroup
if ok := r.handleParseRequest(g, &req); !ok {
return
}
// check if recipient group exists already exists
err := r.RecipientGroupService.UpdateByID(
g.Request.Context(),
session,
id,
&req,
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, &gin.H{})
}
// Import imports recipients to a recipient group
func (r *RecipientGroup) Import(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse request
groupID, ok := r.handleParseIDParam(g)
if !ok {
return
}
var req struct {
Recipients []*model.Recipient `json:"recipients"`
CompanyID *uuid.UUID `json:"companyID"`
IgnoreOverwriteEmptyFields nullable.Nullable[bool] `json:"ignoreOverwriteEmptyFields"`
}
if ok := r.handleParseRequest(g, &req); !ok {
return
}
// IgnoreOverwriteEmptyFields default value is true
if !req.IgnoreOverwriteEmptyFields.IsSpecified() || req.IgnoreOverwriteEmptyFields.IsNull() {
req.IgnoreOverwriteEmptyFields = nullable.NewNullableWithValue(true)
}
err := r.RecipientGroupService.Import(
g,
session,
req.Recipients,
req.IgnoreOverwriteEmptyFields.MustGet(),
groupID,
req.CompanyID,
)
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, &gin.H{})
}
// AddRecipients adds recipients to a recipient group
func (r *RecipientGroup) AddRecipients(g *gin.Context) {
// handle session
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse group ID
groupID, ok := r.handleParseIDParam(g)
if !ok {
return
}
// parse request
var req AddRecipientRequest
if ok := r.handleParseRequest(g, &req); !ok {
return
}
// parse recipient ids
recipientIDs := []*uuid.UUID{}
for _, id := range req.RecipientIDs {
rid, err := uuid.Parse(id)
if err != nil {
r.Logger.Debugw("failed to add recipients to recipient group",
"error", fmt.Errorf("failed to parse recipient id: %w", err),
)
r.Response.BadRequestMessage(g, "invalid recipient id")
return
}
recipientIDs = append(recipientIDs, &rid)
}
// add recipients
err := r.RecipientGroupService.AddRecipients(
g.Request.Context(),
session,
groupID,
recipientIDs,
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, &gin.H{})
}
// RemoveRecipients removes a recipient from a recipient group
func (r *RecipientGroup) RemoveRecipients(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse id
id, ok := r.handleParseIDParam(g)
if !ok {
return
}
// parse request
var req RemoveRecipientRequest
if ok := r.handleParseRequest(g, &req); !ok {
return
}
// parse recipient ids
recipientIDs := []*uuid.UUID{}
for _, id := range req.RecipientIDs {
rid, err := uuid.Parse(id)
if err != nil {
r.Logger.Debugw("failed to remove recipients from recipient group",
"error", fmt.Errorf("failed to parse recipient id: %w", err),
)
r.Response.BadRequestMessage(g, "invalid recipient id")
return
}
recipientIDs = append(recipientIDs, &rid)
}
// remove recipients
err := r.RecipientGroupService.RemoveRecipients(
g.Request.Context(),
session,
id,
recipientIDs,
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, &gin.H{})
}
// DeleteByID deletes a recipient group by id
// deleting a group also deletes all recipients in that group
func (r *RecipientGroup) DeleteByID(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse id
id, ok := r.handleParseIDParam(g)
if !ok {
return
}
// delete recipient group
err := r.RecipientGroupService.DeleteByID(
g.Request.Context(),
session,
id,
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(
g,
&gin.H{},
)
}
+269
View File
@@ -0,0 +1,269 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/api"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/vo"
)
// SMTPConfigurationColumnsMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var SMTPConfigurationColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.SMTP_CONFIGURATION_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.SMTP_CONFIGURATION_TABLE, "updated_at"),
"name": repository.TableColumn(database.SMTP_CONFIGURATION_TABLE, "name"),
"host": repository.TableColumn(database.SMTP_CONFIGURATION_TABLE, "host"),
"port": repository.TableColumn(database.SMTP_CONFIGURATION_TABLE, "port"),
"username": repository.TableColumn(database.SMTP_CONFIGURATION_TABLE, "username"),
}
// SMTPConfiguration is a controller
type SMTPConfiguration struct {
Common
SMTPConfigurationService *service.SMTPConfiguration
}
// Create creates a new SMTPConfiguration
func (c *SMTPConfiguration) Create(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
var req model.SMTPConfiguration
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// save SMTP configuration
id, err := c.SMTPConfigurationService.Create(g, session, &req)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
gin.H{
"id": id.String(),
},
)
}
// GetAll gets SMTP configurations
func (c *SMTPConfiguration) GetAll(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
queryArgs.RemapOrderBy(SMTPConfigurationColumnsMap)
companyID := companyIDFromRequestQuery(g)
// get
smtpConfigs, err := c.SMTPConfigurationService.GetAll(
g.Request.Context(),
session,
companyID,
&repository.SMTPConfigurationOption{
QueryArgs: queryArgs,
WithCompany: true,
WithHeaders: true,
},
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, smtpConfigs)
}
// GetByID gets a SMTP configuration by an ID
func (c *SMTPConfiguration) GetByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// get SMTP configuration
smtpConfig, err := c.SMTPConfigurationService.GetByID(
g.Request.Context(),
session,
id,
&repository.SMTPConfigurationOption{
WithCompany: true,
WithHeaders: true,
},
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, smtpConfig)
}
type SMTPConfigurationTestEmailRequest struct {
Email vo.Email `json:"email" binding:"required,email"`
MailFrom vo.Email `json:"mailFrom" binding:"required,mailFrom"`
}
// TestEmail tests the connection to a SMTP configuration
func (c *SMTPConfiguration) TestEmail(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
var req SMTPConfigurationTestEmailRequest
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// test dial
err := c.SMTPConfigurationService.SendTestEmail(
g,
session,
id,
&req.Email,
&req.MailFrom,
)
// handle any error as a validation error
if err != nil {
err = errs.NewValidationError(err)
}
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// UpdateByID updates a SMTP configuration - but not the headers
func (c *SMTPConfiguration) UpdateByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
var req model.SMTPConfiguration
if ok := c.handleParseRequest(g, &req); !ok {
return
}
err := c.SMTPConfigurationService.UpdateByID(
g.Request.Context(),
session,
id,
&req,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// AddHeader adds a header to a SMTP configuration
func (c *SMTPConfiguration) AddHeader(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
var req model.SMTPHeader
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// save header
smtpID, ok := c.handleParseIDParam(g)
if !ok {
return
}
createdID, err := c.SMTPConfigurationService.AddHeader(
g.Request.Context(),
session,
smtpID,
&req,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{
"id": createdID.String(),
})
}
// RemoveHeader removes a header from a SMTP configuration
func (c *SMTPConfiguration) RemoveHeader(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
headerID, err := uuid.Parse(g.Param("headerID"))
if err != nil {
c.Logger.Debugw("invalid header id",
"headerID", g.Param("headerID"),
"error", err,
)
c.Response.BadRequestMessage(g, api.InvalidSMTPConfigurationID)
return
}
// remove header
err = c.SMTPConfigurationService.RemoveHeader(
g.Request.Context(),
session,
id,
&headerID,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// DeleteByID deletes a SMTP configuration
func (c *SMTPConfiguration) DeleteByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// delete
err := c.SMTPConfigurationService.DeleteByID(g, session, id)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
+88
View File
@@ -0,0 +1,88 @@
package controller
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/service"
)
// SSO the single sign on controller
type SSO struct {
Common
*service.SSO
}
// Upsert upserts a SSO configuration
func (s *SSO) Upsert(g *gin.Context) {
session, _, ok := s.handleSession(g)
if !ok {
return
}
// parse request
var request model.SSOOption
if ok := s.handleParseRequest(g, &request); !ok {
return
}
// handle upsert
err := s.SSO.Upsert(
g.Request.Context(),
session,
&request,
)
// handle responses
if ok := s.handleErrors(g, err); !ok {
return
}
s.Response.OK(g, gin.H{})
}
func (s *SSO) IsEnabled(g *gin.Context) {
// if no sso client is setup, then it is not enabled
if s.SSO.MSALClient == nil {
s.Response.OK(g, false)
return
}
s.Response.OK(g, true)
}
func (s *SSO) EntreIDLogin(g *gin.Context) {
authURL, err := s.SSO.EntreIDLogin(g)
if errors.Is(err, errs.ErrSSODisabled) {
s.Response.BadRequest(g)
return
}
if ok := s.handleErrors(g, err); !ok {
s.Response.BadRequest(g)
return
}
g.Redirect(http.StatusTemporaryRedirect, authURL)
}
func (s *SSO) EntreIDCallBack(g *gin.Context) {
code := g.Query("code")
session, err := s.SSO.HandlEntraIDCallback(g, code)
if err != nil {
g.Redirect(http.StatusTemporaryRedirect, "/login?ssoAuthError=1")
return
}
if ok := s.handleErrors(g, err); !ok {
return
}
// Set the session in the cookie
cookie := &http.Cookie{
Name: data.SessionCookieKey,
Value: session.ID.String(),
Path: "/",
SameSite: http.SameSiteStrictMode,
HttpOnly: true,
Secure: true,
Expires: *session.MaxAgeAt,
}
http.SetCookie(g.Writer, cookie)
g.Redirect(http.StatusTemporaryRedirect, "/dashboard")
}
+93
View File
@@ -0,0 +1,93 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/go-errors/errors"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/service"
)
type Update struct {
Common
UpdateService *service.Update
OptionService *service.Option
}
// CheckForUpdateCached checks if there is a new update from cache
func (u *Update) CheckForUpdateCached(g *gin.Context) {
session, _, ok := u.handleSession(g)
if !ok {
return
}
updateAvailable, usingSystemd, err := u.UpdateService.CheckForUpdateCached(g, session)
if ok := u.handleErrors(g, err); !ok {
return
}
u.Response.OK(g, gin.H{
"updateAvailable": updateAvailable,
"updateInApp": usingSystemd,
})
}
// CheckForUpdate checks if there is a new update
func (u *Update) CheckForUpdate(g *gin.Context) {
session, _, ok := u.handleSession(g)
if !ok {
return
}
updateAvailable, usingSystemd, err := u.UpdateService.CheckForUpdate(g, session)
if ok := u.handleErrors(g, err); !ok {
return
}
u.Response.OK(g, gin.H{
"updateAvailable": updateAvailable,
"updateInApp": usingSystemd,
})
}
// GetUpdateDetails gets details about the newest software update
func (u *Update) GetUpdateDetails(g *gin.Context) {
session, _, ok := u.handleSession(g)
if !ok {
return
}
opt, err := u.OptionService.GetOption(g, session, data.OptionKeyUsingSystemd)
if ok := u.handleErrors(g, err); !ok {
return
}
details, err := u.UpdateService.GetUpdateDetails(g, session)
if err != nil && !errors.Is(err, errs.ErrNoUpdateAvailable) {
if ok := u.handleErrors(g, err); !ok {
return
}
}
if errors.Is(err, errs.ErrNoUpdateAvailable) {
u.Response.OK(g, gin.H{
"updateAvailable": false,
"updateInApp": opt.Value.String() == data.OptionValueUsingSystemdYes,
"downloadURL": "",
"latestVersion": "",
})
return
}
u.Response.OK(g, gin.H{
"updateAvailable": true,
"updateInApp": opt.Value.String() == data.OptionValueUsingSystemdYes,
"downloadURL": details.DownloadURL,
"latestVersion": details.LatestVersion,
})
}
// RunUpdate performs an update
func (u *Update) RunUpdate(g *gin.Context) {
session, _, ok := u.handleSession(g)
if !ok {
return
}
err := u.UpdateService.RunUpdate(g, session)
if ok := u.handleErrors(g, err); !ok {
return
}
u.Response.OK(g, gin.H{})
}
+922
View File
@@ -0,0 +1,922 @@
package controller
import (
"net/http"
"github.com/go-errors/errors"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/vo"
"gorm.io/gorm"
)
var SessionColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.SESSION_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.SESSION_TABLE, "updated_at"),
"ip_address": repository.TableColumn(database.SESSION_TABLE, "ip_address"),
}
var UserColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.USER_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.USER_TABLE, "updated_at"),
"name": repository.TableColumn(database.USER_TABLE, "name"),
"username": repository.TableColumn(database.USER_TABLE, "username"),
"email": repository.TableColumn(database.USER_TABLE, "email"),
}
// UserLoginRequest is a request for login with username and password
type UserLoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
TOTP string `json:"totp"`
MFARecoveryCode string `json:"recoveryCode"`
}
// UserSetupTOTPRequest is a request for setting up TOTP
type UserSetupTOTPRequest struct {
Password string `json:"password"`
}
// UserSetupDisableTOTPRequest is a request for disabling TOTP
type UserDisableTOTPRequest struct {
Token string `json:"token"`
}
// UserVerifyTOTPRequest is a request for verifying TOTP
type UserVerifyTOTPRequest struct {
TOTP string `json:"token"`
}
// UserLoginWithMFARecoveryCodeRequest is a request for login with MFA recovery code
type UserLoginWithMFARecoveryCodeRequest struct {
RecoveryCode string `json:"recoveryCode"`
Username string `json:"username"`
Password string `json:"password"`
}
// User is the change email controller
type User struct {
Common
UserService *service.User
}
// Create creates a new user
func (c *User) Create(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse req
var req model.UserUpsertRequest
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// create user
newUserID, err := c.UserService.Create(
g,
session,
&req,
)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
gin.H{
"id": newUserID.String(),
},
)
}
// GetMaskedAPIKey gets logged-in users masked API key
func (c *User) GetMaskedAPIKey(g *gin.Context) {
session, user, ok := c.handleSession(g)
if !ok {
return
}
if user == nil {
c.handleErrors(g, errors.New("no user in session"))
}
// get
cid := user.ID.MustGet()
apiKey, err := c.UserService.GetMaskedAPIKey(
g,
session,
&cid,
)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
gin.H{
"apiKey": apiKey,
},
)
}
// UpsertAPIKey create/update API key
func (c *User) UpsertAPIKey(g *gin.Context) {
session, user, ok := c.handleSession(g)
if !ok {
return
}
if user == nil {
c.handleErrors(g, errors.New("no user in session"))
}
// create user
uid := user.ID.MustGet()
apiKey, err := c.UserService.UpsertAPIKey(
g,
session,
&uid,
)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
gin.H{
"apiKey": apiKey,
},
)
}
// RemoveAPIKey removes a api key
func (c *User) RemoveAPIKey(g *gin.Context) {
session, user, ok := c.handleSession(g)
if !ok {
return
}
if user == nil {
c.handleErrors(g, errors.New("no user in session"))
}
// create user
uid := user.ID.MustGet()
err := c.UserService.RemoveAPIKey(
g,
session,
&uid,
)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
gin.H{},
)
}
// UpdateByID updates a user by ID
func (c *User) UpdateByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
var req model.User
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// update user
err := c.UserService.Update(
g,
session,
id,
&req,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// Delete deletes a user
func (c *User) Delete(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// delete user
err := c.UserService.Delete(g, session, id)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// GetAll gets all users using pagination
func (c *User) GetAll(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
queryArgs.RemapOrderBy(UserColumnsMap)
// get user
users, err := c.UserService.GetAll(g, session, &repository.UserOption{
QueryArgs: queryArgs,
WithRole: true,
WithCompany: true,
})
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, users)
}
// GetByID gets a user by ID
func (c *User) GetByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// get user
user, err := c.UserService.GetByID(g, session, id)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, user)
}
// ChangeEmailOnLoggedInUser changes email on logged in user
// this is an administrator action
func (c *User) ChangeEmailOnLoggedInUser(g *gin.Context) {
session, sessionUser, ok := c.handleSession(g)
if !ok {
return
}
// parse and validate request
var request model.UserChangeEmailRequest
if ok := c.handleParseRequest(g, &request); !ok {
return
}
// change email
userID := sessionUser.ID.MustGet()
changedEmail, err := c.UserService.ChangeEmailAsAdministrator(
g,
session,
&userID,
&request.Email,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
gin.H{"email": changedEmail.String()},
)
}
// ChangeFullnameOnLoggedInUser is the handler for change fullname
func (c *User) ChangeFullnameOnLoggedInUser(g *gin.Context) {
session, sessionUser, ok := c.handleSession(g)
if !ok {
return
}
// parse req
var req model.UserChangeFullnameRequest
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// change fullname
userID := sessionUser.ID.MustGet()
_, err := c.UserService.ChangeFullname(
g,
session,
&userID,
&req.NewFullname,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// ChangePasswordOnLoggedInUser changes the password on the logged in user
func (c *User) ChangePasswordOnLoggedInUser(g *gin.Context) {
session, sessionUser, ok := c.handleSession(g)
if !ok {
return
}
// parse req
var req model.UserChangePasswordRequest
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// change password
err := c.UserService.ChangePassword(
g,
session,
&req.CurrentPassword,
&req.NewPassword,
)
// handle response
if errors.Is(err, errs.ErrUserWrongPasword) {
c.Response.BadRequestMessage(g, "Invalid current password")
return
}
if ok := c.handleErrors(g, err); !ok {
return
}
// invalidate all currently running sessions
userID := sessionUser.ID.MustGet()
err = c.SessionService.ExpireAllByUserID(g, session, &userID)
// partial error, the password is changed but the sessions are not invalidated
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
"password changed - all sessions have been invalidated",
)
}
// ChangeUsernameOnLoggedInUser changes the username
func (c *User) ChangeUsernameOnLoggedInUser(g *gin.Context) {
session, sessionUser, ok := c.handleSession(g)
if !ok {
return
}
// parse req
var req model.UserChangeUsernameOnLoggedInRequest
if ok := c.handleParseRequest(g, &req); !ok {
return
}
userID := sessionUser.ID.MustGet()
// change username
err := c.UserService.ChangeUsername(
g.Request.Context(),
session,
&userID,
&req.NewUsername,
)
// handle error
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// ExpireSessionByID expires a session by ID
// a administrator can expire any session
// a user can expire their own sessions
func (c *User) ExpireSessionByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
isAuthorized, err := service.IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if ok := c.handleErrors(g, err); !ok {
return
}
if !isAuthorized {
c.Response.Forbidden(g)
return
}
err = c.SessionService.Expire(g, id)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
"session expired",
)
}
// GetSessionsByUserID gets all sessions by user ID
func (c *User) GetSessionsOnLoggedInUser(g *gin.Context) {
session, sessionUser, ok := c.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
queryArgs.RemapOrderBy(SessionColumnsMap)
userID := sessionUser.ID.MustGet()
sessions, err := c.SessionService.GetSessionsByUserID(
g,
session,
&userID,
&repository.SessionOption{
QueryArgs: queryArgs,
},
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
data := []map[string]interface{}{}
for _, sess := range sessions.Rows {
idStr := sess.ID.String()
data = append(data, map[string]interface{}{
"id": idStr,
"current": idStr == session.ID.String(),
"ip": sess.IP,
"createdAt": sess.CreatedAt,
"updatedAt": sess.UpdatedAt,
})
}
c.Response.OK(
g,
gin.H{"sessions": data},
)
}
// Login logs in a user
func (c *User) Login(g *gin.Context) {
// parse req
var req UserLoginRequest
if ok := c.handleParseRequest(g, &req); !ok {
return
}
user, err := c.UserService.AuthenticateUsernameWithPassword(
g,
req.Username,
req.Password,
g.ClientIP(),
)
if errors.Is(err, errs.ErrUserWrongPasword) {
c.Response.BadRequestMessage(g, "Invalid password")
return
}
if errors.Is(err, gorm.ErrRecordNotFound) {
c.Response.BadRequestMessage(g, "Invalid credentials")
return
}
if ok := c.handleErrors(g, err); !ok {
return
}
// if the user has MFA enabled then we check the MFA flow
// if the user has MFA enabled, we must check if there is a
// valid MFA or a valid recovery code
userID := user.ID.MustGet()
MFATokenSupplied := len(req.TOTP) > 0
MFARecoveryCodeSupplied := len(req.MFARecoveryCode) > 0
mfaEnabled, err := c.UserService.IsTOTPEnabledByUserID(
g,
&userID,
)
if errors.Is(err, errs.ErrUserWrongTOTP) {
c.Response.BadRequestMessage(g, "Invalid TOTP")
return
}
if ok := c.handleErrors(g, err); !ok {
return
}
if mfaEnabled {
// if tokens or recovery codes are supplied
// return mfa is required
if !MFATokenSupplied && !MFARecoveryCodeSupplied {
c.Response.OK(
g,
gin.H{
"mfa": true,
},
)
return
}
// if the client has given both a TOTP and a recovery code
// we return a bad request
if MFATokenSupplied && MFARecoveryCodeSupplied {
c.Response.BadRequestMessage(g, "Cannot supply both MFA token and MFA recovery code")
return
}
// verify the TOTP MFA token
userID := user.ID.MustGet()
if MFATokenSupplied && !MFARecoveryCodeSupplied {
// if MFA is enabled, verify the TOTP
totpToken, err := vo.NewString64(req.TOTP)
if err != nil {
c.Logger.Debugw("failed to create TOTP",
"error", err,
)
c.Response.ValidationFailed(g, "TOTP", err)
return
}
err = c.UserService.CheckTOTP(
g,
&userID,
totpToken,
)
if err != nil {
if errors.Is(err, errs.ErrUserWrongTOTP) {
c.Response.BadRequestMessage(g, "Invalid TOTP")
return
}
if ok := c.handleErrors(g, err); !ok {
return
}
}
}
// if the user has MFA enabled and the client has supplied a recovery code
// we verify the recovery code
if !MFATokenSupplied && MFARecoveryCodeSupplied {
recoveryCode, err := vo.NewString64(req.MFARecoveryCode)
if err != nil {
c.Logger.Debugw("failed to create recovery code",
"error", err,
)
c.Response.ValidationFailed(g, "RecoveryCode", err)
return
}
verifiedMFA, err := c.UserService.CheckMFARecoveryCode(
g,
&userID,
recoveryCode,
)
if err != nil {
if errors.Is(err, errs.ErrUserWrongRecoveryCode) {
c.Response.BadRequestMessage(g, "Invalid recovery code")
return
}
if ok := c.handleErrors(g, err); !ok {
return
}
}
if !verifiedMFA {
c.Response.BadRequestMessage(g, "Invalid recovery code")
return
}
// as the recovery code is valid, we can now disable MFA
err = c.UserService.DisableTOTP(g, &userID)
if ok := c.handleErrors(g, err); !ok {
return
}
}
}
// create a new session
session, err := c.SessionService.Create(
g,
user,
g.ClientIP(),
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
// Set the session in the cookie
cookie := &http.Cookie{
Name: data.SessionCookieKey,
Value: session.ID.String(),
Path: "/",
SameSite: http.SameSiteStrictMode,
HttpOnly: true,
Secure: true,
Expires: *session.MaxAgeAt,
}
http.SetCookie(g.Writer, cookie)
c.Response.OK(g, session)
}
// expireCookieAndStatusOK expires the cookie and returns a 200 OK
func (c *User) expireCookieAndStatusOK(g *gin.Context) {
g.SetCookie(
data.SessionCookieKey,
"",
-1,
"/",
"",
false,
true,
)
c.logoutOK(g)
}
// logoutOK returns a 200 OK
func (c *User) logoutOK(g *gin.Context) {
c.Response.OK(
g,
gin.H{"message": "logged out"},
)
}
// Logout logs out the user
// only invalidates the session if the session cookie is
// in the request, this should reduce the risk of CSRF logout
func (c *User) Logout(g *gin.Context) {
sessionCookie, err := g.Cookie(data.SessionCookieKey)
if err != nil {
c.logoutOK(g)
return
}
sessionID, err := uuid.Parse(sessionCookie)
if err != nil {
c.logoutOK(g)
return
}
ctx := g.Request.Context()
err = c.SessionService.Expire(ctx, &sessionID)
if err != nil {
c.expireCookieAndStatusOK(g)
return
}
c.expireCookieAndStatusOK(g)
}
// SessionPing pings the session
func (c *User) SessionPing(g *gin.Context) {
// handle session
session, sessionUser, ok := c.handleSession(g)
if !ok {
return
}
c.Logger.Debugw("pinged session for user",
"userID", sessionUser.ID.MustGet().String(),
)
sessionRole := sessionUser.Role
if sessionRole == nil {
c.Logger.Error("failed to load role from session user")
c.Response.ServerError(g)
return
}
sessionCompany := sessionUser.Company
companyName := ""
if sessionCompany != nil {
companyName = sessionCompany.Name.MustGet().String()
}
c.Response.OK(
g,
gin.H{
"userID": sessionUser.ID,
"username": sessionUser.Username.MustGet().String(),
"name": sessionUser.Name.MustGet().String(),
"role": sessionRole.Name,
"company": companyName,
"ip": session.IP,
},
)
}
// InvalidateAllSessionByUserID is the nuclear session button for a user
func (c *User) InvalidateAllSessionByUserID(g *gin.Context) {
session, user, ok := c.handleSession(g)
if !ok {
return
}
var userID *uuid.UUID
// parse req
var req model.InvalidateAllSessionRequest
err := g.ShouldBindJSON(&req)
if err != nil {
if user == nil || !user.ID.IsSpecified() {
c.Response.BadRequest(g)
return
}
uid := user.ID.MustGet()
userID = &uid
} else {
if req.UserID == nil {
c.Response.BadRequest(g)
return
}
userID = req.UserID
}
// invalidate
err = c.SessionService.ExpireAllByUserID(g, session, userID)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// SetupTOTP generates a new TOTP MFA secrets
func (c *User) SetupTOTP(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
var request UserSetupTOTPRequest
if ok := c.handleParseRequest(g, &request); !ok {
return
}
passwd, err := vo.NewReasonableLengthPassword(request.Password)
if err != nil {
c.Logger.Debugw("failed to create password",
"error", err,
)
c.Response.ValidationFailed(g, "Password", err)
return
}
// get and save TOTP for user
totpValues, err := c.UserService.SetupTOTP(
g.Request.Context(),
session,
passwd,
)
// handle response
if errors.Is(err, errs.ErrAuthenticationFailed) {
c.Response.BadRequestMessage(g, "Incorrect password")
return
}
if ok := handleServerError(g, c.Response, err); !ok {
return
}
c.Response.OK(
g,
gin.H{
"base32": totpValues.Secret,
"url": totpValues.URL,
"recoveryCode": totpValues.RecoveryCode,
},
)
}
// SetupVerifyTOTP verifies a TOTP
func (c *User) SetupVerifyTOTP(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse req
var req UserVerifyTOTPRequest
if ok := c.handleParseRequest(g, &req); !ok {
return
}
totp, err := vo.NewString64(req.TOTP)
if err != nil {
c.Logger.Debugw("failed to create TOTP",
"error", err,
)
c.Response.ValidationFailed(g, "TOTP", err)
return
}
// verify TOTP
err = c.UserService.SetupCheckTOTP(
g.Request.Context(),
session,
totp,
)
if errors.Is(err, errs.ErrUserWrongTOTP) {
c.Response.BadRequestMessage(g, "Invalid token")
return
}
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
"TOTP verified",
)
}
// IsTOTPEnabled checks if TOTP is enabled
func (c *User) IsTOTPEnabled(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// check if TOTP is enabled
isEnabled, err := c.UserService.IsTOTPEnabled(
g.Request.Context(),
session,
)
// handle response
if ok := handleServerError(g, c.Response, err); !ok {
return
}
c.Response.OK(
g,
gin.H{"enabled": isEnabled},
)
}
// DisableTOTP disables TOTP
func (c *User) DisableTOTP(g *gin.Context) {
_, user, ok := c.handleSession(g)
if !ok {
return
}
// parse request
var request UserDisableTOTPRequest
if ok := c.handleParseRequest(g, &request); !ok {
return
}
token, err := vo.NewString64(request.Token)
if err != nil {
c.Logger.Debugw("failed to create token",
"error", err,
)
c.Response.ValidationFailed(g, "Token", err)
return
}
// check TOTP
userID := user.ID.MustGet()
err = c.UserService.CheckTOTP(
g.Request.Context(),
&userID,
token,
)
if err != nil {
if errors.Is(err, errs.ErrUserWrongTOTP) {
c.Response.BadRequestMessage(g, "Invalid token")
return
}
if ok := c.handleErrors(g, err); !ok {
return
}
}
// disable TOTP
err = c.UserService.DisableTOTP(
g.Request.Context(),
&userID,
)
// handle response
if err != nil {
if errors.Is(err, errs.ErrUserWrongTOTP) {
c.Response.BadRequestMessage(g, "Invalid token")
return
}
if ok := c.handleErrors(g, err); !ok {
return
}
}
c.Response.OK(
g,
"TOTP disabled",
)
}
// VerifyTOTP verifies a TOTP
func (c *User) VerifyTOTP(g *gin.Context) {
_, user, ok := c.handleSession(g)
if !ok {
return
}
// parse req
var req UserVerifyTOTPRequest
if ok := c.handleParseRequest(g, &req); !ok {
return
}
totp, err := vo.NewString64(req.TOTP)
if err != nil {
c.Logger.Debugw("failed to create TOTP",
"error", err,
)
c.Response.ValidationFailed(g, "TOTP", err)
return
}
// verify TOTP
userID := user.ID.MustGet()
err = c.UserService.CheckTOTP(
g.Request.Context(),
&userID,
totp,
)
if errors.Is(err, errs.ErrUserWrongTOTP) {
c.Response.BadRequestMessage(g, "Invalid token")
return
}
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
"TOTP verified",
)
}
+358
View File
@@ -0,0 +1,358 @@
package controller
import (
"bytes"
"encoding/csv"
"fmt"
"io"
"mime"
"path/filepath"
"strings"
"time"
"github.com/go-errors/errors"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/api"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/utils"
"github.com/phishingclub/phishingclub/vo"
"go.uber.org/zap"
"gorm.io/gorm"
)
// Common is a common controller base struct it holds common operations on the
// common dependencies
type Common struct {
Response api.JSONResponseHandler
Logger *zap.SugaredLogger
SessionService *service.Session
}
// handleSession handles the session and returns the session and user
// if the session is not valid, a 401 response is sent
func (c *Common) handleSession(
g *gin.Context,
) (*model.Session, *model.User, bool) {
s, ok := g.Get("session")
if !ok {
c.Logger.Debug("session not found in context")
c.Response.Unauthorized(g)
return nil, nil, false
}
session, ok := s.(*model.Session)
if !ok {
c.Logger.Error("session in context is not of type model.Session")
c.Response.Unauthorized(g)
return nil, nil, false
}
user := session.User
if user == nil {
c.Logger.Error("user not found in session")
c.Response.Unauthorized(g)
return nil, nil, false
}
return session, user, true
}
// HandleParseRequest parses the request and returns true if successful
// if the request is not parsable, a 400 response is sent
func (c *Common) handleParseRequest(
g *gin.Context,
req any,
) bool {
body, err := io.ReadAll(g.Request.Body)
if err != nil {
c.Logger.Debugw("failed to read request body",
"error", err,
)
c.Response.BadRequest(g)
return false
}
if err := utils.Unmarshal(body, &req); err != nil {
c.Logger.Debugw("failed to parse request",
"error", err,
)
c.Response.BadRequestMessage(g, err.Error())
return false
}
return true
}
// handleParseIDParam parses the id parameter from the request
// and returns it if successful
// if the id is not parsable, a 400 response is sent
func (c *Common) handleParseIDParam(
g *gin.Context,
) (*uuid.UUID, bool) {
id, err := uuid.Parse(g.Param("id"))
if err != nil {
c.Logger.Debugw("failed to parse id",
"error", err,
)
c.Response.BadRequestMessage(g, errs.MsgFailedToParseUUID)
return nil, false
}
return &id, true
}
// handlePagination parses the pagination from the request and returns it
// if the pagination is not valid, a 400 response is sent
func (c *Common) handlePagination(
g *gin.Context,
) (*vo.Pagination, bool) {
pagination, err := vo.NewPaginationFromRequest(g)
if err != nil {
c.Logger.Debugw("invalid offset or limit",
"error", err,
)
c.Response.ValidationFailed(g, "pagination", err)
return nil, false
}
return pagination, true
}
// handleQueryArgs parses the query from the request and returns it
func (c *Common) handleQueryArgs(g *gin.Context) (*vo.QueryArgs, bool) {
q, err := vo.QueryFromRequest(g)
if err != nil {
c.Logger.Debugw("failed to parse query",
"error", err,
)
c.Response.ValidationFailed(g, "query args", err)
return nil, false
}
return q, true
}
// handleErrors is a helper function to handle common handleErrors
// it most often checks for more than what is needed, but is
// useful to avoid missing any error handling and saving time
// it returns true if no errors are found
// it returns false if an error is found and a response is sent
func (c *Common) handleErrors(
g *gin.Context,
err error,
) bool {
if err != nil {
if ok := handleAuthorizationError(g, c.Response, err); !ok {
c.Logger.Debugw("authorization error",
"auth_error", err,
)
return false
}
if ok := handleValidationError(g, c.Response, err); !ok {
c.Logger.Debugw("validation error",
"validation_error", err,
)
return false
}
if ok := handleCustomError(g, c.Response, err); !ok {
c.Logger.Debugw("custom error",
"custom_error", err,
)
return false
}
if ok := handleDBRowNotFound(g, c.Response, err); !ok {
c.Logger.Debugw("DB row not found error",
"error", err,
)
return false
}
c.Logger.Errorw("API unknown error type", "error", err)
_ = handleServerError(g, c.Response, err)
return false
}
return true
}
// responseWithCSV
func (c *Common) responseWithCSV(
g *gin.Context,
buffer *bytes.Buffer,
writer *csv.Writer,
name string,
) {
writer.Flush()
if err := writer.Error(); err != nil {
c.handleErrors(g, err)
return
}
// Set CSV response headers
setSecureContentDisposition(g, name)
g.Header("Content-Type", "text/csv")
g.Header("Content-Length", fmt.Sprint(buffer.Len()))
// Write the CSV buffer to the response
_, err := g.Writer.Write(buffer.Bytes())
if err != nil {
c.handleErrors(g, err)
}
}
// responseWithZIP
func (c *Common) responseWithZIP(
g *gin.Context,
buffer *bytes.Buffer,
name string,
) {
g.Header("Content-Type", "application/zip")
setSecureContentDisposition(g, name)
g.Header("Content-Transfer-Encoding", "binary")
g.Header("Expires", "0")
g.Header("Cache-Control", "must-revalidate")
g.Header("Pragma", "public")
g.Header("Content-Length", fmt.Sprintf("%d", buffer.Len()))
_, err := g.Writer.Write(buffer.Bytes())
if err != nil {
c.handleErrors(g, err)
}
}
// companyIDFromRequestQuery returns the companyID as a UUID from the query
// or nil if not found
func companyIDFromRequestQuery(g *gin.Context) *uuid.UUID {
companyID := g.Query("companyID")
if companyID != "" {
cid, err := uuid.Parse(companyID)
if err != nil {
return nil
}
return &cid
}
return nil
}
// SetSessionInGinContext sets the session in the gin context
func SetSessionInGinContext(c *gin.Context, s *model.Session) {
c.Set("session", s)
}
// handleDBRowNotFound checks if the error is a not found error
// if it is, a 404 response is sent
// if it is not, true is returned
func handleDBRowNotFound(
g *gin.Context,
responseHandler api.JSONResponseHandler,
err error,
) bool {
if errors.Is(err, gorm.ErrRecordNotFound) {
// error is logged in service
_ = err
responseHandler.NotFound(g)
return false
}
return true
}
// handleAuthorizationError checks if the error is an authorization error
// if it is, a 403 response is sent
// if it is not, true is returned
func handleAuthorizationError(
g *gin.Context,
responseHandler api.JSONResponseHandler,
err error,
) bool {
if errors.Is(err, errs.ErrAuthorizationFailed) {
// error is logged in service
_ = err
responseHandler.Forbidden(g)
return false
}
return true
}
// handleValidationError checks if the error is a validation error
// if it is, a 400 response is sent
// if it is not, true is returned
func handleValidationError(
g *gin.Context,
responseHandler api.JSONResponseHandler,
err error,
) bool {
if errors.As(err, &errs.ValidationError{}) {
// error is logged in service
_ = err
responseHandler.BadRequestMessage(g, err.Error())
return false
}
return true
}
// handleCustomError checks if the error is a custom error
// if it is a 400 response is sent
// if it is not, true is returned
func handleCustomError(
g *gin.Context,
responseHandler api.JSONResponseHandler,
err error,
) bool {
if errors.As(err, &errs.CustomError{}) {
// error is logged in service
_ = err
responseHandler.BadRequestMessage(g, err.Error())
return false
}
return true
}
// handleServerError checks if the error is a server error
// if it is, a 500 response is sent
// if it is not, true is returned
func handleServerError(
g *gin.Context,
responseHandler api.JSONResponseHandler,
err error,
) bool {
if err != nil {
// error is logged in service
_ = err
responseHandler.ServerError(g)
return false
}
return true
}
func setSecureContentDisposition(c *gin.Context, filename string) {
// Strip any directory components
filename = filepath.Base(filename)
// Remove any potentially problematic characters
filename = strings.Map(func(r rune) rune {
// Keep only alphanumeric, space, dash, underscore and dot
if (r >= 'a' && r <= 'z') ||
(r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') ||
(r == ' ' || r == '-' || r == '_' || r == '.') {
return r
}
return -1
}, filename)
// Ensure we still have a valid filename
if filename == "" || filename == "." || filename == ".." {
filename = time.Now().UTC().Format("20060102150405")
}
// Properly encode the filename for Content-Disposition
encodedFilename := mime.QEncoding.Encode("utf-8", filename)
c.Header("Content-Disposition",
fmt.Sprintf(`attachment; filename="%s";`,
encodedFilename,
),
)
}
func (c *Common) requiresFlag(g *gin.Context, featureFlag string) {
// handle session
_, _, ok := c.handleSession(g)
if !ok {
return
}
c.Response.ServerErrorMessage(g, "requires "+featureFlag+" edition")
}
+26
View File
@@ -0,0 +1,26 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/service"
)
// Version is a controller
type Version struct {
Common
versionService *service.Version
}
// Get application version
func (c *Version) Get(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
version, err := c.versionService.Get(g.Request.Context(), session)
if ok := handleServerError(g, c.Response, err); !ok {
return
}
c.Response.OK(g, version)
}
+171
View File
@@ -0,0 +1,171 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
)
// WebhookColumnsMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var WebhookColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.WEBHOOK_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.WEBHOOK_TABLE, "updated_at"),
"name": repository.TableColumn(database.WEBHOOK_TABLE, "name"),
}
// Webhook is a controller
type Webhook struct {
Common
WebhookService *service.Webhook
}
// Create creates a new webhook
func (w *Webhook) Create(g *gin.Context) {
session, _, ok := w.handleSession(g)
if !ok {
return
}
// parse request
var req model.Webhook
if ok := w.handleParseRequest(g, &req); !ok {
return
}
// save webhook
id, err := w.WebhookService.Create(g.Request.Context(), session, &req)
// handle response
if ok := w.handleErrors(g, err); !ok {
return
}
w.Response.OK(
g,
gin.H{
"id": id.String(),
},
)
}
// GetAll gets the webhooks
func (w *Webhook) GetAll(g *gin.Context) {
session, _, ok := w.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := w.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
companyID := companyIDFromRequestQuery(g)
// get
webhooks, err := w.WebhookService.GetAll(
g.Request.Context(),
session,
companyID,
&repository.WebhookOption{
QueryArgs: queryArgs,
},
)
// handle response
if ok := w.handleErrors(g, err); !ok {
return
}
w.Response.OK(
g,
webhooks,
)
}
// GetByID gets a webhook by id
func (w *Webhook) GetByID(g *gin.Context) {
session, _, ok := w.handleSession(g)
if !ok {
return
}
// parse request
id, ok := w.handleParseIDParam(g)
if !ok {
return
}
// get
webhook, err := w.WebhookService.GetByID(
g.Request.Context(),
session,
id,
)
// handle response
if ok := w.handleErrors(g, err); !ok {
return
}
w.Response.OK(g, webhook)
}
// Update updates a webhook
func (w *Webhook) UpdateByID(g *gin.Context) {
session, _, ok := w.handleSession(g)
if !ok {
return
}
// parse request
id, ok := w.handleParseIDParam(g)
if !ok {
return
}
var req model.Webhook
if ok := w.handleParseRequest(g, &req); !ok {
return
}
// save
err := w.WebhookService.Update(g.Request.Context(), session, id, &req)
// handle response
if ok := w.handleErrors(g, err); !ok {
return
}
w.Response.OK(g, nil)
}
// DeleteByID deletes a webhook by id
func (w *Webhook) DeleteByID(g *gin.Context) {
session, _, ok := w.handleSession(g)
if !ok {
return
}
// parse request
id, ok := w.handleParseIDParam(g)
if !ok {
return
}
// delete
err := w.WebhookService.DeleteByID(g, session, id)
// handle response
if ok := w.handleErrors(g, err); !ok {
return
}
w.Response.OK(g, nil)
}
// SendTest sends a test webhook
func (w *Webhook) SendTest(g *gin.Context) {
session, _, ok := w.handleSession(g)
if !ok {
return
}
// parse request
id, ok := w.handleParseIDParam(g)
if !ok {
return
}
// send
data, err := w.WebhookService.SendTest(g.Request.Context(), session, id)
// handle response
if ok := w.handleErrors(g, err); !ok {
return
}
w.Response.OK(g, data)
}
+4
View File
@@ -0,0 +1,4 @@
package data
const ASSET_GLOBAL_FOLDER = "shared"
const ATTACHMENT_GLOBAL_FOLDER = "shared"
+36
View File
@@ -0,0 +1,36 @@
package data
const (
EVENT_CAMPAIGN_SCHEDULED = "campaign_scheduled"
EVENT_CAMPAIGN_ACTIVE = "campaign_active"
EVENT_CAMPAIGN_SELF_MANAGED = "campaign_self_managed"
EVENT_CAMPAIGN_CLOSED = "campaign_closed"
EVENT_CAMPAIGN_RECIPIENT_SCHEDULED = "campaign_recipient_scheduled"
EVENT_CAMPAIGN_RECIPIENT_MESSAGE_SENT = "campaign_recipient_message_sent"
EVENT_CAMPAIGN_RECIPIENT_MESSAGE_FAILED = "campaign_recipient_message_failed"
EVENT_CAMPAIGN_RECIPIENT_MESSAGE_READ = "campaign_recipient_message_read"
EVENT_CAMPAIGN_RECIPIENT_BEFORE_PAGE_VISITED = "campaign_recipient_before_page_visited"
EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED = "campaign_recipient_page_visited"
EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED = "campaign_recipient_after_page_visited"
EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA = "campaign_recipient_submitted_data"
EVENT_CAMPAIGN_RECIPIENT_CANCELLED = "campaign_recipient_cancelled"
)
var Events = []string{
// campaign events
EVENT_CAMPAIGN_SCHEDULED,
EVENT_CAMPAIGN_ACTIVE,
EVENT_CAMPAIGN_SELF_MANAGED,
EVENT_CAMPAIGN_CLOSED,
// campaign recipient events
EVENT_CAMPAIGN_RECIPIENT_SCHEDULED,
EVENT_CAMPAIGN_RECIPIENT_MESSAGE_SENT,
EVENT_CAMPAIGN_RECIPIENT_MESSAGE_FAILED,
EVENT_CAMPAIGN_RECIPIENT_MESSAGE_READ,
EVENT_CAMPAIGN_RECIPIENT_BEFORE_PAGE_VISITED,
EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED,
EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED,
EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA,
EVENT_CAMPAIGN_RECIPIENT_CANCELLED,
}
+15
View File
@@ -0,0 +1,15 @@
package data
const (
DefaultAdminCertDir = "certs/admin"
DefaultAdminPublicCertFileName = "public.pem"
DefaultAdminPrivateCertFileName = "private.pem"
)
const (
// default admin user
DefaultSacrificalAccountUsername = "admin"
DefaultSacrificalAccountName = "admin"
DefaultSacrificalAccountEmail = "admin@localhost.invalid" // RFC 2606
DefaultSacrificalCompanyName = "company"
)
+6
View File
@@ -0,0 +1,6 @@
package data
const (
MESSAGE_TYPE_EMAIL = "email"
MESSAGE_TYPE_SMS = "sms"
)
+26
View File
@@ -0,0 +1,26 @@
package data
const (
OptionKeyIsInstalled = "is_installed"
OptionValueIsInstalled = "true"
OptionValueIsNotInstalled = "false"
// KeyIsInstalled is the key for the is_installed option
OptionKeyInstanceID = "instance_id"
OptionKeyLogLevel = "log_level"
OptionKeyDBLogLevel = "db_log_level"
OptionKeyUsingSystemd = "systemd_install"
OptionValueUsingSystemdYes = "true"
OptionValueUsingSystemdNo = "false"
OptionKeyDevelopmentSeeded = "development_seeded"
OptionValueSeeded = "true"
OptionKeyMaxFileUploadSizeMB = "max_file_upload_size_mb"
OptionValueKeyMaxFileUploadSizeMBDefault = "100"
OptionKeyRepeatOffenderMonths = "repeat_offender_months"
OptionKeyAdminSSOLogin = "sso_login"
)
+8
View File
@@ -0,0 +1,8 @@
package data
const (
PAGE_TYPE_BEFORE = "before"
PAGE_TYPE_LANDING = "landing"
PAGE_TYPE_AFTER = "after"
PAGE_TYPE_DONE = "done"
)
+6
View File
@@ -0,0 +1,6 @@
package data
const (
// PERMISSION_ALLOW_GLOBAL allows all permissions, it is the god mode of permissions
PERMISSION_ALLOW_GLOBAL = "*"
)
+26
View File
@@ -0,0 +1,26 @@
package data
// This is name key for the different roles
const (
// RoleSystem is the system role
// is is reserved for system actions only
RoleSystem = "system"
// RoleSuperAdministrator is the super administrator role
// this role has access to everything a user can do
RoleSuperAdministrator = "superadministrator"
// RoleCompanyAdministrator is the company role
// this role had read access to their associated company
RoleCompanyUser = "companyuser"
)
// RolePermissions is a map of roles to their permissions
// these are the roles and their permissions
var RolePermissions = map[string][]string{
RoleSystem: {
PERMISSION_ALLOW_GLOBAL,
},
RoleSuperAdministrator: {
PERMISSION_ALLOW_GLOBAL,
},
RoleCompanyUser: {},
}
+8
View File
@@ -0,0 +1,8 @@
package data
const SessionCookieKey = "session"
const APIHeaderKey = "x-API"
const RequestAPISessionKey = "apiSession"
const SystemSessionID = "00000000-0000-0111-0777-000000000000"
const APISessionID = "00000000-0000-0100-0000-000000000000"
+11
View File
@@ -0,0 +1,11 @@
package data
import "github.com/phishingclub/phishingclub/build"
// GetCrmURL returns the URL for the CRM system depending on the environment
func GetCrmURL() string {
if build.Flags.Production {
return "https://user.phishing.club"
}
return "https://crm:8009"
}
+33
View File
@@ -0,0 +1,33 @@
package database
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
const (
ALLOW_DENY_TABLE = "allow_denies"
)
// AllowDeny is a gorm data model for allow deny listing
type AllowDeny struct {
ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`
CreatedAt *time.Time `gorm:"not null;index;"`
UpdatedAt *time.Time `gorm:"not null;index"`
CompanyID *uuid.UUID `gorm:"uniqueIndex:idx_allow_denies_unique_name_and_company_id;type:uuid"`
Name string `gorm:"not null;uniqueIndex:idx_allow_denies_unique_name_and_company_id;"`
Cidrs string `gorm:"not null;"`
Allowed bool `gorm:"not null;"`
}
func (AllowDeny) TableName() string {
return ALLOW_DENY_TABLE
}
func (e *AllowDeny) Migrate(db *gorm.DB) error {
// SQLITE
// ensure name + company id is unique
return UniqueIndexNameAndNullCompanyID(db, "allow_denies")
}
+48
View File
@@ -0,0 +1,48 @@
package database
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
const (
API_SENDER_TABLE = "api_senders"
)
type APISender struct {
ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`
CreatedAt *time.Time `gorm:"not null;index;"`
UpdatedAt *time.Time `gorm:"not null;index"`
Name string `gorm:"not null;uniqueIndex:idx_api_senders_name_company_id;"`
CompanyID *uuid.UUID `gorm:"uniqueIndex:idx_api_senders_name_company_id;type:uuid"`
// Extra fields
APIKey string
CustomField1 string
CustomField2 string
CustomField3 string
CustomField4 string
// Request fields
RequestMethod string
RequestURL string
RequestHeaders string
RequestBody string
// Response fields
ExpectedResponseStatusCode int
ExpectedResponseHeaders string
ExpectedResponseBody string
}
func (e *APISender) Migrate(db *gorm.DB) error {
// SQLITE
// ensure name + null company id is unique
return UniqueIndexNameAndNullCompanyID(db, "api_senders")
}
func (APISender) TableName() string {
return API_SENDER_TABLE
}
+26
View File
@@ -0,0 +1,26 @@
package database
import (
"time"
"github.com/google/uuid"
)
type APISenderHeader struct {
ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`
CreatedAt *time.Time `gorm:"not null;index;"`
UpdatedAt *time.Time `gorm:"not null;index"`
Key string `gorm:"not null;"`
Value string `gorm:"not null;"`
// IsRequestHeader is true if the header is a request header
// and false if it is a expected response header
IsRequestHeader bool `gorm:"not null;"`
// belongs to
APISenderID *uuid.UUID `gorm:"index;not null;type:uuid"`
}
func (APISenderHeader) TableName() string {
return "api_sender_headers"
}
+33
View File
@@ -0,0 +1,33 @@
package database
import (
"time"
"github.com/google/uuid"
)
const (
ASSET_TABLE = "assets"
)
// Asset is gorm data model
type Asset struct {
ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`
CreatedAt *time.Time `gorm:"not null;index;"`
UpdatedAt *time.Time `gorm:"not null;index;"`
// has one
DomainID *uuid.UUID `gorm:"index;type:uuid;"`
DomainName string
// can has one
CompanyID *uuid.UUID `gorm:"index;type:uuid;"`
Name string `gorm:";index"`
Description string `gorm:";"`
Path string `gorm:"not null;index"`
}
func (Asset) TableName() string {
return ASSET_TABLE
}
+33
View File
@@ -0,0 +1,33 @@
package database
import (
"time"
"github.com/google/uuid"
)
const (
ATTACHMENT_TABLE = "attachments"
)
// Attachment is gorm data model
type Attachment struct {
ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`
CreatedAt *time.Time `gorm:"not null;index;"`
UpdatedAt *time.Time `gorm:"not null;index;"`
// can has one
CompanyID *uuid.UUID `gorm:"index;type:uuid;"`
// many to many
Mails []Email `gorm:"many2many:message_attachments;"`
Name string `gorm:";index"`
Description string `gorm:";"`
Filename string `gorm:"not null;index"`
EmbeddedContent bool `gorm:"not null;default:false;index"`
}
func (Attachment) TableName() string {
return ATTACHMENT_TABLE
}
+76
View File
@@ -0,0 +1,76 @@
package database
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
const (
CAMPAIGN_TABLE = "campaigns"
)
// Campaign is gorm data model
type Campaign struct {
ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`
CreatedAt *time.Time `gorm:"not null;index;"`
UpdatedAt *time.Time `gorm:"not null;index;"`
CloseAt *time.Time `gorm:"index;"`
ClosedAt *time.Time `gorm:"index;"`
AnonymizeAt *time.Time `gorm:"index;"`
AnonymizedAt *time.Time `gorm:"index;"`
SortField string `gorm:";"`
SortOrder string `gorm:";"` // 'asc,desc,random'
SendStartAt *time.Time `gorm:"index;"`
SendEndAt *time.Time `gorm:"index;"`
// ConstraintWeekDays is a binary format.
// 0b00000001 = 1 = sunday
// 0b00000010 = 2 = monday
// 0b00000100 = 4 = tuesday
// 0b00001000 = 8 = ...
// 0b00010000 = 16 =
// 0b00100000 = 32 =
// 0b01000000 = 64 =
ConstraintWeekDays *int `gorm:";"`
// hh:mm
ConstraintStartTime *string `gorm:"index;"`
// hh:mm
ConstraintEndTime *string `gorm:"index;"`
SaveSubmittedData bool `gorm:"not null;default:false"`
IsAnonymous bool `gorm:"not null;default:false"`
IsTest bool `gorm:"not null;default:false"`
// has one
CampaignTemplateID *uuid.UUID `gorm:"index;type:uuid;"`
CampaignTemplate *CampaignTemplate
// can has one
CompanyID *uuid.UUID `gorm:"index;type:uuid;index;uniqueIndex:idx_campaigns_unique_name_and_company_id;"`
Company *Company
DenyPageID *uuid.UUID `gorm:"type:uuid;index;"`
DenyPage *Page `gorm:"foreignKey:DenyPageID;references:ID"`
// NotableEventID notable event for this campaign
NotableEvent *Event `gorm:"foreignKey:NotableEventID;references:ID"`
NotableEventID *uuid.UUID `gorm:"type:uuid;index"`
WebhookID *uuid.UUID `gorm:"type:uuid;index;"`
// has many-to-many
RecipientGroups []*RecipientGroup `gorm:"many2many:campaign_recipient_groups"`
AllowDeny []*AllowDeny `gorm:"many2many:campaign_allow_denies"`
Name string `gorm:"not null;uniqueIndex:idx_campaigns_unique_name_and_company_id"`
}
func (c *Campaign) Migrate(db *gorm.DB) error {
// SQLITE
// ensure name + company id is unique
return UniqueIndexNameAndNullCompanyID(db, "campaigns")
}
func (Campaign) TableName() string {
return CAMPAIGN_TABLE
}
+22
View File
@@ -0,0 +1,22 @@
package database
import (
"github.com/google/uuid"
)
const (
CAMPAIGN_ALLOW_DENY_TABLE = "campaign_allow_denies"
)
// CampaignAllowDeny is a gorm data model
// is a table of those allow deny lists that belong to a campaign
type CampaignAllowDeny struct {
CampaignID *uuid.UUID `gorm:"not null;index;type:uuid;uniqueIndex:idx_campaign_allow_denies;"`
Campaign *Campaign
AllowDenyID *uuid.UUID `gorm:"not null;index;type:uuid;uniqueIndex:idx_campaign_allow_denies;"`
AllowDeny *AllowDeny
}
func (CampaignAllowDeny) TableName() string {
return CAMPAIGN_ALLOW_DENY_TABLE
}
+52
View File
@@ -0,0 +1,52 @@
package database
import (
"reflect"
"time"
"github.com/google/uuid"
)
const (
CAMPAIGN_EVENT_TABLE = "campaign_events"
)
// Campaign is gorm data model
type CampaignEvent struct {
ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`
CreatedAt *time.Time `gorm:"not null;index;"`
UpdatedAt *time.Time `gorm:"not null;"`
// arbitrary data
Data string `gorm:"not null;"`
// has one
CampaignID *uuid.UUID `gorm:"not null;index;type:uuid;"`
EventID *uuid.UUID `gorm:"not null;index;type:uuid;"`
// can has one
UserAgent string `gorm:";"`
IPAddress string `gorm:";"`
// AnonymizedID is set when the recipient has been anonymized
AnonymizedID *uuid.UUID `gorm:"type:uuid;index;"`
// if null either the event has no recipient or the recipient has been anonymized
RecipientID *uuid.UUID `gorm:"index;type:uuid;"`
Recipient *Recipient
CompanyID *uuid.UUID `gorm:"index;type:uuid;index;"`
}
// RecipientCampaignEvent is a aggregated read-only model
type RecipientCampaignEvent struct {
CampaignEvent
Name string // event name
CampaignName string
}
func (CampaignEvent) TableName() string {
return CAMPAIGN_EVENT_TABLE
}
var _ = reflect.TypeOf(RecipientCampaignEvent{})
+52
View File
@@ -0,0 +1,52 @@
package database
import (
"time"
"github.com/google/uuid"
)
const (
CAMPAIGN_RECIPIENT_TABLE_NAME = "campaign_recipients"
)
// CampaigReciever is gorm data model
// this model/table is primarily used to keep track of who and when should recieve a campaign
type CampaignRecipient struct {
ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`
CreatedAt *time.Time `gorm:"not null;index;"`
UpdatedAt *time.Time `gorm:"not null;index;"`
Campaign *Campaign
CampaignID *uuid.UUID `gorm:"not null;type:uuid;uniqueIndex:idx_campaign_recipients_campaign_id_recipient_id;"`
// CancelledAt *time.Time `gorm:"index;"`
CancelledAt *time.Time `gorm:"index;"`
// when it should be send
SendAt *time.Time `gorm:"index;"`
// when it was last attempted send
LastAttemptAt *time.Time `gorm:"index;"`
// when it was sent
SentAt *time.Time `gorm:"index;"`
// self-managed
SelfManaged bool `gorm:"not null;default:false;"`
// AnonymizedID is set when the recipient has been anonymized
AnonymizedID *uuid.UUID `gorm:"type:uuid;"`
Recipient *Recipient
// A null recipientID means that the data has been anonymized
RecipientID *uuid.UUID `gorm:"type:uuid;index;uniqueIndex:idx_campaign_recipients_campaign_id_recipient_id;"`
// NotableEventID is the most notable event for this recipient
NotableEvent *Event `gorm:"foreignKey:NotableEventID;references:ID"`
NotableEventID *uuid.UUID `gorm:"type:uuid;index"`
}
func (CampaignRecipient) TableName() string {
return CAMPAIGN_RECIPIENT_TABLE_NAME
}

Some files were not shown because too many files have changed in this diff Show More