mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-06-09 16:03:58 +02:00
Initial open source release
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
Vendored
+25
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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**
|
||||
@@ -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
|
||||
@@ -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/>.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,263 @@
|
||||
# Phishing Club
|
||||
|
||||
[](https://github.com/phishingclub/phishingclub/releases/latest)
|
||||
[](https://github.com/phishingclub/phishingclub/releases)
|
||||
[](https://discord.gg/Zssps7U8gX)
|
||||
[](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.
|
||||
@@ -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"]
|
||||
@@ -0,0 +1 @@
|
||||
This service is used for testing and using the functionality of the api sender and webhook functionality.
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/phishingclub/phishingclub/api-test-server
|
||||
|
||||
go 1.23.8
|
||||
@@ -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
|
||||
}
|
||||
Executable
+49
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
**/node_modules/
|
||||
./.dev/**
|
||||
@@ -0,0 +1,10 @@
|
||||
.dev-air/*
|
||||
.dev/**/*
|
||||
db.sqlite3
|
||||
*.sqlite3
|
||||
# air
|
||||
air
|
||||
vendor
|
||||
frontend/build/*
|
||||
go.work
|
||||
go.work.sum
|
||||
@@ -0,0 +1 @@
|
||||
vendor
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"lsp": {
|
||||
"gopls": {
|
||||
"initialization_options": {
|
||||
"buildFlags": ["-tags=dev"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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-----
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package build
|
||||
|
||||
type flags struct {
|
||||
Production bool
|
||||
}
|
||||
|
||||
// Flags is a global variable for build flags
|
||||
var Flags = flags{
|
||||
Production: false,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build production
|
||||
|
||||
package build
|
||||
|
||||
func init() {
|
||||
Flags.Production = true
|
||||
}
|
||||
Executable
+32
@@ -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
|
||||
Executable
+13
@@ -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
|
||||
Executable
+8
@@ -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"
|
||||
Executable
+66
@@ -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"
|
||||
Executable
+31
@@ -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"
|
||||
Executable
+6
@@ -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
|
||||
Vendored
+80
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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{},
|
||||
)
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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{},
|
||||
)
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package data
|
||||
|
||||
const ASSET_GLOBAL_FOLDER = "shared"
|
||||
const ATTACHMENT_GLOBAL_FOLDER = "shared"
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package data
|
||||
|
||||
const (
|
||||
MESSAGE_TYPE_EMAIL = "email"
|
||||
MESSAGE_TYPE_SMS = "sms"
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package data
|
||||
|
||||
const (
|
||||
PAGE_TYPE_BEFORE = "before"
|
||||
PAGE_TYPE_LANDING = "landing"
|
||||
PAGE_TYPE_AFTER = "after"
|
||||
PAGE_TYPE_DONE = "done"
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package data
|
||||
|
||||
const (
|
||||
// PERMISSION_ALLOW_GLOBAL allows all permissions, it is the god mode of permissions
|
||||
PERMISSION_ALLOW_GLOBAL = "*"
|
||||
)
|
||||
@@ -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: {},
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{})
|
||||
@@ -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
Reference in New Issue
Block a user