mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-08-23 10:52:44 +02:00
Initial open source release
This commit is contained in:
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
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// CampaignRecipientGroup is gorm data model
|
||||
// is a table of those recipient groups that belong to a campaign
|
||||
type CampaignRecipientGroup struct {
|
||||
CampaignID *uuid.UUID `gorm:"not null;index;type:uuid;uniqueIndex:idx_campaign_recipient_group;"`
|
||||
Campaign *Campaign
|
||||
|
||||
RecipientGroupID *uuid.UUID `gorm:"not null;index;type:uuid;uniqueIndex:idx_campaign_recipient_group;"`
|
||||
RecipientGroup *RecipientGroup
|
||||
}
|
||||
|
||||
func (CampaignRecipientGroup) TableName() string {
|
||||
return "campaign_recipient_groups"
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
CAMPAIGN_STATS_TABLE = "campaign_stats"
|
||||
)
|
||||
|
||||
// CampaignStats is gorm data model for aggregated campaign statistics
|
||||
type CampaignStats struct {
|
||||
ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid" json:"id"`
|
||||
CreatedAt *time.Time `gorm:"not null;index;" json:"createdAt"`
|
||||
UpdatedAt *time.Time `gorm:"not null;" json:"updatedAt"`
|
||||
|
||||
// Campaign reference
|
||||
CampaignID *uuid.UUID `gorm:"not null;unique;index;type:uuid;" json:"campaignId"`
|
||||
CampaignName string `gorm:"not null;" json:"campaignName"`
|
||||
CompanyID *uuid.UUID `gorm:"index;type:uuid;" json:"companyId"` // nullable for global campaigns
|
||||
|
||||
// Time metrics
|
||||
CampaignStartDate *time.Time `gorm:"index;" json:"campaignStartDate"`
|
||||
CampaignEndDate *time.Time `gorm:"index;" json:"campaignEndDate"`
|
||||
CampaignClosedAt *time.Time `gorm:"index;" json:"campaignClosedAt"`
|
||||
|
||||
// Volume metrics
|
||||
TotalRecipients int `gorm:"not null;default:0" json:"totalRecipients"`
|
||||
TotalEvents int `gorm:"not null;default:0" json:"totalEvents"`
|
||||
|
||||
// Event type breakdowns
|
||||
EmailsSent int `gorm:"not null;default:0" json:"emailsSent"`
|
||||
TrackingPixelLoaded int `gorm:"not null;default:0" json:"trackingPixelLoaded"` // Email opens
|
||||
WebsiteVisits int `gorm:"not null;default:0" json:"websiteVisits"` // Link clicks
|
||||
DataSubmissions int `gorm:"not null;default:0" json:"dataSubmissions"` // Form submissions
|
||||
|
||||
// Success rates (as percentages for quick display)
|
||||
OpenRate float64 `gorm:"not null;default:0" json:"openRate"`
|
||||
ClickRate float64 `gorm:"not null;default:0" json:"clickRate"`
|
||||
SubmissionRate float64 `gorm:"not null;default:0" json:"submissionRate"`
|
||||
|
||||
// Campaign metadata
|
||||
TemplateName string `gorm:"" json:"templateName"`
|
||||
CampaignType string `gorm:"" json:"campaignType"` // 'scheduled', 'self-managed'
|
||||
}
|
||||
|
||||
func (CampaignStats) TableName() string {
|
||||
return CAMPAIGN_STATS_TABLE
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
CAMPAIGN_TEMPLATE_TABLE = "campaign_templates"
|
||||
)
|
||||
|
||||
// CampaignTemplate is gorm data model
|
||||
type CampaignTemplate 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;index;uniqueIndex:idx_campaign_templates_unique_name_and_company_id;"`
|
||||
|
||||
URLPath string `gorm:"not null;default:'';index"`
|
||||
|
||||
// IsUsable indicates if a template is usable based on if it has all the required
|
||||
// data such as domainID, landingPage and etc to be used in a campaign
|
||||
IsUsable bool `gorm:"not null;default:false;index"`
|
||||
|
||||
// has-a
|
||||
LandingPageID *uuid.UUID `gorm:"type:uuid;index;"`
|
||||
LandingPage *Page `gorm:"references:LandingPage;foreignKey:LandingPageID;references:ID;"`
|
||||
|
||||
DomainID *uuid.UUID `gorm:"type:uuid;index;"`
|
||||
Domain *Domain `gorm:"foreignKey:DomainID"`
|
||||
|
||||
URLIdentifierID *uuid.UUID `gorm:"not null;type:uuid;index"`
|
||||
URLIdentifier *Identifier `gorm:"references:foreignKey:URLIdentifierID;references:ID"`
|
||||
|
||||
StateIdentifierID *uuid.UUID `gorm:"type:uuid;index"`
|
||||
StateIdentifier *Identifier `gorm:"references:foreignKey:StateIdentifierID;references:ID"`
|
||||
|
||||
// has-a optional
|
||||
BeforeLandingPageID *uuid.UUID `gorm:"type:uuid;index"`
|
||||
BeforeLandingPage *Page `gorm:"foreignkey:BeforeLandingPageID;references:ID"`
|
||||
|
||||
AfterLandingPageID *uuid.UUID `gorm:"type:uuid;index"`
|
||||
AfterLandingPage *Page `gorm:"foreignKey:AfterLandingPageID;references:ID"`
|
||||
|
||||
AfterLandingPageRedirectURL string `gorm:"not null;"`
|
||||
|
||||
EmailID *uuid.UUID `gorm:"type:uuid;index;"`
|
||||
Email *Email `gorm:"foreignKey:EmailID;references:ID;"`
|
||||
|
||||
SMTPConfigurationID *uuid.UUID `gorm:"type:uuid;index;"`
|
||||
SMTPConfiguration *SMTPConfiguration `gorm:"foreignKey:SMTPConfigurationID"`
|
||||
|
||||
APISenderID *uuid.UUID `gorm:"type:uuid;index;"`
|
||||
APISender *APISender `gorm:"foreignKey:APISenderID"`
|
||||
|
||||
// can belong-to
|
||||
CompanyID *uuid.UUID `gorm:"type:uuid;index;uniqueIndex:idx_campaign_templates_unique_name_and_company_id"`
|
||||
Company *Company `gorm:"foreignKey:CompanyID"`
|
||||
}
|
||||
|
||||
func (e *CampaignTemplate) Migrate(db *gorm.DB) error {
|
||||
// SQLITE
|
||||
// ensure name + company id is unique
|
||||
return UniqueIndexNameAndNullCompanyID(db, "campaign_templates")
|
||||
}
|
||||
|
||||
func (CampaignTemplate) TableName() string {
|
||||
return CAMPAIGN_TEMPLATE_TABLE
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
COMPANY_TABLE = "companies"
|
||||
)
|
||||
|
||||
type Company 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;unique;index"`
|
||||
|
||||
// backref: many-to-one
|
||||
Users []*User //`gorm:"foreignKey:CompanyID;"`
|
||||
RecipientGroups []*RecipientGroup //`gorm:"foreignKey:CompanyID;"`
|
||||
}
|
||||
|
||||
func (Company) TableName() string {
|
||||
return COMPANY_TABLE
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
DOMAIN_TABLE = "domains"
|
||||
)
|
||||
|
||||
// Domain is gorm data model
|
||||
type Domain 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:"index;type:uuid;"`
|
||||
Name string `gorm:"not null;unique;"`
|
||||
ManagedTLSCerts bool `gorm:"not null;index;default:false"`
|
||||
OwnManagedTLS bool `gorm:"not null;index;default:false"`
|
||||
HostWebsite bool `gorm:"not null;"`
|
||||
PageContent string
|
||||
PageNotFoundContent string
|
||||
RedirectURL string
|
||||
// could has-one
|
||||
Company *Company
|
||||
}
|
||||
|
||||
func (Domain) TableName() string {
|
||||
return DOMAIN_TABLE
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
EMAIL_TABLE = "emails"
|
||||
)
|
||||
|
||||
// Email is a gorm data model
|
||||
type Email 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;index;uniqueIndex:idx_emails_name_company_id;"`
|
||||
Content string `gorm:"not null;"`
|
||||
|
||||
AddTrackingPixel bool `gorm:"not null;"`
|
||||
|
||||
// mail fields
|
||||
// Envelope header - Bounce / Return-Path
|
||||
MailFrom string `gorm:"not null;"`
|
||||
// Mail header
|
||||
Subject string `gorm:"not null;"`
|
||||
From string `gorm:"not null;"`
|
||||
|
||||
// many to many
|
||||
Attachments []*Attachment `gorm:"many2many:email_attachments;"`
|
||||
|
||||
// can belong to
|
||||
CompanyID *uuid.UUID `gorm:"index;type:uuid;uniqueIndex:idx_emails_name_company_id;"`
|
||||
Company *Company
|
||||
}
|
||||
|
||||
func (e *Email) Migrate(db *gorm.DB) error {
|
||||
// SQLITE
|
||||
// ensure name + null company id is unique
|
||||
return UniqueIndexNameAndNullCompanyID(db, "emails")
|
||||
}
|
||||
|
||||
func (Email) TableName() string {
|
||||
return EMAIL_TABLE
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// EmailAttachment is a gorm data model
|
||||
// it is a many to many relationship between messages and attachments
|
||||
type EmailAttachment struct {
|
||||
EmailID *uuid.UUID `gorm:"primary_key;not null;index;type:uuid;unique_index:idx_message_attachment;"`
|
||||
AttachmentID *uuid.UUID `gorm:"primary_key;not null;index;type:uuid;unique_index:idx_message_attachment;"`
|
||||
}
|
||||
|
||||
func (EmailAttachment) TableName() string {
|
||||
return "email_attachments"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
EVENT_TABLE = "events"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`
|
||||
CreatedAt *time.Time `gorm:"not null;index;"`
|
||||
Name string `gorm:"not null;index;"`
|
||||
}
|
||||
|
||||
func (Event) TableName() string {
|
||||
return EVENT_TABLE
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/phishingclub/phishingclub/config"
|
||||
"github.com/phishingclub/phishingclub/errs"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// FromConfig database factory from config
|
||||
func FromConfig(conf config.Config) (*gorm.DB, error) {
|
||||
var db *gorm.DB
|
||||
switch conf.Database().Engine {
|
||||
case config.DefaultAdministrationUseSqlite:
|
||||
var err error
|
||||
dsn := fmt.Sprintf(
|
||||
"%s?_journal_mode=WAL&_busy_timeout=5000&_synchronous=NORMAL&_foreign_keys=ON",
|
||||
conf.Database().DSN,
|
||||
)
|
||||
db, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
// SetMaxOpenConns sets the maximum number of open connections to the database.
|
||||
// without this, gorutines doing simultaneous db operations will cause
|
||||
// "database is locked" error when using sqlite with a high concurrency
|
||||
// this is because sqlite only allows one write operation at a time
|
||||
// and locks the whole database for the duration any write operation
|
||||
innerDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
innerDB.SetMaxIdleConns(1)
|
||||
default:
|
||||
return nil, config.ErrInvalidDatabase
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
IDENTIFIER_TABLE = "identifiers"
|
||||
)
|
||||
|
||||
type Identifier struct {
|
||||
ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`
|
||||
Name string `gorm:"not null;uniqueIndex"`
|
||||
}
|
||||
|
||||
func (Identifier) TableName() string {
|
||||
return IDENTIFIER_TABLE
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Option is a database option (options stored in the database)
|
||||
type Option struct {
|
||||
ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`
|
||||
Key string `gorm:"not null;unique;index"`
|
||||
Value string `gorm:"not null;"`
|
||||
}
|
||||
|
||||
func (Option) TableName() string {
|
||||
return "options"
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
PAGE_TABLE = "pages"
|
||||
)
|
||||
|
||||
// Page is a gorm data model
|
||||
type Page 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:"index;uniqueIndex:idx_pages_unique_name_and_company_id;type:uuid"`
|
||||
Name string `gorm:"not null;index;uniqueIndex:idx_pages_unique_name_and_company_id;"`
|
||||
Content string `gorm:"not null;"`
|
||||
|
||||
// could has-one
|
||||
Company *Company
|
||||
}
|
||||
|
||||
func (e *Page) Migrate(db *gorm.DB) error {
|
||||
// SQLITE
|
||||
// ensure name + company id is unique
|
||||
return UniqueIndexNameAndNullCompanyID(db, "pages")
|
||||
}
|
||||
|
||||
func (Page) TableName() string {
|
||||
return PAGE_TABLE
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
RECIPIENT_TABLE = "recipients"
|
||||
)
|
||||
|
||||
// Recipient is a gorm data model
|
||||
type Recipient 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"`
|
||||
DeletedAt *time.Time `gorm:"index;"`
|
||||
|
||||
Email *string `gorm:";uniqueIndex"`
|
||||
Phone *string `gorm:";index"`
|
||||
ExtraIdentifier *string `gorm:";index"`
|
||||
|
||||
FirstName string `gorm:";"`
|
||||
LastName string `gorm:";"`
|
||||
Position string `gorm:";"`
|
||||
Department string `gorm:";"`
|
||||
City string `gorm:";"`
|
||||
Country string `gorm:";"`
|
||||
Misc string `gorm:";"`
|
||||
|
||||
// can belong to
|
||||
CompanyID *uuid.UUID `gorm:"type:uuid;index;"`
|
||||
Company *Company
|
||||
|
||||
// many-to-many
|
||||
Groups []RecipientGroup `gorm:"many2many:recipient_group_recipients;"`
|
||||
}
|
||||
|
||||
func (Recipient) TableName() string {
|
||||
return RECIPIENT_TABLE
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package database
|
||||
|
||||
// RecipientCampaignEventView is a view read-only model
|
||||
type RecipientCampaignEventView struct {
|
||||
CampaignEvent
|
||||
|
||||
Name string // event name
|
||||
CampaignName string
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user