Initial open source release

This commit is contained in:
Ronni Skansing
2025-08-21 16:14:09 +02:00
commit 11cf01f08e
488 changed files with 97180 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
root = "."
testdata_dir = "testdata"
tmp_dir = ".dev"
[build]
args_bin = ["-files ./.dev", "-config ./config.docker.json"]
bin = "/app/.dev-air/platform"
cmd = "CGO_ENABLED=1 go build dev -o ./.dev-air/platform main.go" # community
delay = 1000
exclude_dir = [
"out",
".dev",
"vendor",
"testdata",
".git",
"build",
"frontend/build/",
]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
#full_bin = "dlv exec --log-dest /.dev/dlv.log --accept-multiclient --headless --continue --listen 0.0.0.0:2345 --api-version 2 /app/air/platform --"
# to debug something early in the application like at boot up, then remove the continue flag
# this will make the debugger not start the program before a client attaches to it
#full_bin = "dlv exec --accept-multiclient --headless --continue --listen 0.0.0.0:2345 --api-version 2 /app/air/platform --"
include_dir = [""]
include_ext = ["go", "tpl", "tmpl", "html"]
kill_delay = "0s"
log = "build-errors.log"
send_interrupt = true #false
stop_on_error = true
[color]
# app = "red"
# build = "yellow"
# main = "magenta"
# runner = "green"
# watcher = "cyan"
[log]
time = false
main_only = false
[misc]
clean_on_exit = false
[screen]
clear_on_rebuild = false
+2
View File
@@ -0,0 +1,2 @@
**/node_modules/
./.dev/**
View File
+10
View File
@@ -0,0 +1,10 @@
.dev-air/*
.dev/**/*
db.sqlite3
*.sqlite3
# air
air
vendor
frontend/build/*
go.work
go.work.sum
+1
View File
@@ -0,0 +1 @@
vendor
+9
View File
@@ -0,0 +1,9 @@
{
"lsp": {
"gopls": {
"initialization_options": {
"buildFlags": ["-tags=dev"]
}
}
}
}
+27
View File
@@ -0,0 +1,27 @@
# development docker file
FROM golang:1.24.5
EXPOSE 8000 8001
WORKDIR /app
# Add user
# Add group with ID 1000 and user with ID 1000
RUN groupadd -g 1000 appuser && \
useradd -r -u 1000 -g appuser appuser -d /home/appuser -m
# install deps
#RUN go install github.com/cosmtrek/air@latest \
#RUN go install github.com/go-delve/delve/cmd/dlv@1.9.1
COPY go.mod /app/go.mod
COPY go.sum /app/go.sum
RUN mkdir -p /app/.dev
RUN mkdir -p /app/.test
RUN chown -R appuser:appuser /app
USER appuser
RUN go install github.com/cosmtrek/air@v1.40.4 && go install github.com/go-delve/delve/cmd/dlv@latest
RUN go mod tidy
CMD ["air", "-c", "/.dev-air/.air.docker.toml"]
+53
View File
@@ -0,0 +1,53 @@
### Platform backend
Install AIR for auto-reloading `go install github.com/cosmtrek/air@latest`
To start the project locally run: `make backend-dev`
Check the terminal output to see the address, username and password
### Production / Deployment
The program must be executable
`chmod +x ./path/to/binary`
The program must have rights to serve on privliged ports
`sudo setcap CAP_NET_BIND_SERVICE=+eip /path/to/binary`
### Known Issues
#### Hot reloading not working / New files now working
If a file accessible in the frontend after adding it, save an existing file to trigger a rebuild. This should now include the new file. If this does not work, try to run `make sorry` which will restart all services.
### Debugging with AIR via. docker and delve
To debug the backend you must uncomment the full bin line in the docker air toml file.
Attach to the debugger to trigger starting the backend.
Do not edit files while in debug mode, instead stop the debugger, edit the file and start the debugger again.
### docker-compose
docker-compose is a plugin for docker that allows you to define multiple services in a single file.
Before this was a stand alone python script run with `docker-compose` but as a plugin this is `docker compose`.
In the makefile, you can edit the top line to change if docker compose is called with or without the dash (-) in the middle.
# Notes about allow listing
{
admin_allowed
trusted_proxies
trusted_ip_header
}
if no admin_allowed is set, all IPs are welcome.
If no trusted proxies are set, headers such as X-Forwarded-By will not be used.
If TrustedIPHeader is set, then this header is used for finding the real IP.
For example cloudflare uses cf-connecting-ip.
If TrustedIPHeader is not set and trusted_proxies is set, then it trusts the IP
from X-Forwarded
# SSO Setup
## Microsoft Entra-ID
### Ensure only specific tenant user's can log in.
In 'properties' set 'Assignment required' to 'Yes'.
In 'Users and groups' add the users or groups that should be able to log into the application.
+89
View File
@@ -0,0 +1,89 @@
package acme
import (
_ "embed"
"github.com/caddyserver/certmagic"
"github.com/go-errors/errors"
"github.com/phishingclub/phishingclub/config"
"github.com/phishingclub/phishingclub/database"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"gorm.io/gorm"
)
// maintenanceCore wraps the original core to filter maintenance messages
type maintenanceCore struct {
zapcore.Core
originalCore zapcore.Core
}
func (c *maintenanceCore) Check(ent zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry {
if ent.Message == "started background certificate maintenance" {
c.Core = c.originalCore
return nil
}
return c.Core.Check(ent, ce)
}
func (c *maintenanceCore) With(fields []zapcore.Field) zapcore.Core {
return &maintenanceCore{
Core: c.Core.With(fields),
originalCore: c.originalCore,
}
}
func setupCertMagic(
certStoragePath string,
conf *config.Config,
db *gorm.DB,
logger *zap.SugaredLogger,
) (*certmagic.Config, *certmagic.Cache, error) {
l := logger.Desugar()
usedLogger := l.Core()
if l.Level() != zap.DebugLevel {
usedLogger = &maintenanceCore{
Core: l.Core(),
originalCore: usedLogger,
}
}
filteredLogger := zap.New(usedLogger)
// Create main config first
certmagic.DefaultACME.Logger = l
certmagic.DefaultACME.Email = conf.ACMEEmail()
mainConfig := certmagic.NewDefault()
mainConfig.Logger = l
mainConfig.Storage = &certmagic.FileStorage{Path: certStoragePath}
mainConfig.OnDemand = &certmagic.OnDemandConfig{
DecisionFunc: func(name string) error {
// check if admin server with auto TLS
if conf.TLSAuto() && conf.TLSHost() == name {
return nil
}
// check phishing host with managed TLS
res := db.
Select("id").
Where("name = ?", name).
Where("managed_tls_certs IS true").
First(&database.Domain{})
if res.RowsAffected > 0 {
return nil
}
return errors.Errorf("not allowing TLS on-demand request for '%s'", name)
},
}
// create cache with config getter
var finalConfig *certmagic.Config
defaultCache := certmagic.NewCache(certmagic.CacheOptions{
GetConfigForCert: func(cert certmagic.Certificate) (*certmagic.Config, error) {
return finalConfig, nil
},
Logger: filteredLogger,
})
// create final config that uses the cache
finalConfig = certmagic.New(defaultCache, *mainConfig)
return finalConfig, defaultCache, nil
}
+56
View File
@@ -0,0 +1,56 @@
//go:build dev
package acme
import (
"crypto/x509"
_ "embed"
"encoding/pem"
"log"
"github.com/caddyserver/certmagic"
"github.com/phishingclub/phishingclub/config"
"github.com/phishingclub/phishingclub/errs"
"go.uber.org/zap"
"gorm.io/gorm"
)
const DEV_ACME_URL = "https://pebble:14000/dir"
//go:embed pebble.minica.pem
var acmeRootCertPemBlock []byte
func loadDevelopmentPebbleCertificate() (*x509.Certificate, error) {
certDERBlock, _ := pem.Decode(acmeRootCertPemBlock)
if certDERBlock == nil {
log.Fatal("Failed to parse the certificate PEM.")
}
acmeRootCert, err := x509.ParseCertificate(certDERBlock.Bytes)
if err != nil {
log.Fatal(err)
}
return acmeRootCert, nil
}
// SetupCertMagic creates a certmagic config for development
// and checks which domains are allowed from the db before getting a certificate
func SetupCertMagic(
certStoragePath string,
conf *config.Config,
db *gorm.DB,
logger *zap.SugaredLogger,
) (*certmagic.Config, *certmagic.Cache, error) {
cert, err := loadDevelopmentPebbleCertificate()
if err != nil {
return nil, nil, errs.Wrap(err)
}
pool := x509.NewCertPool()
pool.AddCert(cert)
certmagic.DefaultACME = certmagic.ACMEIssuer{
CA: DEV_ACME_URL,
TestCA: DEV_ACME_URL,
Agreed: true,
TrustedRoots: pool,
}
return setupCertMagic(certStoragePath, conf, db, logger)
}
+23
View File
@@ -0,0 +1,23 @@
//go:build !dev
package acme
import (
_ "embed"
"github.com/caddyserver/certmagic"
"github.com/phishingclub/phishingclub/config"
"go.uber.org/zap"
"gorm.io/gorm"
)
// SetupCertMagic creates a certmagic config for development
// and checks which domains are allowed from the db before getting a certificate
func SetupCertMagic(
certStoragePath string,
conf *config.Config,
db *gorm.DB,
logger *zap.SugaredLogger,
) (*certmagic.Config, *certmagic.Cache, error) {
return setupCertMagic(certStoragePath, conf, db, logger)
}
+20
View File
@@ -0,0 +1,20 @@
{
"pebble": {
"listenAddress": "0.0.0.0:14000",
"managementListenAddress": "0.0.0.0:15000",
"certificate": "test/certs/localhost/cert.pem",
"privateKey": "test/certs/localhost/key.pem",
"httpPort": 8000,
"tlsPort": 8001,
"ocspResponderURL": "",
"externalAccountBindingRequired": false,
"domainBlocklist": [
"blocked-domain.example"
],
"retryAfter": {
"authz": 3,
"order": 5
},
"certificateValidityPeriod": 157766400
}
}
+19
View File
@@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDCTCCAfGgAwIBAgIIJOLbes8sTr4wDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE
AxMVbWluaWNhIHJvb3QgY2EgMjRlMmRiMCAXDTE3MTIwNjE5NDIxMFoYDzIxMTcx
MjA2MTk0MjEwWjAgMR4wHAYDVQQDExVtaW5pY2Egcm9vdCBjYSAyNGUyZGIwggEi
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC5WgZNoVJandj43kkLyU50vzCZ
alozvdRo3OFiKoDtmqKPNWRNO2hC9AUNxTDJco51Yc42u/WV3fPbbhSznTiOOVtn
Ajm6iq4I5nZYltGGZetGDOQWr78y2gWY+SG078MuOO2hyDIiKtVc3xiXYA+8Hluu
9F8KbqSS1h55yxZ9b87eKR+B0zu2ahzBCIHKmKWgc6N13l7aDxxY3D6uq8gtJRU0
toumyLbdzGcupVvjbjDP11nl07RESDWBLG1/g3ktJvqIa4BWgU2HMh4rND6y8OD3
Hy3H8MY6CElL+MOCbFJjWqhtOxeFyZZV9q3kYnk9CAuQJKMEGuN4GU6tzhW1AgMB
AAGjRTBDMA4GA1UdDwEB/wQEAwIChDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYB
BQUHAwIwEgYDVR0TAQH/BAgwBgEB/wIBADANBgkqhkiG9w0BAQsFAAOCAQEAF85v
d40HK1ouDAtWeO1PbnWfGEmC5Xa478s9ddOd9Clvp2McYzNlAFfM7kdcj6xeiNhF
WPIfaGAi/QdURSL/6C1KsVDqlFBlTs9zYfh2g0UXGvJtj1maeih7zxFLvet+fqll
xseM4P9EVJaQxwuK/F78YBt0tCNfivC6JNZMgxKF59h0FBpH70ytUSHXdz7FKwix
Mfn3qEb9BXSk0Q3prNV5sOV3vgjEtB4THfDxSz9z3+DepVnW3vbbqwEbkXdk3j82
2muVldgOUgTwK8eT+XdofVdntzU/kzygSAtAQwLJfn51fS1GvEcYGBc1bDryIqmF
p9BI7gVKtWSZYegicA==
-----END CERTIFICATE-----
+187
View File
@@ -0,0 +1,187 @@
package acme
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"os"
"path/filepath"
"time"
"github.com/go-errors/errors"
"github.com/phishingclub/phishingclub/build"
"go.uber.org/zap"
)
// Information is a struct for certificate information
type Information struct {
CommonName string
Organization []string
Country []string
Province []string
Locality []string
StreetAddress []string
PostalCode []string
}
// NewInformation creates a new Information
func NewInformation(
commonName string,
organization []string,
country []string,
province []string,
locality []string,
streetAddress []string,
postalCode []string,
) Information {
return Information{
Organization: organization,
Country: country,
Province: province,
Locality: locality,
StreetAddress: streetAddress,
PostalCode: postalCode,
}
}
// NewInformationWithDefault creates a new Information with default values
func NewInformationWithDefault() Information {
return NewInformation(
"",
[]string{""},
[]string{""},
[]string{""},
[]string{""},
[]string{""},
[]string{""},
)
}
// CreateSelfSignedCert creates a self signed certificate with provided hostnames
func CreateSelfSignedCert(
logger *zap.SugaredLogger,
info Information,
hostnames []string,
publicPath string,
privatePath string,
) error {
// Process hostnames into IP addresses and DNS names
var ipAddresses []net.IP
var dnsNames []string
if !build.Flags.Production {
ipAddresses = append(ipAddresses, net.IPv4(127, 0, 0, 1), net.IPv6loopback)
dnsNames = append(dnsNames, "localhost")
}
for _, h := range hostnames {
if ip := net.ParseIP(h); ip != nil {
ipAddresses = append(ipAddresses, ip)
} else {
dnsNames = append(dnsNames, h)
}
}
// Use info.CommonName if provided, otherwise use first hostname or "localhost"
commonName := info.CommonName
if commonName == "" || commonName == "127.0.0.1" {
if len(hostnames) > 0 {
commonName = hostnames[0]
} else {
commonName = "localhost"
}
}
// Create certificate with appropriate SAN extensions
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
return errors.Errorf("failed to generate serial number: %s", err)
}
cert := &x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
CommonName: commonName,
Organization: info.Organization,
Country: info.Country,
Province: info.Province,
Locality: info.Locality,
StreetAddress: info.StreetAddress,
PostalCode: info.PostalCode,
},
IPAddresses: ipAddresses,
DNSNames: dnsNames,
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(10, 0, 0),
SubjectKeyId: []byte{0, 0, 0, 0, 0},
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
BasicConstraintsValid: true,
}
certPrivKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return errors.Errorf("failed to generate private key: %s", err)
}
certBytes, err := x509.CreateCertificate(rand.Reader, cert, cert, &certPrivKey.PublicKey, certPrivKey)
if err != nil {
return errors.Errorf("failed to create certificate: %s", err)
}
// Create directories if they don't exist
certDir := filepath.Dir(publicPath)
if err := os.MkdirAll(certDir, 0750); err != nil {
return errors.Errorf("failed to create certificate directory: %s", err)
}
keyDir := filepath.Dir(privatePath)
if err := os.MkdirAll(keyDir, 0750); err != nil {
return errors.Errorf("failed to create key directory: %s", err)
}
// Write certificate
// #nosec
certOut, err := os.Create(publicPath)
if err != nil {
return errors.Errorf("failed to open certificate file for writing: %s", err)
}
defer certOut.Close()
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: certBytes}); err != nil {
return errors.Errorf("failed to write certificate: %s", err)
}
// Write private key
// #nosec
keyOut, err := os.OpenFile(privatePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return errors.Errorf("failed to open key file for writing: %s", err)
}
defer keyOut.Close()
privBlock := &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(certPrivKey),
}
if err := pem.Encode(keyOut, privBlock); err != nil {
return errors.Errorf("failed to write private key: %s", err)
}
/*
logger.Debugf("generated self-signed certificate",
"certificate", publicPath,
"key", privatePath,
"common_name", commonName,
"ip_addresses", ipAddresses,
"dns_names", dnsNames,
)
*/
return nil
}
+173
View File
@@ -0,0 +1,173 @@
package api
import (
"fmt"
"net/http"
"github.com/go-errors/errors"
"github.com/gin-gonic/gin"
)
const (
// All constant here are used for frontend responses
NotFound = "Not found"
InvalidData = "Missing or invalid data"
Unauthorized = "Authorization failed"
Forbidden = "Access denied"
ServerError = "Internal server error"
InvalidCompanyID = "Invalid company ID"
InvalidDomainID = "Invalid domain ID"
InvalidMessageID = "Invalid message ID"
InvalidPageID = "Invalid page ID"
InvalidPageTypeID = "Invalid page type ID"
InvalidRecipientID = "Invalid recipient ID"
InvalidRecipientGroupID = "Invalid recipient group ID"
InvalidSMTPConfigurationID = "Invalid SMTP configuration ID"
CompanyNotFound = "Company not found"
)
// JSONResponse is the response structure for the API
type JSONResponse struct {
Success bool `json:"success"`
Data any `json:"data"`
Error string `json:"error"`
}
// JSONResponseHandler is a interface for API responses
type JSONResponseHandler interface {
OK(g *gin.Context, data any)
NotFound(g *gin.Context)
Unauthorized(g *gin.Context)
Forbidden(g *gin.Context)
BadRequest(g *gin.Context)
BadRequestMessage(g *gin.Context, message string)
ValidationFailed(g *gin.Context, field string, err error)
ServerError(g *gin.Context)
ServerErrorMessage(g *gin.Context, message string)
}
// jsonResponseHandler is a JSON API responder
type jsonResponseHandler struct{}
// NewJSONResponseHandler creates a new JSON responder
func NewJSONResponseHandler() JSONResponseHandler {
return &jsonResponseHandler{}
}
// newResponse creates a new JSON response
func (r *jsonResponseHandler) newResponse(
success bool,
data any,
errorMessage string,
) JSONResponse {
return JSONResponse{
Success: success,
Data: data,
Error: errorMessage,
}
}
// newOK creates a new OK response
func (r *jsonResponseHandler) newOK(data any) JSONResponse {
return r.newResponse(true, data, "")
}
// newError creates a new error response
func (r *jsonResponseHandler) newError(errorMessage string) JSONResponse {
return r.newResponse(false, nil, errorMessage)
}
// OK responds with 200 - OK
func (r *jsonResponseHandler) OK(g *gin.Context, data any) {
g.JSON(http.StatusOK, r.newOK(data))
}
// NotFound responds 404 - NOT FOUND
func (r *jsonResponseHandler) NotFound(g *gin.Context) {
g.JSON(
http.StatusNotFound,
r.newError(NotFound),
)
g.Abort()
}
// Unauthorized responds with 401 - UNAUTHORIZED
// generic error handler for authentication errors
func (r *jsonResponseHandler) Unauthorized(g *gin.Context) {
g.JSON(
http.StatusUnauthorized,
r.newError(Forbidden),
)
g.Abort()
}
// Forbidden responds with 403 - FORBIDDEN and a custom error message
// generic error handler for authorization errors
func (r *jsonResponseHandler) Forbidden(g *gin.Context) {
g.JSON(
http.StatusForbidden,
r.newError(Unauthorized),
)
g.Abort()
}
// BadRequest responds with 400 - BAD REQUEST
func (r *jsonResponseHandler) BadRequest(g *gin.Context) {
g.JSON(
http.StatusBadRequest,
r.newError(InvalidData),
)
g.Abort()
}
// BadRequestMessage responds with 400 - BAD REQUEST and a custom error message
func (r *jsonResponseHandler) BadRequestMessage(g *gin.Context, message string) {
g.JSON(
http.StatusBadRequest,
r.newError(message),
)
g.Abort()
}
func (r *jsonResponseHandler) unwrapErrorMessage(err error) string {
message := err.Error()
unwrapped := errors.Unwrap(err)
if unwrapped != nil {
message = r.unwrapErrorMessage(unwrapped)
}
return message
}
// ValidationFailed responds with 400 - BAD REQUEST and a validation error message
// that includes the field name and the validation error message
// if the err IS a ValidationError it will unwrap the validation error
// else it will use the error passed
func (r *jsonResponseHandler) ValidationFailed(g *gin.Context, field string, err error) {
message := r.unwrapErrorMessage(err)
g.JSON(
http.StatusBadRequest,
r.newError(
fmt.Sprintf("%s %s", field, message),
),
)
g.Abort()
}
// ServerError responds with 500 - INTERNAL SERVER ERROR
func (r *jsonResponseHandler) ServerError(g *gin.Context) {
g.JSON(
http.StatusInternalServerError,
r.newError(ServerError),
)
g.Abort()
}
// ServerError responds with 500 - INTERNAL SERVER ERROR and a custom error message
func (r *jsonResponseHandler) ServerErrorMessage(g *gin.Context, message string) {
g.JSON(
http.StatusInternalServerError,
r.newError(message),
)
g.Abort()
}
+817
View File
@@ -0,0 +1,817 @@
package app
import (
"context"
"crypto/tls"
"crypto/x509"
"embed"
"encoding/pem"
"fmt"
"io/fs"
"log"
"net"
"net/http"
"os"
"strings"
"time"
"github.com/caddyserver/certmagic"
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/acme"
"github.com/phishingclub/phishingclub/build"
"github.com/phishingclub/phishingclub/config"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/frontend"
"github.com/phishingclub/phishingclub/server"
"go.uber.org/zap"
)
const (
// health
ROUTE_V1_HEALTH = "/api/v1/healthz"
ROUTE_V1_LIVE = "/api/v1/livez"
ROUTE_V1_READY = "/api/v1/readyz"
// application
ROUTE_V1_FEATURE = "/api/v1/features"
ROUTE_V1_UPDATE_AVAILABLE = "/api/v1/update/available"
ROUTE_V1_UPDATE_AVAILABLE_CACHED = "/api/v1/update/available/cached"
ROUTE_V1_UPDATE = "/api/v1/update"
// user
ROUTE_V1_USER = "/api/v1/user"
ROUTE_V1_USER_ID = "/api/v1/user/:id"
ROUTE_V1_USER_LOGIN = "/api/v1/user/login"
ROUTE_V1_USER_LOGOUT = "/api/v1/user/logout"
// #nosec
ROUTE_V1_USER_PASSWORD = "/api/v1/user/password"
ROUTE_V1_USER_USERNAME = "/api/v1/user/username"
ROUTE_V1_USER_FULLNAME = "/api/v1/user/fullname"
ROUTE_V1_USER_EMAIL = "/api/v1/user/email"
ROUTE_V1_USER_SESSIONS = "/api/v1/user/sessions"
ROUTE_V1_USER_SESSIONS_INVALIDATE = "/api/v1/user/sessions/invalidate"
ROUTE_V1_USER_API = "/api/v1/user/api"
// sso
ROUTE_V1_SSO_ENTRA_ID = "/api/v1/sso/entra-id"
ROUTE_V1_SSO_ENTRA_ID_ENABLED = "/api/v1/sso/entra-id/enabled"
ROUTE_V1_SSO_ENTRA_ID_LOGIN = "/api/v1/sso/entra-id/login"
ROUTE_V1_SSO_ENTRA_ID_CALLBACK = "/api/v1/sso/entra-id/auth"
// mfa
ROUTE_V1_USER_MFA_TOTP_SETUP = "/api/v1/user/mfa/totp/setup"
ROUTE_V1_USER_MFA_TOTP_SETUP_VERIFY = "/api/v1/user/mfa/totp/setup/verify"
ROUTE_V1_USER_MFA_TOTP_VERIFY = "/api/v1/user/mfa/totp/verify"
ROUTE_V1_USER_MFA_TOTP = "/api/v1/user/mfa/totp"
ROUTE_V1_QR_FROM_TOTP = "/api/v1/qr/totp"
ROUTE_V1_QR_URL_TO_HTML = "/api/v1/qr/html"
// session
ROUTE_V1_SESSION_ID = "/api/v1/session/:id"
ROUTE_V1_SESSION_PING = "/api/v1/session/ping"
// company
ROUTE_V1_COMPANY = "/api/v1/company"
ROUTE_V1_COMPANY_ID = "/api/v1/company/:id"
ROUTE_V1_COMPANY_ID_EXPORT = "/api/v1/company/:id/export"
ROUTE_V1_COMPANY_ID_EXPORT_SHARED = "/api/v1/company/shared/export"
// option
ROUTE_V1_OPTION = "/api/v1/option"
ROUTE_V1_OPTION_GET = "/api/v1/option/:key"
// installation
ROUTE_V1_INSTALL = "/api/v1/install"
// domain
ROUTE_V1_DOMAIN = "/api/v1/domain"
ROUTE_V1_DOMAIN_SUBSET = "/api/v1/domain/subset"
ROUTE_V1_DOMAIN_ID = "/api/v1/domain/:id"
ROUTE_V1_DOMAIN_NAME = "/api/v1/domain/name/:domain"
// page
ROUTE_V1_PAGE = "/api/v1/page"
ROUTE_V1_PAGE_OVERVIEW = "/api/v1/page/overview"
ROUTE_V1_PAGE_ID = "/api/v1/page/:id"
ROUTE_V1_PAGE_CONTENT_ID = "/api/v1/page/:id/content"
// recipient and groups
ROUTE_V1_RECIPIENT = "/api/v1/recipient"
ROUTE_V1_RECIPIENT_IMPORT = "/api/v1/recipient/import"
ROUTE_V1_RECIPIENT_EXPORT = "/api/v1/recipient/:id/export"
ROUTE_V1_RECIPIENT_ID = "/api/v1/recipient/:id"
ROUTE_V1_RECIPIENT_ID_EVENTS = "/api/v1/recipient/:id/events"
ROUTE_V1_RECIPIENT_ID_STATS = "/api/v1/recipient/:id/stats"
ROUTE_V1_RECIPIENT_REPEAT_OFFENDERS = "/api/v1/recipient/repeat-offenders"
ROUTE_V1_RECIPIENT_GROUP = "/api/v1/recipient/group"
ROUTE_V1_RECIPIENT_GROUP_ID = "/api/v1/recipient/group/:id"
ROUTE_V1_RECIPIENT_GROUP_ID_IMPORT = "/api/v1/recipient/group/:id/import"
ROUTE_V1_RECIPIENT_GROUP_RECIPIENTS = "/api/v1/recipient/group/:id/recipients"
// logging
ROUTE_V1_LOG = "/api/v1/log"
ROUTE_V1_LOG_TEST = "/api/v1/log/test"
// smtp configuration
ROUTE_V1_SMTP_CONFIGURATION = "/api/v1/smtp-configuration"
ROUTE_V1_SMTP_CONFIGURATION_ID = "/api/v1/smtp-configuration/:id"
ROUTE_V1_SMTP_CONFIGURATION_ID_TEST_EMAIL = "/api/v1/smtp-configuration/:id/test-email"
ROUTE_V1_SMTP_CONFIGURATION_HEADERS = "/api/v1/smtp-configuration/:id/header"
ROUTE_V1_SMTP_HEADER_ID = "/api/v1/smtp-configuration/:id/header/:headerID"
// email
ROUTE_V1_EMAIL = "/api/v1/email"
ROUTE_V1_EMAIL_OVERVIEW = "/api/v1/email/overview"
ROUTE_V1_EMAIL_ID = "/api/v1/email/:id"
ROUTE_V1_EMAIL_SEND_TEST = "/api/v1/email/:id/send-test"
ROUTE_V1_EMAIL_CONTENT_ID = "/api/v1/email/:id/content"
// campaign
ROUTE_V1_CAMPAIGN_TEMPLATE = "/api/v1/campaign/template"
ROUTE_v1_CAMPAIGN_TEMPLATE_ID = "/api/v1/campaign/template/:id"
ROUTE_V1_CAMPAIGN = "/api/v1/campaign"
ROUTE_V1_CAMPAIGN_CALENDAR = "/api/v1/campaign/calendar"
ROUTE_V1_CAMPAIGN_ACTIVE = "/api/v1/campaign/active"
ROUTE_V1_CAMPAIGN_UPCOMING = "/api/v1/campaign/upcoming"
ROUTE_V1_CAMPAIGN_FINISHED = "/api/v1/campaign/finished"
ROUTE_V1_CAMPAIGN_CLOSE = "/api/v1/campaign/:id/close"
ROUTE_V1_CAMPAIGN_EXPORT_EVENTS = "/api/v1/campaign/:id/export/events"
ROUTE_V1_CAMPAIGN_EXPORT_SUBMISSIONS = "/api/v1/campaign/:id/export/submissions"
ROUTE_V1_CAMPAIGN_ANONYMIZE = "/api/v1/campaign/:id/anonymize"
ROUTE_V1_CAMPAIGN_ID = "/api/v1/campaign/:id"
ROUTE_V1_CAMPAIGN_NAME = "/api/v1/campaign/name/:name"
ROUTE_V1_CAMPAIGN_RECIPIENTS = "/api/v1/campaign/:id/recipients"
ROUTE_V1_CAMPAIGN_RESULT_STATS = "/api/v1/campaign/:id/statistics"
ROUTE_V1_CAMPAIGN_EVENTS = "/api/v1/campaign/:id/events"
ROUTE_V1_CAMPAIGN_EVENT_NAMES = "/api/v1/campaign/event-types"
ROUTE_V1_CAMPAIGN_STATS = "/api/v1/campaign/statistics"
ROUTE_V1_CAMPAIGN_STATS_ID = "/api/v1/campaign/:id/stats"
ROUTE_V1_CAMPAIGN_STATS_ALL = "/api/v1/campaign/stats/all"
// campaign-recipient
ROUTE_V1_CAMPAIGN_RECIPIENT_EMAIL = "/api/v1/campaign/recipient/:id/email"
ROUTE_V1_CAMPAIGN_RECIPIENT_URL = "/api/v1/campaign/recipient/:id/url"
ROUTE_V1_CAMPAIGN_RECIPIENT_SET_SENT = "/api/v1/campaign/recipient/:id/sent"
// asset
ROUTE_V1_ASSET = "/api/v1/asset"
ROUTE_V1_ASSET_ID = "/api/v1/asset/:id"
ROUTE_V1_ASSET_DOMAIN_CONTEXT = "/api/v1/asset/domain/:domain"
ROUTE_V1_ASSET_GLOBAL_CONTEXT = "/api/v1/asset/domain/"
ROUTE_V1_ASSET_DOMAIN_VIEW = "/api/v1/asset/view/domain/:domain/*path"
// attachments
ROUTE_V1_ATTACHMENT = "/api/v1/attachment"
ROUTE_V1_ATTACHMENT_ID = "/api/v1/attachment/:id"
ROUTE_V1_ATTACHMENT_ID_CONTENT = "/api/v1/attachment/:id/content"
ROUTE_V1_ATTACHMENT_COMPANY_CONTEXT = "/api/v1/attachment/company/:companyID"
ROUTE_V1_ATTACHMENT_GLOBAL_CONTEXT = "/api/v1/attachment/company/"
ROUTE_V1_EMAIL_ATTACHMENT = "/api/v1/email/:id/attachment"
// api sender
ROUTE_V1_API_SENDER = "/api/v1/api-sender"
ROUTE_V1_API_SENDER_OVERVIEW = "/api/v1/api-sender/overview"
ROUTE_V1_API_SENDER_ID = "/api/v1/api-sender/:id"
ROUTE_V1_API_SENDER_ID_TEST = "/api/v1/api-sender/:id/test"
// deny allow
ROUTE_V1_ALLOW_DENY = "/api/v1/allow-deny"
ROUTE_V1_ALLOW_DENY_OVERVIEW = "/api/v1/allow-deny/overview"
ROUTE_V1_ALLOW_DENY_ID = "/api/v1/allow-deny/:id"
// web hooks
ROUTE_V1_WEBHOOK = "/api/v1/webhook"
ROUTE_V1_WEBHOOK_ID = "/api/v1/webhook/:id"
ROUTE_V1_WEBHOOK_ID_TEST = "/api/v1/webhook/:id/test"
// identifiers
ROUTE_V1_IDENTIFIER = "/api/v1/identifier"
// license
ROUTE_V1_LICENSE = "/api/v1/license"
// version
ROUTE_V1_VERSION = "/api/v1/version"
// import
ROUTE_V1_IMPORT = "/api/v1/import"
)
// administrationServer is the administrationServer app
type administrationServer struct {
Server *http.Server
router *gin.Engine
logger *zap.SugaredLogger
production bool
embedBackendFS *embed.FS
certMagicConfig *certmagic.Config
}
// NewAdministrationServer creates a new administration app
func NewAdministrationServer(
router *gin.Engine,
controllers *Controllers,
middlewares *Middlewares,
logger *zap.SugaredLogger,
certMagicConfig *certmagic.Config,
production bool,
) *administrationServer {
router = setupRoutes(router, controllers, middlewares)
return &administrationServer{
router: router,
logger: logger,
production: production,
certMagicConfig: certMagicConfig,
}
}
func (a *administrationServer) Router() *gin.Engine {
return a.router
}
// setupRoutes sets up the routes for the administration app
func setupRoutes(
r *gin.Engine,
controllers *Controllers,
middleware *Middlewares,
) *gin.Engine {
if !build.Flags.Production {
r.
GET("/api/v1/_debug/panic", middleware.SessionHandler, controllers.Log.Panic).
GET("/api/v1/_debug/slow", middleware.SessionHandler, controllers.Log.Slow)
}
r.
// log
GET(ROUTE_V1_LOG, middleware.SessionHandler, controllers.Log.GetLevel).
POST(ROUTE_V1_LOG, middleware.SessionHandler, controllers.Log.SetLevel).
GET(ROUTE_V1_LOG_TEST, middleware.SessionHandler, controllers.Log.TestLog).
// application
GET(ROUTE_V1_UPDATE_AVAILABLE, middleware.SessionHandler, controllers.Update.CheckForUpdate).
GET(ROUTE_V1_UPDATE_AVAILABLE_CACHED, middleware.SessionHandler, controllers.Update.CheckForUpdateCached).
// health
GET(ROUTE_V1_HEALTH, controllers.Health.Health).
GET(ROUTE_V1_LIVE, controllers.Health.Health).
GET(ROUTE_V1_READY, controllers.Health.Health).
// login, logout and session
GET(ROUTE_V1_SESSION_PING, middleware.SessionHandler, controllers.User.SessionPing).
POST(ROUTE_V1_USER_LOGIN, middleware.LoginRateLimiter, controllers.User.Login).
POST(ROUTE_V1_USER_LOGOUT, controllers.User.Logout).
// install
POST(ROUTE_V1_INSTALL, middleware.SessionHandler, controllers.Installer.Install).
// user
GET(ROUTE_V1_USER, middleware.SessionHandler, controllers.User.GetAll).
GET(ROUTE_V1_USER_ID, middleware.SessionHandler, controllers.User.GetByID).
POST(ROUTE_V1_USER_ID, middleware.SessionHandler, controllers.User.UpdateByID).
POST(ROUTE_V1_USER, middleware.SessionHandler, controllers.User.Create).
DELETE(ROUTE_V1_USER_ID, middleware.SessionHandler, controllers.User.Delete).
POST(ROUTE_V1_USER_PASSWORD, middleware.SessionHandler, controllers.User.ChangePasswordOnLoggedInUser).
POST(ROUTE_V1_USER_USERNAME, middleware.SessionHandler, controllers.User.ChangeUsernameOnLoggedInUser).
POST(ROUTE_V1_USER_FULLNAME, middleware.SessionHandler, controllers.User.ChangeFullnameOnLoggedInUser).
POST(ROUTE_V1_USER_EMAIL, middleware.SessionHandler, controllers.User.ChangeEmailOnLoggedInUser).
GET(ROUTE_V1_USER_SESSIONS, middleware.SessionHandler, controllers.User.GetSessionsOnLoggedInUser).
POST(ROUTE_V1_USER_SESSIONS_INVALIDATE, middleware.SessionHandler, controllers.User.InvalidateAllSessionByUserID).
DELETE(ROUTE_V1_SESSION_ID, middleware.SessionHandler, controllers.User.ExpireSessionByID).
GET(ROUTE_V1_USER_API, middleware.SessionHandler, controllers.User.GetMaskedAPIKey).
POST(ROUTE_V1_USER_API, middleware.SessionHandler, controllers.User.UpsertAPIKey).
DELETE(ROUTE_V1_USER_API, middleware.SessionHandler, controllers.User.RemoveAPIKey).
// sso
GET(ROUTE_V1_SSO_ENTRA_ID_ENABLED, controllers.SSO.IsEnabled).
POST(ROUTE_V1_SSO_ENTRA_ID, middleware.SessionHandler, controllers.SSO.Upsert).
GET(ROUTE_V1_SSO_ENTRA_ID_LOGIN, controllers.SSO.EntreIDLogin).
GET(ROUTE_V1_SSO_ENTRA_ID_CALLBACK, controllers.SSO.EntreIDCallBack).
// user mfa
GET(ROUTE_V1_USER_MFA_TOTP, middleware.SessionHandler, controllers.User.IsTOTPEnabled).
POST(ROUTE_V1_USER_MFA_TOTP_SETUP, middleware.LoginRateLimiter, middleware.SessionHandler, controllers.User.SetupTOTP).
POST(ROUTE_V1_USER_MFA_TOTP_SETUP_VERIFY, middleware.LoginRateLimiter, middleware.SessionHandler, controllers.User.SetupVerifyTOTP).
POST(ROUTE_V1_USER_MFA_TOTP_VERIFY, middleware.LoginRateLimiter, middleware.SessionHandler, controllers.User.VerifyTOTP).
POST(ROUTE_V1_USER_MFA_TOTP, middleware.LoginRateLimiter, middleware.SessionHandler, controllers.User.DisableTOTP).
// qr
POST(ROUTE_V1_QR_FROM_TOTP, middleware.SessionHandler, controllers.QR.ToTOTPURL).
POST(ROUTE_V1_QR_URL_TO_HTML, middleware.SessionHandler, controllers.QR.ToHTML).
// company
POST(ROUTE_V1_COMPANY, middleware.SessionHandler, controllers.Company.Create).
POST(ROUTE_V1_COMPANY_ID, middleware.SessionHandler, controllers.Company.ChangeName).
GET(ROUTE_V1_COMPANY, middleware.SessionHandler, controllers.Company.GetAll).
GET(ROUTE_V1_COMPANY_ID_EXPORT, middleware.SessionHandler, controllers.Company.ExportByCompanyID).
GET(ROUTE_V1_COMPANY_ID_EXPORT_SHARED, middleware.SessionHandler, controllers.Company.ExportShared).
GET(ROUTE_V1_COMPANY_ID, middleware.SessionHandler, controllers.Company.GetByID).
DELETE(ROUTE_V1_COMPANY_ID, middleware.SessionHandler, controllers.Company.DeleteByID).
// options
GET(ROUTE_V1_OPTION_GET, middleware.SessionHandler, controllers.Option.Get).
POST(ROUTE_V1_OPTION, middleware.SessionHandler, middleware.SessionHandler, controllers.Option.Update).
// domain
GET(ROUTE_V1_DOMAIN, middleware.SessionHandler, controllers.Domain.GetAll).
GET(ROUTE_V1_DOMAIN_SUBSET, middleware.SessionHandler, controllers.Domain.GetAllOverview).
GET(ROUTE_V1_DOMAIN_ID, middleware.SessionHandler, controllers.Domain.GetByID).
GET(ROUTE_V1_DOMAIN_NAME, middleware.SessionHandler, controllers.Domain.GetByName).
POST(ROUTE_V1_DOMAIN, middleware.SessionHandler, controllers.Domain.Create).
POST(ROUTE_V1_DOMAIN_ID, middleware.SessionHandler, controllers.Domain.UpdateByID).
DELETE(ROUTE_V1_DOMAIN_ID, middleware.SessionHandler, controllers.Domain.DeleteByID).
// recipient
GET(ROUTE_V1_RECIPIENT, middleware.SessionHandler, controllers.Recipient.GetAll).
GET(ROUTE_V1_RECIPIENT_ID, middleware.SessionHandler, controllers.Recipient.GetByID).
GET(ROUTE_V1_RECIPIENT_ID_EVENTS, middleware.SessionHandler, controllers.Recipient.GetCampaignEvents).
GET(ROUTE_V1_RECIPIENT_ID_STATS, middleware.SessionHandler, controllers.Recipient.GetStatsByID).
POST(ROUTE_V1_RECIPIENT, middleware.SessionHandler, controllers.Recipient.Create).
POST(ROUTE_V1_RECIPIENT_IMPORT, middleware.SessionHandler, controllers.Recipient.Import).
GET(ROUTE_V1_RECIPIENT_EXPORT, middleware.SessionHandler, controllers.Recipient.Export).
PATCH(ROUTE_V1_RECIPIENT_ID, middleware.SessionHandler, controllers.Recipient.UpdateByID).
DELETE(ROUTE_V1_RECIPIENT_ID, middleware.SessionHandler, controllers.Recipient.DeleteByID).
GET(ROUTE_V1_RECIPIENT_REPEAT_OFFENDERS, middleware.SessionHandler, controllers.Recipient.GetRepeatOffenderCount).
// recipient group
GET(ROUTE_V1_RECIPIENT_GROUP, middleware.SessionHandler, controllers.RecipientGroup.GetAll).
GET(ROUTE_V1_RECIPIENT_GROUP_ID, middleware.SessionHandler, controllers.RecipientGroup.GetByID).
GET(ROUTE_V1_RECIPIENT_GROUP_RECIPIENTS, middleware.SessionHandler, controllers.RecipientGroup.GetRecipientsByGroupID).
POST(ROUTE_V1_RECIPIENT_GROUP_RECIPIENTS, middleware.SessionHandler, controllers.RecipientGroup.AddRecipients).
DELETE(ROUTE_V1_RECIPIENT_GROUP_RECIPIENTS, middleware.SessionHandler, controllers.RecipientGroup.RemoveRecipients).
POST(ROUTE_V1_RECIPIENT_GROUP, middleware.SessionHandler, controllers.RecipientGroup.Create).
PATCH(ROUTE_V1_RECIPIENT_GROUP_ID, middleware.SessionHandler, controllers.RecipientGroup.UpdateByID).
PUT(ROUTE_V1_RECIPIENT_GROUP_ID_IMPORT, middleware.SessionHandler, controllers.RecipientGroup.Import).
DELETE(ROUTE_V1_RECIPIENT_GROUP_ID, middleware.SessionHandler, controllers.RecipientGroup.DeleteByID).
// page
GET(ROUTE_V1_PAGE, middleware.SessionHandler, controllers.Page.GetAll).
GET(ROUTE_V1_PAGE_OVERVIEW, middleware.SessionHandler, controllers.Page.GetOverview).
GET(ROUTE_V1_PAGE_ID, middleware.SessionHandler, controllers.Page.GetByID).
Any(ROUTE_V1_PAGE_CONTENT_ID, middleware.SessionHandler, controllers.Page.GetContentByID).
POST(ROUTE_V1_PAGE, middleware.SessionHandler, controllers.Page.Create).
PATCH(ROUTE_V1_PAGE_ID, middleware.SessionHandler, controllers.Page.UpdateByID).
DELETE(ROUTE_V1_PAGE_ID, middleware.SessionHandler, controllers.Page.DeleteByID).
// smtp configuration
GET(ROUTE_V1_SMTP_CONFIGURATION, middleware.SessionHandler, controllers.SMTPConfiguration.GetAll).
GET(ROUTE_V1_SMTP_CONFIGURATION_ID, middleware.SessionHandler, controllers.SMTPConfiguration.GetByID).
POST(ROUTE_V1_SMTP_CONFIGURATION, middleware.SessionHandler, controllers.SMTPConfiguration.Create).
POST(ROUTE_V1_SMTP_CONFIGURATION_ID_TEST_EMAIL, middleware.SessionHandler, controllers.SMTPConfiguration.TestEmail).
PATCH(ROUTE_V1_SMTP_CONFIGURATION_ID, middleware.SessionHandler, controllers.SMTPConfiguration.UpdateByID).
DELETE(ROUTE_V1_SMTP_CONFIGURATION_ID, middleware.SessionHandler, controllers.SMTPConfiguration.DeleteByID).
// smtp configuration headers
PATCH(ROUTE_V1_SMTP_CONFIGURATION_HEADERS, middleware.SessionHandler, controllers.SMTPConfiguration.AddHeader).
DELETE(ROUTE_V1_SMTP_HEADER_ID, middleware.SessionHandler, controllers.SMTPConfiguration.RemoveHeader).
// emails
GET(ROUTE_V1_EMAIL, middleware.SessionHandler, controllers.Email.GetAll).
GET(ROUTE_V1_EMAIL_OVERVIEW, middleware.SessionHandler, controllers.Email.GetOverviews).
GET(ROUTE_V1_EMAIL_ID, middleware.SessionHandler, controllers.Email.GetByID).
GET(ROUTE_V1_EMAIL_CONTENT_ID, middleware.SessionHandler, controllers.Email.GetContentByID).
POST(ROUTE_V1_EMAIL_SEND_TEST, middleware.SessionHandler, controllers.Email.SendTestEmail).
POST(ROUTE_V1_EMAIL, middleware.SessionHandler, controllers.Email.Create).
// TODO PATCH
POST(ROUTE_V1_EMAIL_ID, middleware.SessionHandler, controllers.Email.UpdateByID).
DELETE(ROUTE_V1_EMAIL_ID, middleware.SessionHandler, controllers.Email.DeleteByID).
// email attachments
POST(ROUTE_V1_EMAIL_ATTACHMENT, middleware.SessionHandler, controllers.Email.AddAttachments).
DELETE(ROUTE_V1_EMAIL_ATTACHMENT, middleware.SessionHandler, controllers.Email.RemoveAttachment).
// campaign templates
GET(ROUTE_V1_CAMPAIGN_TEMPLATE, middleware.SessionHandler, controllers.CampaignTemplate.GetAll).
GET(ROUTE_v1_CAMPAIGN_TEMPLATE_ID, middleware.SessionHandler, controllers.CampaignTemplate.GetByID).
// TODO PATCH
POST(ROUTE_V1_CAMPAIGN_TEMPLATE, middleware.SessionHandler, controllers.CampaignTemplate.Create).
POST(ROUTE_v1_CAMPAIGN_TEMPLATE_ID, middleware.SessionHandler, controllers.CampaignTemplate.UpdateByID).
DELETE(ROUTE_v1_CAMPAIGN_TEMPLATE_ID, middleware.SessionHandler, controllers.CampaignTemplate.DeleteByID).
// campaigns
GET(ROUTE_V1_CAMPAIGN, middleware.SessionHandler, controllers.Campaign.GetAll).
GET(ROUTE_V1_CAMPAIGN_CALENDAR, middleware.SessionHandler, controllers.Campaign.GetAllWithinDates).
GET(ROUTE_V1_CAMPAIGN_ACTIVE, middleware.SessionHandler, controllers.Campaign.GetAllActive).
GET(ROUTE_V1_CAMPAIGN_UPCOMING, middleware.SessionHandler, controllers.Campaign.GetAllUpcoming).
GET(ROUTE_V1_CAMPAIGN_FINISHED, middleware.SessionHandler, controllers.Campaign.GetAllFinished).
GET(ROUTE_V1_CAMPAIGN_EVENT_NAMES, middleware.SessionHandler, controllers.Campaign.GetAllEventTypes).
GET(ROUTE_V1_CAMPAIGN_EVENTS, middleware.SessionHandler, controllers.Campaign.GetEventsByCampaignID).
GET(ROUTE_V1_CAMPAIGN_STATS, middleware.SessionHandler, controllers.Campaign.GetStats).
GET(ROUTE_V1_CAMPAIGN_RESULT_STATS, middleware.SessionHandler, controllers.Campaign.GetResultStats).
GET(ROUTE_V1_CAMPAIGN_STATS_ID, middleware.SessionHandler, controllers.Campaign.GetCampaignStats).
GET(ROUTE_V1_CAMPAIGN_STATS_ALL, middleware.SessionHandler, controllers.Campaign.GetAllCampaignStats).
GET(ROUTE_V1_CAMPAIGN_ID, middleware.SessionHandler, controllers.Campaign.GetByID).
GET(ROUTE_V1_CAMPAIGN_NAME, middleware.SessionHandler, controllers.Campaign.GetByName).
POST(ROUTE_V1_CAMPAIGN, middleware.SessionHandler, controllers.Campaign.Create).
// TODO PATCH
POST(ROUTE_V1_CAMPAIGN_ID, middleware.SessionHandler, controllers.Campaign.UpdateByID).
POST(ROUTE_V1_CAMPAIGN_CLOSE, middleware.SessionHandler, controllers.Campaign.CloseCampaignByID).
GET(ROUTE_V1_CAMPAIGN_EXPORT_EVENTS, middleware.SessionHandler, controllers.Campaign.ExportEventsAsCSV).
GET(ROUTE_V1_CAMPAIGN_EXPORT_SUBMISSIONS, middleware.SessionHandler, controllers.Campaign.ExportSubmissionsAsCSV).
POST(ROUTE_V1_CAMPAIGN_ANONYMIZE, middleware.SessionHandler, controllers.Campaign.AnonymizeByID).
DELETE(ROUTE_V1_CAMPAIGN_ID, middleware.SessionHandler, controllers.Campaign.DeleteByID).
// campaign-recipient
GET(ROUTE_V1_CAMPAIGN_RECIPIENTS, middleware.SessionHandler, controllers.Campaign.GetRecipientsByCampaignID).
GET(ROUTE_V1_CAMPAIGN_RECIPIENT_EMAIL, middleware.SessionHandler, controllers.Campaign.GetCampaignEmail).
GET(ROUTE_V1_CAMPAIGN_RECIPIENT_URL, middleware.SessionHandler, controllers.Campaign.GetCampaignURL).
POST(ROUTE_V1_CAMPAIGN_RECIPIENT_SET_SENT, middleware.SessionHandler, controllers.Campaign.SetSentAtByCampaignRecipientID).
// asset
GET(ROUTE_V1_ASSET_DOMAIN_VIEW, middleware.SessionHandler, controllers.Asset.GetContentByID).
GET(ROUTE_V1_ASSET_ID, middleware.SessionHandler, controllers.Asset.GetByID).
PATCH(ROUTE_V1_ASSET_ID, middleware.SessionHandler, controllers.Asset.UpdateByID).
GET(ROUTE_V1_ASSET_DOMAIN_CONTEXT, middleware.SessionHandler, controllers.Asset.GetAllForContext).
GET(ROUTE_V1_ASSET_GLOBAL_CONTEXT, middleware.SessionHandler, controllers.Asset.GetAllForContext).
POST(ROUTE_V1_ASSET, middleware.SessionHandler, controllers.Asset.Create).
DELETE(ROUTE_V1_ASSET_ID, middleware.SessionHandler, controllers.Asset.RemoveByID).
// attachments
POST(ROUTE_V1_ATTACHMENT, middleware.SessionHandler, controllers.Attachment.Create).
GET(ROUTE_V1_ATTACHMENT_ID, middleware.SessionHandler, controllers.Attachment.GetByID).
GET(ROUTE_V1_ATTACHMENT_ID_CONTENT, middleware.SessionHandler, controllers.Attachment.GetContentByID).
GET(ROUTE_V1_ATTACHMENT, middleware.SessionHandler, controllers.Attachment.GetAllForContext).
PATCH(ROUTE_V1_ATTACHMENT_ID, middleware.SessionHandler, controllers.Attachment.UpdateByID).
DELETE(ROUTE_V1_ATTACHMENT_ID, middleware.SessionHandler, controllers.Attachment.RemoveByID).
// api sender
GET(ROUTE_V1_API_SENDER, middleware.SessionHandler, controllers.APISender.GetAll).
GET(ROUTE_V1_API_SENDER_OVERVIEW, middleware.SessionHandler, controllers.APISender.GetAllOverview).
GET(ROUTE_V1_API_SENDER_ID, middleware.SessionHandler, controllers.APISender.GetByID).
POST(ROUTE_V1_API_SENDER, middleware.SessionHandler, controllers.APISender.Create).
PATCH(ROUTE_V1_API_SENDER_ID, middleware.SessionHandler, controllers.APISender.UpdateByID).
POST(ROUTE_V1_API_SENDER_ID_TEST, middleware.SessionHandler, controllers.APISender.SendTest).
DELETE(ROUTE_V1_API_SENDER_ID, middleware.SessionHandler, controllers.APISender.DeleteByID).
// allow deny
GET(ROUTE_V1_ALLOW_DENY, middleware.SessionHandler, controllers.AllowDeny.GetAll).
GET(ROUTE_V1_ALLOW_DENY_OVERVIEW, middleware.SessionHandler, controllers.AllowDeny.GetAllOverview).
GET(ROUTE_V1_ALLOW_DENY_ID, middleware.SessionHandler, controllers.AllowDeny.GetByID).
POST(ROUTE_V1_ALLOW_DENY, middleware.SessionHandler, controllers.AllowDeny.Create).
PATCH(ROUTE_V1_ALLOW_DENY_ID, middleware.SessionHandler, controllers.AllowDeny.UpdateByID).
DELETE(ROUTE_V1_ALLOW_DENY_ID, middleware.SessionHandler, controllers.AllowDeny.DeleteByID).
// web hooks
GET(ROUTE_V1_WEBHOOK, middleware.SessionHandler, controllers.Webhook.GetAll).
GET(ROUTE_V1_WEBHOOK_ID, middleware.SessionHandler, controllers.Webhook.GetByID).
POST(ROUTE_V1_WEBHOOK, middleware.SessionHandler, controllers.Webhook.Create).
PATCH(ROUTE_V1_WEBHOOK_ID, middleware.SessionHandler, controllers.Webhook.UpdateByID).
DELETE(ROUTE_V1_WEBHOOK_ID, middleware.SessionHandler, controllers.Webhook.DeleteByID).
POST(ROUTE_V1_WEBHOOK_ID_TEST, middleware.SessionHandler, controllers.Webhook.SendTest).
// identifiers
GET(ROUTE_V1_IDENTIFIER, middleware.SessionHandler, controllers.Identifier.GetAll).
// version
GET(ROUTE_V1_VERSION, middleware.SessionHandler, controllers.Version.Get).
// update
GET(ROUTE_V1_UPDATE, middleware.SessionHandler, controllers.Update.GetUpdateDetails).
POST(ROUTE_V1_UPDATE, middleware.SessionHandler, controllers.Update.RunUpdate).
// import
POST(ROUTE_V1_IMPORT, middleware.SessionHandler, controllers.Import.Import)
return r
}
func (a *administrationServer) handleTLSCertificate(
conf *config.Config,
) error {
publicCertExists := true
privateCertExists := true
if _, err := os.Stat(conf.TLSCertPath()); err != nil {
if !os.IsNotExist(err) {
return err
}
privateCertExists = false
}
if _, err := os.Stat(conf.TLSKeyPath()); err != nil {
if !os.IsNotExist(err) {
return err
}
publicCertExists = false
}
// determine hostnames to include in the certificate
hostnames := []string{}
if h := conf.TLSHost(); len(h) > 0 {
hostnames = append(hostnames, h)
}
// get the address from config
if conf.AdminNetAddress() != "" {
host, _, err := net.SplitHostPort(conf.AdminNetAddress())
if err == nil && host != "" && host != "0.0.0.0" && host != "::" {
hostnames = append(hostnames, host)
}
}
// try to get all non-loopback IP addresses
addrs, err := net.InterfaceAddrs()
if err == nil {
for _, addr := range addrs {
if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
ip := ipnet.IP
// skip private IPs (RFC 1918)
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
continue
}
// only add public IPs to the certificate
hostnames = append(hostnames, ip.String())
}
}
}
needToCreateCert := !privateCertExists || !publicCertExists
// check if we need to recreate the certificate because host/IP has changed
if privateCertExists && publicCertExists {
// read the existing certificate to check the hostnames
certData, err := os.ReadFile(conf.TLSCertPath())
if err == nil {
block, _ := pem.Decode(certData)
if block != nil && block.Type == "CERTIFICATE" {
cert, err := x509.ParseCertificate(block.Bytes)
if err == nil {
// vheck if all requested hostnames are in the certificate
missingHosts := false
hostMap := make(map[string]bool)
// add all current certificate SANs to the map
for _, dnsName := range cert.DNSNames {
hostMap[dnsName] = true
}
for _, ip := range cert.IPAddresses {
hostMap[ip.String()] = true
}
// check if the common name is in our hostnames
if cert.Subject.CommonName != "" {
hostMap[cert.Subject.CommonName] = true
}
// Check if all requested hostnames are covered
for _, host := range hostnames {
if !hostMap[host] {
missingHosts = true
a.logger.Debugw("host not found in existing certificate", "host", host)
break
}
}
// if the TLSHost is specified and not in the certificate, or other hosts are missing, regenerate
if missingHosts {
a.logger.Debug("recreating certificate due to changed host/IP configuration")
needToCreateCert = true
}
} else {
a.logger.Warnw("could not parse existing certificate, will recreate", "error", err)
needToCreateCert = true
}
} else {
a.logger.Warn("invalid certificate format, will recreate")
needToCreateCert = true
}
} else {
a.logger.Warnw("could not read existing certificate, will recreate", "error", err)
needToCreateCert = true
}
}
// create certificates if needed
if needToCreateCert {
a.logger.Debug("creating self signed certificate for administration server")
info := acme.NewInformationWithDefault()
if len(hostnames) > 0 {
info.CommonName = hostnames[0]
}
a.logger.Debugw("generating certificate with hostnames", "hostnames", hostnames)
err = acme.CreateSelfSignedCert(
a.logger,
info,
hostnames,
conf.TLSCertPath(),
conf.TLSKeyPath(),
)
if err != nil {
return fmt.Errorf("failed to create self signed certificate: %s", err)
}
a.logger.Debugw(
"saved self signed certificate for administration servers",
"TLS certificate", conf.TLSCertPath(),
"TLS key path", conf.TLSKeyPath(),
)
} else {
a.logger.Debug("using existing certificate for administration server")
}
return nil
}
// LoadFrontend loads the frontend
// if this is a production build, the fronten will be embedded
// else the routes will be setup to load the frontend resources on every request
func (a *administrationServer) LoadFrontend(
ln net.Listener,
) error {
if build.Flags.Production {
return a.loadEmbeddedFileSystem(
ln,
)
}
return a.loadPerRequestLoading()
}
// loadPerRequestLoading loads the frontend resources on every request
// this is only used in a dev enviroment using nodemon as is a
// backup if the current vite proxy stragegy does not work.
func (a *administrationServer) loadPerRequestLoading() error {
a.router.GET("/", func(c *gin.Context) {
c.File("./frontend/website/build/index.html")
})
// perform manual lookup for the frontend files on each request
// build files might have been added or removed, so each request must
// do a check if the file exists
a.router.NoRoute(func(c *gin.Context) {
// a.logger.Infow("serving frontend file", "path", c.Request.URL.Path)
// check if the request url path exists in the root directory
if _, err := os.Stat("./frontend/website/build" + c.Request.URL.Path); err == nil {
c.File("./frontend/website/build" + c.Request.URL.Path)
return
}
// if the path ends with / or does not have a file extension, then it should fallback to index.html as
// it is a SPA path such as /company/foo/
if c.Request.URL.Path[len(c.Request.URL.Path)-1:] == "/" || !strings.Contains(c.Request.URL.Path, ".") {
c.File("./frontend/website/build/index.html")
}
// file not found - return 404
c.AbortWithStatus(http.StatusNotFound)
})
return nil
}
func (a *administrationServer) loadEmbeddedFileSystem(
ln net.Listener,
) error {
_ = ln
embedFS := frontend.GetEmbededFS()
// make embedded .html work
frontend.LoadHTMLFromEmbedFS(a.router, *embedFS, "build/*.html")
rootDir, err := embedFS.ReadDir("build")
if err != nil {
return errs.Wrap(err)
}
for _, entry := range rootDir {
path := entry.Name()
// add root files
if !entry.IsDir() {
// special case for the frontpage
if path == "index.html" {
a.router.GET("/", func(c *gin.Context) {
c.HTML(http.StatusOK, "build/index.html", nil)
})
continue
}
// any file in the root folder gets server as a file
a.router.GET("/"+path, func(c *gin.Context) {
c.FileFromFS("build/"+path, http.FS(*embedFS))
})
continue
}
// add static folders
staticFS, err := fs.Sub(embedFS, "build/"+path)
if err != nil {
return errs.Wrap(err)
}
switch path {
case ".well-known":
fallthrough
case "_app":
a.router.StaticFS(path, http.FS(staticFS))
}
}
// fall back to the root index.html
a.router.NoRoute(func(c *gin.Context) {
c.HTML(http.StatusOK, "build/index.html", nil)
})
return nil
}
func (a *administrationServer) StartServer(
conf *config.Config,
) (chan server.StartupMessage, net.Listener, error) {
startupMessage := server.NewStartupMessageChannel()
ln, err := net.Listen("tcp", conf.AdminNetAddress())
if err != nil {
return nil, nil, fmt.Errorf("failed to listen on %s due to: %s", conf.AdminNetAddress(), err)
}
err = a.LoadFrontend(ln)
if err != nil {
return nil, nil, errs.Wrap(err)
}
err = a.handleTLSCertificate(conf)
if err != nil {
return nil, nil, errs.Wrap(err)
}
a.Server = &http.Server{
Handler: a.router,
// The maximum duration for reading the entire request, including the request line, headers, and body
ReadTimeout: 15 * time.Second,
// The maximum duration for writing the entire response, including the response headers and body
WriteTimeout: 15 * time.Second, // Timeout for writing the response
// The maximum duration to wait for the next request when the connection is in the idle state
IdleTimeout: 10 * time.Second,
// The maximum duration for reading the request headers.
ReadHeaderTimeout: 2 * time.Second,
// Maximum size of request headers (512 KB)
MaxHeaderBytes: 1 << 19,
}
a.Server.ErrorLog = log.New(
&SkipFirstTlsToZapWriter{
logger: a.logger,
serverPtr: a.Server,
}, "", 0,
)
a.logger.Debugw("TLS settings",
"certPath", conf.TLSCertPath(),
"certKeyPath", conf.TLSKeyPath(),
)
// start the administration server
adminHost := "admin.test"
err = a.certMagicConfig.ManageSync(context.Background(), []string{adminHost})
if err != nil {
a.logger.Errorw("certmagic managesync failed", "error", err)
return nil, nil, errs.Wrap(err)
}
go func() {
if !conf.TLSAuto() {
a.logger.Debugw("starting administration",
"address", ln.Addr().String(),
)
err := a.Server.ServeTLS(
ln,
conf.TLSCertPath(),
conf.TLSKeyPath(),
)
if err != nil && err != http.ErrServerClosed {
log.Fatalf("failed to start administration server due to: %s", err)
}
} else {
// Setup TLS config from CertMagic
tlsConfig := a.certMagicConfig.TLSConfig()
tlsConfig.NextProtos = append([]string{"h2", "http/1.1"}, tlsConfig.NextProtos...)
// Create new TLS listener with the config
tlsLn := tls.NewListener(ln, tlsConfig)
a.logger.Debugw("starting administration with automatic TLS",
"address", ln.Addr().String(),
"domain", adminHost,
)
err := a.Server.Serve(tlsLn)
if err != nil && err != http.ErrServerClosed {
log.Fatalf("failed to start administration server due to: %s", err)
}
}
}()
// test the connection to the administration server
// and send a startup message
// TODO the connectivity check has been disabled as it fucks up the auto tls
// as it calls the certmagic DecisionFunc from addreses such as ::1 and I am not
// sure we it is safe to allow list all of them or if I know all of the potential addresses.
/*
go func() {
a.logger.Debug("testing connectivity to administration server...")
// wait for connection to the server
attempts := 1
for {
dialer := &net.Dialer{
Timeout: time.Second,
KeepAlive: time.Second,
}
conn, err := tls.DialWithDialer(
dialer,
"tcp",
ln.Addr().String(),
&tls.Config{
InsecureSkipVerify: true,
},
)
if err != nil {
a.logger.Debugw("failed to connect to administration server",
"attempt", attempts,
)
time.Sleep(1 * time.Second)
if attempts == 3 {
startupMessage <- server.NewStartupMessage(
false,
fmt.Errorf("failed to connect to administration server"),
)
break
}
attempts += 1
continue
}
conn.Close()
startupMessage <- server.NewStartupMessage(true, nil)
break
}
}()
*/
startupMessage <- server.NewStartupMessage(true, nil)
return startupMessage, ln, nil
}
// https://stackoverflow.com/questions/52294334/net-http-set-custom-logger
type fwdToZapWriter struct {
logger *zap.SugaredLogger
}
func (fw *fwdToZapWriter) Write(p []byte) (n int, err error) {
fw.logger.Errorw(string(p))
return len(p), nil
}
// SkipFirstTlsToZapWriter is a weird Writer that replaces itself
// when it has seen a TLS handshake error it is used for handling
// a special annoying case where a health check on startup creates
// a tls handshake that we want to ignore
type SkipFirstTlsToZapWriter struct {
logger *zap.SugaredLogger
// ignore first tls
serverPtr *http.Server
}
func (fw *SkipFirstTlsToZapWriter) Write(p []byte) (n int, err error) {
if strings.Contains(string(p), "TLS handshake error") {
// After catching the first TLS error, replace the ErrorLog with direct logger
fw.serverPtr.ErrorLog = log.New(
&fwdToZapWriter{
logger: fw.logger,
},
"",
0,
)
return len(p), nil
}
fw.logger.Errorw(string(p))
return len(p), nil
}
+41
View File
@@ -0,0 +1,41 @@
package app
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"github.com/go-errors/errors"
"github.com/phishingclub/phishingclub/config"
"github.com/phishingclub/phishingclub/errs"
)
// SetupConfig sets up the config
func SetupConfig(
enviroment string,
configFilePath string,
) (*config.Config, error) {
configFolder, configFile := filepath.Split(configFilePath)
filesystem := os.DirFS(configFolder)
configDTO, err := config.NewDTOFromFile(filesystem, configFile)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return nil, errs.Wrap(err)
}
if errors.Is(err, fs.ErrNotExist) {
fmt.Printf(" * No config loaded. Creating default config file at %s\n\n", configFilePath)
var conf *config.Config
if enviroment == MODE_DEVELOPMENT {
conf = config.NewDevDefaultConfig()
} else {
conf = config.NewProductionDefaultConfig()
}
err = conf.WriteToFile(configFilePath)
configDTO = conf.ToDTO()
if err != nil {
return nil, errs.Wrap(err)
}
}
return config.FromDTO(configDTO)
}
+200
View File
@@ -0,0 +1,200 @@
package app
import (
"github.com/phishingclub/phishingclub/controller"
"go.uber.org/zap"
"gorm.io/gorm"
)
// Controllers is a collection of controllers
type Controllers struct {
Asset *controller.Asset
Attachment *controller.Attachment
Company *controller.Company
Health *controller.Health
Installer *controller.Install
InitialSetup *controller.InitialSetup
Page *controller.Page
Log *controller.Log
Option *controller.Option
User *controller.User
Domain *controller.Domain
Recipient *controller.Recipient
RecipientGroup *controller.RecipientGroup
SMTPConfiguration *controller.SMTPConfiguration
Email *controller.Email
CampaignTemplate *controller.CampaignTemplate
Campaign *controller.Campaign
QR *controller.QRGenerator
APISender *controller.APISender
AllowDeny *controller.AllowDeny
Webhook *controller.Webhook
Identifier *controller.Identifier
Version *controller.Version
SSO *controller.SSO
Update *controller.Update
Import *controller.Import
}
// NewControllers creates a collection of controllers
func NewControllers(
staticAssetPath string,
attachmentsPath string,
repositories *Repositories,
services *Services,
logger *zap.SugaredLogger,
atomLogger *zap.AtomicLevel,
utillities *Utilities,
db *gorm.DB,
) *Controllers {
common := controller.Common{
SessionService: services.Session,
Logger: logger,
Response: utillities.JSONResponseHandler,
}
asset := &controller.Asset{
Common: common,
StaticAssetPath: staticAssetPath,
AssetService: services.Asset,
OptionService: services.Option,
DomainService: services.Domain,
}
attachment := &controller.Attachment{
Common: common,
StaticAttachmentPath: attachmentsPath,
AttachmentService: services.Attachment,
OptionService: services.Option,
TemplateService: services.Template,
CompanyService: services.Company,
}
company := &controller.Company{
Common: common,
CampaignService: services.Campaign,
CompanyService: services.Company,
RecipientService: services.Recipient,
}
initialSetup := &controller.InitialSetup{
Common: common,
CLIOutputter: utillities.CLIOutputter,
OptionRepository: repositories.Option,
InstallService: services.InstallSetup,
OptionService: services.Option,
}
installer := &controller.Install{
Common: common,
UserRepository: repositories.User,
CompanyRepository: repositories.Company,
OptionRepository: repositories.Option,
PasswordHasher: *utillities.PasswordHasher,
DB: db,
}
health := &controller.Health{}
log := &controller.Log{
Common: common,
OptionService: services.Option,
Database: db,
LoggerAtom: atomLogger,
}
page := &controller.Page{
Common: common,
PageService: services.Page,
TemplateService: services.Template,
}
option := &controller.Option{
Common: common,
OptionService: services.Option,
}
user := &controller.User{
Common: common,
UserService: services.User,
}
domain := &controller.Domain{
Common: common,
DomainService: services.Domain,
}
recipient := &controller.Recipient{
Common: common,
RecipientService: services.Recipient,
}
recipientGroup := &controller.RecipientGroup{
Common: common,
RecipientGroupService: services.RecipientGroup,
}
smtpConfiguration := &controller.SMTPConfiguration{
Common: common,
SMTPConfigurationService: services.SMTPConfiguration,
}
email := &controller.Email{
Common: common,
EmailService: services.Email,
TemplateService: services.Template,
EmailRepository: repositories.Email,
}
campaignTemplate := &controller.CampaignTemplate{
Common: common,
CampaignTemplateService: services.CampaignTemplate,
}
campaign := &controller.Campaign{
Common: common,
CampaignService: services.Campaign,
}
qr := &controller.QRGenerator{
Common: common,
}
apiSender := &controller.APISender{
Common: common,
APISenderService: services.APISender,
}
allowDeny := &controller.AllowDeny{
Common: common,
AllowDenyService: services.AllowDeny,
}
webhook := &controller.Webhook{
Common: common,
WebhookService: services.Webhook,
}
identifier := &controller.Identifier{
Common: common,
IdentifierService: services.Identifier,
}
version := &controller.Version{Common: common}
sso := &controller.SSO{Common: common, SSO: services.SSO}
update := &controller.Update{
Common: common,
UpdateService: services.Update,
OptionService: services.Option,
}
importController := &controller.Import{
Common: common,
ImportService: services.Import,
}
return &Controllers{
Asset: asset,
Attachment: attachment,
Company: company,
Installer: installer,
InitialSetup: initialSetup,
Health: health,
Page: page,
Log: log,
Option: option,
User: user,
Domain: domain,
Recipient: recipient,
RecipientGroup: recipientGroup,
SMTPConfiguration: smtpConfiguration,
Email: email,
CampaignTemplate: campaignTemplate,
Campaign: campaign,
QR: qr,
APISender: apiSender,
AllowDeny: allowDeny,
Webhook: webhook,
Identifier: identifier,
Version: version,
SSO: sso,
Update: update,
Import: importController,
}
}
+16
View File
@@ -0,0 +1,16 @@
package app
import (
"github.com/phishingclub/phishingclub/config"
"github.com/phishingclub/phishingclub/database"
"gorm.io/gorm"
)
// SetupDatabase sets up the database
// this includes creating the database connection
func SetupDatabase(
conf *config.Config,
) (*gorm.DB, error) {
// create db connection
return database.FromConfig(*conf)
}
+76
View File
@@ -0,0 +1,76 @@
package app
import (
"github.com/go-errors/errors"
"github.com/phishingclub/phishingclub/config"
"github.com/phishingclub/phishingclub/log"
"github.com/phishingclub/phishingclub/version"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
const (
MODE_INTEGRATION_TEST = "integration_test"
MODE_DEVELOPMENT = "development"
MODE_PRODUCTION = "production"
)
func createCore(core zapcore.Core) zapcore.Core {
return &stackCore{core}
}
type stackCore struct {
zapcore.Core
}
func (c *stackCore) Check(ent zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry {
// dont add our core again if it's already been added
if ce != nil {
return ce
}
return ce.AddCore(ent, c)
}
func (c *stackCore) Write(ent zapcore.Entry, fields []zapcore.Field) error {
// return c.Core.Write(ent, fields)
// look for error field and enhance the message with stack trace
for _, field := range fields {
if field.Key == "error" {
if err, ok := field.Interface.(error); ok {
if goErr, ok := err.(*errors.Error); ok {
ent.Stack = goErr.ErrorStack()
}
}
}
}
return c.Core.Write(ent, fields)
}
func SetupLogger(loggerType string, conf *config.Config) (*zap.SugaredLogger, *zap.AtomicLevel, error) {
var logger *zap.Logger
var loggerAtom *zap.AtomicLevel
var err error
switch loggerType {
case MODE_DEVELOPMENT:
logger, loggerAtom, err = log.NewDevelopmentLogger(conf)
case MODE_INTEGRATION_TEST:
fallthrough
case MODE_PRODUCTION:
fallthrough
default:
logger, loggerAtom, err = log.NewProductionLogger(conf)
}
if err != nil {
return nil, nil, err
}
// Create new logger with custom core
logger = zap.New(createCore(logger.Core()))
sgr := logger.Sugar()
if loggerType == MODE_PRODUCTION {
sgr = sgr.With("v-debug", version.Get())
}
return sgr, loggerAtom, nil
}
+43
View File
@@ -0,0 +1,43 @@
package app
import (
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/config"
"github.com/phishingclub/phishingclub/middleware"
"go.uber.org/zap"
)
// Middlwares is a collection of middlewares
type Middlewares struct {
IPLimiter gin.HandlerFunc
LoginRateLimiter gin.HandlerFunc
SessionHandler gin.HandlerFunc
}
// NewMiddlewares creates a collection of middlewares
func NewMiddlewares(
requestPerSecond float64,
requestBurst int,
conf *config.Config,
services *Services,
utils *Utilities,
logger *zap.SugaredLogger,
) *Middlewares {
ipLimiter := middleware.NewAllowIPMiddleware(conf, logger)
loginThrottle := middleware.NewIPRateLimiterMiddleware(
requestPerSecond, // requests per second
requestBurst, // burst
)
sessionHandler := middleware.NewSessionHandler(
services.Session,
services.User,
utils.JSONResponseHandler,
logger,
)
return &Middlewares{
IPLimiter: ipLimiter,
LoginRateLimiter: loginThrottle,
SessionHandler: sessionHandler,
}
}
+59
View File
@@ -0,0 +1,59 @@
package app
import (
"github.com/phishingclub/phishingclub/repository"
"gorm.io/gorm"
)
// Repositories is a collection of repositories
type Repositories struct {
Asset *repository.Asset
Attachment *repository.Attachment
Company *repository.Company
Option *repository.Option
Page *repository.Page
Role *repository.Role
Session *repository.Session
User *repository.User
Domain *repository.Domain
Recipient *repository.Recipient
RecipientGroup *repository.RecipientGroup
SMTPConfiguration *repository.SMTPConfiguration
Email *repository.Email
Campaign *repository.Campaign
CampaignRecipient *repository.CampaignRecipient
CampaignTemplate *repository.CampaignTemplate
APISender *repository.APISender
AllowDeny *repository.AllowDeny
Webhook *repository.Webhook
Identifier *repository.Identifier
}
// NewRepositories creates a collection of repositories
func NewRepositories(
db *gorm.DB,
) *Repositories {
option := &repository.Option{DB: db}
return &Repositories{
Asset: &repository.Asset{DB: db},
Attachment: &repository.Attachment{DB: db},
Company: &repository.Company{DB: db},
Option: option,
Page: &repository.Page{DB: db},
Role: &repository.Role{DB: db},
Session: &repository.Session{DB: db},
User: &repository.User{DB: db},
Domain: &repository.Domain{DB: db},
Recipient: &repository.Recipient{DB: db, OptionRepository: option},
RecipientGroup: &repository.RecipientGroup{DB: db},
SMTPConfiguration: &repository.SMTPConfiguration{DB: db},
Email: &repository.Email{DB: db},
Campaign: &repository.Campaign{DB: db},
CampaignRecipient: &repository.CampaignRecipient{DB: db},
CampaignTemplate: &repository.CampaignTemplate{DB: db},
APISender: &repository.APISender{DB: db},
AllowDeny: &repository.AllowDeny{DB: db},
Webhook: &repository.Webhook{DB: db},
Identifier: &repository.Identifier{DB: db},
}
}
File diff suppressed because it is too large Load Diff
+254
View File
@@ -0,0 +1,254 @@
package app
import (
"github.com/caddyserver/certmagic"
"github.com/phishingclub/phishingclub/service"
"go.uber.org/zap"
"gorm.io/gorm"
)
// Services is a collection of services
type Services struct {
Asset *service.Asset
Attachment *service.Attachment
File *service.File
Company *service.Company
InstallSetup *service.InstallSetup
Option *service.Option
Page *service.Page
Session *service.Session
User *service.User
Domain *service.Domain
Recipient *service.Recipient
RecipientGroup *service.RecipientGroup
SMTPConfiguration *service.SMTPConfiguration
Email *service.Email
CampaignTemplate *service.CampaignTemplate
Campaign *service.Campaign
Template *service.Template
APISender *service.APISender
AllowDeny *service.AllowDeny
Webhook *service.Webhook
Identifier *service.Identifier
Version *service.Version
SSO *service.SSO
Update *service.Update
Import *service.Import
}
// NewServices creates a collection of services
func NewServices(
db *gorm.DB,
repositories *Repositories,
logger *zap.SugaredLogger,
utilities *Utilities,
assetPath string,
attachmentPath string,
ownManagedCertificatePath string,
enviroment string,
certMagicConfig *certmagic.Config,
certMagicCache *certmagic.Cache,
licenseServerURL string,
) *Services {
common := service.Common{
Logger: logger,
}
templateService := &service.Template{
Common: common,
}
file := &service.File{
Common: common,
}
asset := &service.Asset{
Common: common,
RootFolder: assetPath,
FileService: file,
AssetRepository: repositories.Asset,
DomainRepository: repositories.Domain,
}
attachment := &service.Attachment{
Common: common,
RootFolder: attachmentPath,
FileService: file,
AttachmentRepository: repositories.Attachment,
EmailRepository: repositories.Email,
}
installSetup := &service.InstallSetup{
Common: common,
UserRepository: repositories.User,
RoleRepository: repositories.Role,
CompanyRepository: repositories.Company,
PasswordHasher: utilities.PasswordHasher,
}
sessionService := &service.Session{
Common: common,
SessionRepository: repositories.Session,
}
optionService := &service.Option{
Common: common,
OptionRepository: repositories.Option,
}
userService := &service.User{
Common: common,
UserRepository: repositories.User,
RoleRepository: repositories.Role,
CompanyRepository: repositories.Company,
PasswordHasher: utilities.PasswordHasher,
}
recipient := &service.Recipient{
Common: common,
RecipientRepository: repositories.Recipient,
RecipientGroupRepository: repositories.RecipientGroup,
CampaignRepository: repositories.Campaign,
CampaignRecipientRepository: repositories.CampaignRecipient,
}
recipientGroup := &service.RecipientGroup{
Common: common,
CampaignRepository: repositories.Campaign,
CampaignRecipientRepository: repositories.CampaignRecipient,
RecipientGroupRepository: repositories.RecipientGroup,
RecipientRepository: repositories.Recipient,
RecipientService: recipient,
DB: db,
}
webhook := &service.Webhook{
Common: common,
CampaignRepository: repositories.Campaign,
WebhookRepository: repositories.Webhook,
}
campaignTemplate := &service.CampaignTemplate{
Common: common,
CampaignTemplateRepository: repositories.CampaignTemplate,
CampaignRepository: repositories.Campaign,
IdentifierRepository: repositories.Identifier,
}
apiSender := &service.APISender{
Common: common,
APISenderRepository: repositories.APISender,
TemplateService: templateService,
CampaignTemplateService: campaignTemplate,
}
smtpConfiguration := &service.SMTPConfiguration{
Common: common,
SMTPConfigurationRepository: repositories.SMTPConfiguration,
CampaignTemplateService: campaignTemplate,
}
page := &service.Page{
Common: common,
CampaignRepository: repositories.Campaign,
PageRepository: repositories.Page,
CampaignTemplateService: campaignTemplate,
}
domain := &service.Domain{
Common: common,
OwnManagedCertificatePath: ownManagedCertificatePath,
CertMagicConfig: certMagicConfig,
CertMagicCache: certMagicCache,
DomainRepository: repositories.Domain,
CompanyRepository: repositories.Company,
CampaignTemplateService: campaignTemplate,
AssetService: asset,
FileService: file,
}
email := &service.Email{
Common: common,
AttachmentPath: attachmentPath,
AttachmentService: attachment,
DomainService: domain,
EmailRepository: repositories.Email,
SMTPService: smtpConfiguration,
RecipientService: recipient,
TemplateService: templateService,
}
campaign := &service.Campaign{
Common: common,
CampaignRepository: repositories.Campaign,
CampaignRecipientRepository: repositories.CampaignRecipient,
RecipientRepository: repositories.Recipient,
RecipientGroupRepository: repositories.RecipientGroup,
AllowDenyRepository: repositories.AllowDeny,
WebhookRepository: repositories.Webhook,
CampaignTemplateService: campaignTemplate,
DomainService: domain,
RecipientService: recipient,
MailService: email,
APISenderService: apiSender,
SMTPConfigService: smtpConfiguration,
WebhookService: webhook,
TemplateService: templateService,
AttachmentPath: attachmentPath,
}
allowDeny := &service.AllowDeny{
Common: common,
AllowDenyRepository: repositories.AllowDeny,
CampaignRepository: repositories.Campaign,
}
identifier := &service.Identifier{
Common: common,
IdentifierRepository: repositories.Identifier,
}
companyService := &service.Company{
Common: common,
DomainService: domain,
PageService: page,
EmailService: email,
SMTPConfigurationService: smtpConfiguration,
APISenderService: apiSender,
RecipientService: recipient,
RecipientGroupService: recipientGroup,
CampaignService: campaign,
CampaignTemplate: campaignTemplate,
AllowDenyService: allowDeny,
WebhookService: webhook,
CompanyRepository: repositories.Company,
}
versionService := &service.Version{Common: common}
ssoService := &service.SSO{
Common: common,
OptionsService: optionService,
UserService: userService,
SessionService: sessionService,
// MSALClient: msalClient, this dependency is set AFTER this function
}
updateService := &service.Update{
Common: common,
OptionService: optionService,
}
importService := &service.Import{
Common: common,
Asset: asset,
Page: page,
Email: email,
File: file,
EmailRepository: repositories.Email,
PageRepository: repositories.Page,
}
return &Services{
Asset: asset,
Attachment: attachment,
Company: companyService,
File: file,
InstallSetup: installSetup,
Option: optionService,
Page: page,
Session: sessionService,
User: userService,
Domain: domain,
Recipient: recipient,
RecipientGroup: recipientGroup,
SMTPConfiguration: smtpConfiguration,
Email: email,
Template: templateService,
CampaignTemplate: campaignTemplate,
Campaign: campaign,
APISender: apiSender,
AllowDeny: allowDeny,
Webhook: webhook,
Identifier: identifier,
Version: versionService,
SSO: ssoService,
Update: updateService,
Import: importService,
}
}
+23
View File
@@ -0,0 +1,23 @@
package app
import (
"github.com/phishingclub/phishingclub/api"
"github.com/phishingclub/phishingclub/cli"
"github.com/phishingclub/phishingclub/password"
)
// Utilities is a collection of utils
type Utilities struct {
CLIOutputter cli.Outputter
PasswordHasher *password.Argon2Hasher
JSONResponseHandler api.JSONResponseHandler
}
// NewUtils creates a collection of utils
func NewUtils() *Utilities {
return &Utilities{
CLIOutputter: cli.NewCLIOutputter(),
PasswordHasher: password.NewHasherWithDefaultValues(),
JSONResponseHandler: api.NewJSONResponseHandler(),
}
}
+10
View File
@@ -0,0 +1,10 @@
package build
type flags struct {
Production bool
}
// Flags is a global variable for build flags
var Flags = flags{
Production: false,
}
+7
View File
@@ -0,0 +1,7 @@
//go:build production
package build
func init() {
Flags.Production = true
}
+32
View File
@@ -0,0 +1,32 @@
#!/bin/sh
echo "### Building frontend"
# remove any old builds
rm -rf phishingclub/frontend/frontend/build
mkdir -p phishingclub/frontend/frontend/build
sudo docker run --rm \
-v "$(pwd)":/app \
-w /app/phishingclub/frontend \
node:alpine \
sh -c "npm ci && npm run build-production"
# Get current user and group IDs
USER_ID=$(id -u)
GROUP_ID=$(id -g)
sudo chown -R $USER_ID:$GROUP_ID phishingclub/frontend/build
sudo mv phishingclub/frontend/build ./phishingclub/frontend/frontend/
echo "### Building backend"
HASH=$(git rev-parse --short HEAD)
echo "Building with hash: $HASH"
echo "building..."
sudo docker run --rm \
-v "$(pwd)":/app \
-w /app/phishingclub/frontend \
golang:alpine \
go build -trimpath \
-ldflags="-X github.com/phishingclub/phishingclub/version.hash=ph$HASH" \
-tags production -o ../build/phishingclub main.go
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
HASH=$(git rev-parse --short HEAD)
echo "Building backend with hash: $HASH"
sudo docker run --rm \
-v "$(pwd)":/app \
-w /app/phishingclub/frontend \
golang \
go build -trimpath \
-ldflags="-X github.com/phishingclub/phishingclub/version.hash=ph$HASH" \
-tags production -o ../build/phishingclub main.go
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
echo "### Building frontend"
sudo docker run --rm \
-v "$(pwd)":/app \
-w /app/phishingclub/frontend \
node:alpine \
sh -c "npm ci && npm run build-production"
+66
View File
@@ -0,0 +1,66 @@
#!/bin/bash
set -e
# Get the current version from the VERSION file
VERSION=$(cat phishingclub/frontend/version/VERSION | tr -d '\n\r ')
# Check if version is valid
if [[ ! $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: Invalid version format. Expected semver format (e.g., 0.9.0)"
exit 1
fi
# Get current git hash
GIT_HASH=$(git rev-parse --short HEAD)
# Create build directory
mkdir -p build
# Prompt for confirmation
echo "Ready to build and tag release v$VERSION ($GIT_HASH)"
read -p "Continue? (y/n): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Operation cancelled"
exit 1
fi
# Build frontend
echo "Building frontend..."
# remove any old builds
rm -rf phishingclub/frontend/frontend/build
mkdir -p phishingclub/frontend/frontend/build
sudo docker run --rm \
-v "$(pwd)":/app \
-w /app/phishingclub/frontend \
node:alpine \
sh -c "npm ci && npm run build-production"
# Get current user and group IDs
USER_ID=$(id -u)
GROUP_ID=$(id -g)
sudo chown -R $USER_ID:$GROUP_ID phishingclub/frontend/build
mv phishingclub/frontend/build ./phishingclub/frontend/frontend/
# Build the application
echo "Building application..."
sudo docker run --rm \
-v "$(pwd)":/app \
-w /app/phishingclub/frontend \
golang:alpine \
go build -trimpath \
-ldflags="-X github.com/phishingclub/phishingclub/version.hash=ph$GIT_HASH" \
-tags production -o ../build/phishingclub_${VERSION} main.go
echo "Build completed successfully: build/phishingclub_${VERSION}"
echo "Build completed successfully!"
echo "Created files:"
ls -lh build/
cd ..
echo "Release tagged as v$VERSION"
+31
View File
@@ -0,0 +1,31 @@
#!/bin/bash
# Exit on any error
set -e
echo "Generating licenses..."
# Create temp directory if it doesn't exist
mkdir -p /tmp/licenses
# Generate backend licenses
echo "Generating backend licenses..."
sudo docker compose exec -T backend bash -c "go install github.com/google/go-licenses@latest && \
go-licenses report --ignore github.com/phishingclub/phishingclub --template ./utils/ossTemplate.tpl ./... > /tmp/backend-licenses.md 2> /dev/null"
sudo docker compose cp backend:/tmp/backend-licenses.md /tmp/licenses/
# Generate frontend licenses
echo "Generating frontend licenses..."
sudo docker compose exec -T frontend bash -c "npm run --silent license-report > /tmp/frontend-licenses.json 2>/dev/null"
sudo docker compose cp frontend:/tmp/frontend-licenses.json /tmp/licenses/
# Combine licenses
echo "Combining licenses..."
cat /tmp/licenses/backend-licenses.md > phishingclub/frontend/static/licenses.txt
echo -e "\n\n" >> phishingclub/frontend/static/licenses.txt
cat /tmp/licenses/frontend-licenses.json >> phishingclub/frontend/static/licenses.txt
# Cleanup
rm -rf /tmp/licenses
echo "License file generated at phishingclub/frontend/static/licenses.txt"
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
# generate private key
openssl genrsa -out test.key 2048
# generate self-signed certificate
# generate certificate
openssl req -new -x509 -key test.key -out test.pem -days 365
+80
View File
@@ -0,0 +1,80 @@
package cache
import (
"sync/atomic"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/data"
)
// EventIDByName is a map of event names to event IDs
var EventIDByName = map[string]*uuid.UUID{}
// EventNameByID is a map of event ids to names
// this is not safe before the API is up an running entirely
var EventNameByID = map[string]string{}
var isUpdateAvailable atomic.Bool
func init() {
for _, name := range data.Events {
EventIDByName[name] = nil
}
isUpdateAvailable.Store(false)
}
func SetUpdateAvailable(updateAvailable bool) {
isUpdateAvailable.Store(updateAvailable)
}
func IsUpdateAvailable() bool {
return isUpdateAvailable.Load()
}
// TODO all priority event functions should be in utils or something, and the priority in the data package.
// var CampaignEventPriority = map[]
// Add priority rankings (higher number = higher priority)
// readonly
var CampaignEventPriority = map[string]int{
// campaign recipient events
data.EVENT_CAMPAIGN_RECIPIENT_CANCELLED: 80,
data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA: 70,
data.EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED: 60,
data.EVENT_CAMPAIGN_RECIPIENT_BEFORE_PAGE_VISITED: 40,
data.EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED: 50,
data.EVENT_CAMPAIGN_RECIPIENT_MESSAGE_READ: 30,
data.EVENT_CAMPAIGN_RECIPIENT_MESSAGE_FAILED: 20,
data.EVENT_CAMPAIGN_RECIPIENT_MESSAGE_SENT: 20,
data.EVENT_CAMPAIGN_RECIPIENT_SCHEDULED: 10,
// campaign events
data.EVENT_CAMPAIGN_CLOSED: 30,
data.EVENT_CAMPAIGN_ACTIVE: 20,
data.EVENT_CAMPAIGN_SELF_MANAGED: 20,
data.EVENT_CAMPAIGN_SCHEDULED: 10,
}
// IsMoreNotableCampaignRecipientEvent returns true if newEvent is more notable than currentEvent
func IsMoreNotableCampaignRecipientEvent(newEvent, currentEvent string) bool {
newPriority, newExists := CampaignEventPriority[newEvent]
currentPriority, currentExists := CampaignEventPriority[currentEvent]
// If either event doesn't exist in our priority map, treat it as lowest priority
if !newExists || !currentExists {
return false
}
return newPriority > currentPriority
}
func IsMoreNotableCampaignRecipientEventID(currentID, newID *uuid.UUID) bool {
if currentID == nil || currentID.String() == uuid.Nil.String() {
return true
}
if newID == nil {
return false
}
newEventName := EventNameByID[newID.String()]
currentEventName := EventNameByID[currentID.String()]
return IsMoreNotableCampaignRecipientEvent(newEventName, currentEventName)
}
+17
View File
@@ -0,0 +1,17 @@
package cli
import (
"fmt"
)
// OutputEnv outputs the available environment variables
// These are used for CI or similar enviroment tests
func OutputEnv() {
fmt.Println("Available environment variables:")
fmt.Println("APP_MODE = production, development, integration_test")
fmt.Println("TEST_DB_LOG_LEVEL = silent, debug, error, warn, info")
fmt.Println("HTTP_PROXY - sets outgoing http proxy")
fmt.Println("HTTPS_PROXY - sets outgoing https proxy")
fmt.Println("NO_PROXY - hosts that should not be proxied")
}
+44
View File
@@ -0,0 +1,44 @@
package cli
import (
"fmt"
"github.com/fatih/color"
)
// PrintVersion outputs the version of the application
func PrintVersion(
name,
version string,
) {
fmt.Printf("%s (%s)\n", name, version)
}
// PrintBanner outputs the banner for the application
func PrintBanner() {
blue := color.New(color.FgBlue)
_, _ = blue.Println(`
--:
.@@@@@*-.
.@@@@@@@@++:
.+*=. .@@@@@@@@@@@@*-.
+@@@@++- .+@@@@@@@@@@@@@@#=:
*@@@@@@@@#=. .=#@@@@@@@@@@@@@@@+*-
*@@@@@@@@@@@+- :#@@@@@@@@@@@@@@@@#.
*@@@@@@@@@@@@= +@@@@@@@@@@@@@@@@@=
*@@@@@@@@++: .=#@@@@@@@@@@@@@@@@++:
*@@@@@*=. .+@@@@@@@@@@@@@@@@#=.
.*#+: .@@@@@@@@@@@@@+*-
.@@@@@@@@@@#=.
.@@@@@@+*-
++@#=. `)
_, _ = fmt.Println()
_, _ = fmt.Println()
}
func PrintServerStarted(
name string,
address string,
) {
fmt.Printf("%s available:\nhttps://%s\n\n", name, address)
}
+37
View File
@@ -0,0 +1,37 @@
package cli
import (
"github.com/fatih/color"
)
type Outputter interface {
PrintInitialAdminAccount(username, password string)
}
type cliOutputter struct {
color *color.Color
}
// NewCLIOutputter creates a new CLIOutputter
func NewCLIOutputter() Outputter {
return &cliOutputter{
color: color.New(),
}
}
func (c *cliOutputter) PrintInitialAdminAccount(
username,
password string,
) {
bold := color.New(color.Bold)
italic := color.New(color.Bold)
_, _ = italic.Println("One time credentials for account setup")
_, _ = c.color.Println()
_, _ = c.color.Print("Username: ")
_, _ = bold.Println(username)
_, _ = c.color.Printf("Password: ")
_, _ = bold.Println(password)
_, _ = bold.Println()
_, _ = c.color.Println()
c.color.DisableColor()
}
+21
View File
@@ -0,0 +1,21 @@
{
"acme": {
"email": ""
},
"administration": {
"tls_host": "phish.test",
"tls_auto": false,
"tls_cert_path": ".dev/certs/self-signed/admin-public.pem",
"tls_key_path": ".dev/certs/self-signed/admin-private.pem",
"address": "0.0.0.0:8002"
},
"phishing": {
"http": "0.0.0.0:8000",
"https": "0.0.0.0:8001"
},
"database": {
"engine": "sqlite3",
"dsn": "file:/app/.dev/db.sqlite3"
},
"ip_allow_list": []
}
+25
View File
@@ -0,0 +1,25 @@
{
"acme": {
"email": ""
},
"administration": {
"tls_host": "phish.test",
"tls_auto": false,
"tls_cert_path": "certs/admin/public.pem",
"tls_key_path": "certs/admin/private.pem",
"address": "127.0.0.1:8002"
},
"phishing": {
"http": "127.0.0.1:8000",
"https": "127.0.0.1:8001"
},
"database": {
"engine": "sqlite3",
"dsn": "file:./db.sqlite3"
},
"log": {
"path": "",
"errorPath": ""
},
"ip_allow_list": []
}
+522
View File
@@ -0,0 +1,522 @@
package config
import (
"encoding/json"
"fmt"
"io/fs"
"net"
"os"
"strconv"
"github.com/go-errors/errors"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/file"
)
var (
ErrMissingIP = errors.New("missing IP")
ErrMissingPort = errors.New("missing port")
ErrMissingDatabaseDSN = errors.New("missing database DSN")
ErrInvalidIP = errors.New("invalid IP")
ErrInvalidPort = errors.New("invalid port")
ErrInvalidDatabase = errors.New("invalid database")
ErrWriterIsNil = errors.New("writer is nil")
)
const (
DefaultACMEEmail = ""
DefaultDevACMEEmail = ""
DatabaseUsePostgres = "postgres"
DefaultAdministrationUseSqlite = "sqlite3"
DefaultDatabase = DefaultAdministrationUseSqlite
DefaultAdministrationDSN = "file:./db.sqlite3"
DefaultDevAdministrationPort = 0 // 0 uses ephemeral port, random available port
DefaultDevHTTPPhishingPort = 8080
DefaultDevHTTPSPhishingPort = 8443
DefaultProductionAdministrationPort = 0 // 0 uses ephemeral port, random available port
DefaultProductionHTTPPhishingPort = 80
DefaultProductionHTTPSPhishingPort = 443
// empty is none
DefaultLogFilePath = ""
DefaultErrLogFilePath = ""
DefaultTrustedIPHeader = ""
DefaultAdminHost = ""
DefaultAdminAutoTLS = true
DefaultAdminAutoTLSString = "true"
)
var (
defaultTrustedProxies = []string{}
defaultAdminAllowed = []string{}
)
type (
// Config config
Config struct {
acme ACME
tlsHost string
tlsAuto bool
tlsCertPath string
tlsKeyPath string
adminNetAddress net.TCPAddr
phishingHTTPNetAddress net.TCPAddr
phishingHTTPSNetAddress net.TCPAddr
database Database
fileWriter file.Writer
LogPath string
ErrLogPath string
IPSecurity IPSecurityConfig
}
// ConfigDTO config DTO
ConfigDTO struct {
ACME ACME `json:"acme"`
AdministrationServer AdministrationServer `json:"administration"`
PhishingServer PhishingServer `json:"phishing"`
Database Database `json:"database"`
Log Log `json:"log"`
IPSecurity IPSecurityConfig `json:"ip_security"`
}
Log struct {
Path string `json:"path"`
ErrorPath string `json:"errorPath"`
}
// AdministrationServer ConfigDTO administration
AdministrationServer struct {
TLSHost string `json:"tls_host"`
TLSAuto bool `json:"tls_auto"`
TLSCertPath string `json:"tls_cert_path"`
TLSKeyPath string `json:"tls_key_path"`
Address string `json:"address"`
AllowList []string `json:"ip_allow_list"`
}
// PhishingServer ConfigDTO phishing
PhishingServer struct {
Http string `json:"http"`
Https string `json:"https"`
}
// Database ConfigDTO database
Database struct {
Engine string `json:"engine"`
DSN string `json:"dsn"`
}
// ACME ConfigDTO acme
ACME struct {
Email string `json:"email"`
}
)
type IPSecurityConfig struct {
// ip/cidr that are allowed to access the admin interface
AdminAllowed []string `json:"admin_allowed"`
// ip/cidr of legitimate reverse proxies (e.g., Nginx, HAProxy, Cloudflare edges)
TrustedProxies []string `json:"trusted_proxies"`
// headers to check for real client IP
// examples: CF-Connecting-IP, X-Real-IP, True-Client-IP, X-Forwarded-For
TrustedIPHeader string `json:"trusted_ip_header"`
}
// ValidateFileWriter validates the file writer
func ValidateFileWriter(fileWriter file.Writer) error {
if fileWriter == nil {
return ErrWriterIsNil
}
return nil
}
// NewConfig factory
func NewConfig(
acmeEmail string,
tlsHost string,
tlsAuto bool,
adminPublicCertPath string,
adminPrivateCertKey string,
adminAddress string,
phishingHTTPAddress string,
phishingHTTPSAddress string,
database Database,
fileWriter file.Writer,
logPath string,
errLogPath string,
ipSecurity IPSecurityConfig,
) (*Config, error) {
if err := ValidateFileWriter(fileWriter); err != nil {
return nil, errs.Wrap(err)
}
adminNetAddress, err := StringAddressToTCPAddr(adminAddress)
if err != nil {
return nil, errs.Wrap(err)
}
phishingHTTPNetAddress, err := StringAddressToTCPAddr(phishingHTTPAddress)
if err != nil {
return nil, errs.Wrap(err)
}
phishingHTTPSNetAddress, err := StringAddressToTCPAddr(phishingHTTPSAddress)
if err != nil {
return nil, errs.Wrap(err)
}
switch database.Engine {
case DatabaseUsePostgres:
case DefaultAdministrationUseSqlite:
default:
return nil, ErrInvalidDatabase
}
return &Config{
acme: ACME{
Email: acmeEmail,
},
tlsHost: tlsHost,
tlsAuto: tlsAuto,
tlsCertPath: adminPublicCertPath,
tlsKeyPath: adminPrivateCertKey,
adminNetAddress: *adminNetAddress,
phishingHTTPNetAddress: *phishingHTTPNetAddress,
phishingHTTPSNetAddress: *phishingHTTPSNetAddress,
database: Database{
Engine: database.Engine,
DSN: database.DSN,
},
fileWriter: &file.FileWriter{},
LogPath: logPath,
ErrLogPath: errLogPath,
IPSecurity: ipSecurity,
}, nil
}
// NewDevDefaultConfig returns a default config
func NewDevDefaultConfig() *Config {
tlsHost := "phish.test"
tlsAuto := false
publicCertPath := fmt.Sprintf(
"%s/%s",
data.DefaultAdminCertDir,
data.DefaultAdminPublicCertFileName,
)
privateCertPath := fmt.Sprintf(
"%s/%s",
data.DefaultAdminCertDir,
data.DefaultAdminPrivateCertFileName,
)
return &Config{
acme: ACME{
Email: DefaultACMEEmail,
},
tlsHost: tlsHost,
tlsAuto: tlsAuto,
tlsCertPath: publicCertPath,
tlsKeyPath: privateCertPath,
adminNetAddress: net.TCPAddr{
IP: net.IPv4(0, 0, 0, 0),
Port: DefaultDevAdministrationPort,
},
phishingHTTPNetAddress: net.TCPAddr{
IP: net.IPv4(0, 0, 0, 0),
Port: DefaultDevHTTPPhishingPort,
},
phishingHTTPSNetAddress: net.TCPAddr{
IP: net.IPv4(0, 0, 0, 0),
Port: DefaultDevHTTPSPhishingPort,
},
database: Database{
Engine: DefaultAdministrationUseSqlite,
DSN: DefaultAdministrationDSN,
},
fileWriter: &file.FileWriter{},
LogPath: DefaultLogFilePath,
ErrLogPath: DefaultErrLogFilePath,
IPSecurity: IPSecurityConfig{
AdminAllowed: []string{},
TrustedProxies: []string{},
TrustedIPHeader: "",
},
}
}
// NewDevDefaultConfig returns a default config
func NewProductionDefaultConfig() *Config {
tlsHost := "localhost"
tlsAuto := DefaultAdminAutoTLS
publicCertPath := fmt.Sprintf(
"%s/%s",
data.DefaultAdminCertDir,
data.DefaultAdminPublicCertFileName,
)
privateCertPath := fmt.Sprintf(
"%s/%s",
data.DefaultAdminCertDir,
data.DefaultAdminPrivateCertFileName,
)
return &Config{
acme: ACME{
Email: DefaultACMEEmail,
},
tlsHost: tlsHost,
tlsAuto: tlsAuto,
tlsCertPath: publicCertPath,
tlsKeyPath: privateCertPath,
adminNetAddress: net.TCPAddr{
IP: net.IPv4(0, 0, 0, 0),
Port: DefaultProductionAdministrationPort,
},
phishingHTTPNetAddress: net.TCPAddr{
IP: net.IPv4(0, 0, 0, 0),
Port: DefaultProductionHTTPPhishingPort,
},
phishingHTTPSNetAddress: net.TCPAddr{
IP: net.IPv4(0, 0, 0, 0),
Port: DefaultProductionHTTPSPhishingPort,
},
database: Database{
Engine: DefaultAdministrationUseSqlite,
DSN: DefaultAdministrationDSN,
},
fileWriter: &file.FileWriter{},
IPSecurity: IPSecurityConfig{
AdminAllowed: []string{},
TrustedProxies: []string{},
TrustedIPHeader: "",
},
}
}
// ACMEEmail returns the acme email
func (c *Config) ACMEEmail() string {
return c.acme.Email
}
// SetACMEEmail sets the acme email
func (c *Config) SetACMEEmail(email string) {
c.acme.Email = email
}
// TLSHost returns the host to use for admin server
func (c *Config) TLSHost() string {
return c.tlsHost
}
// TLSAuto returns if ACME service should handle TLS for the admin server
func (c *Config) TLSAuto() bool {
return c.tlsAuto
}
// TLSCertPath returns the cert path
func (c *Config) TLSCertPath() string {
return c.tlsCertPath
}
// TLSKeyPath returns the private key
func (c *Config) TLSKeyPath() string {
return c.tlsKeyPath
}
// SetTLSCertPath returns the admin host
func (c *Config) SetTLSHost(host string) {
c.tlsHost = host
}
// SetTLSAuto sets if a ACME service should handle TLS for the admin server
func (c *Config) SetTLSAuto(auto bool) {
c.tlsAuto = auto
}
// SetAdminNetAddress sets the administration network address
func (c *Config) SetAdminNetAddress(adminNetAddress string) error {
newAddr, err := StringAddressToTCPAddr(adminNetAddress)
if err != nil {
return err
}
c.adminNetAddress = *newAddr
return nil
}
// SetPhishingHTTPNetAddress sets the phishing network address
func (c *Config) SetPhishingHTTPNetAddress(addr string) error {
newAddr, err := StringAddressToTCPAddr(addr)
if err != nil {
return err
}
c.phishingHTTPNetAddress = *newAddr
return nil
}
// SetPhishingHTTPNetAddress sets the phishing network address
func (c *Config) SetPhishingHTTPSNetAddress(addr string) error {
newAddr, err := StringAddressToTCPAddr(addr)
if err != nil {
return err
}
c.phishingHTTPSNetAddress = *newAddr
return nil
}
// SetFileWriter sets the file writer
func (c *Config) SetFileWriter(fileWriter file.Writer) error {
if err := ValidateFileWriter(fileWriter); err != nil {
return fmt.Errorf("failed to set file writer on config: %w", err)
}
c.fileWriter = fileWriter
return nil
}
// Write writes the config to a writer
func (c *Config) WriteToFile(filepath string) error {
dto := c.ToDTO()
conf, err := json.MarshalIndent(dto, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
// Write the content to the writer
if _, err := c.fileWriter.Write(filepath, conf, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644); err != nil {
return fmt.Errorf("failed to write config: %w", err)
}
return nil
}
// StringAddressToTCPAddr converts a string address to a TCPAddr
func StringAddressToTCPAddr(address string) (*net.TCPAddr, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, errs.Wrap(err)
}
ip := net.ParseIP(host)
if ip == nil {
return nil, ErrInvalidIP
}
// convert port to int
p, err := strconv.Atoi(port)
if err != nil {
return nil, errs.Wrap(err)
}
if p < 0 || p > 65535 {
return nil, ErrInvalidPort
}
return &net.TCPAddr{
IP: ip,
Port: p,
}, nil
}
// FromMap creates a *Config from a DTO
func FromDTO(dto *ConfigDTO) (*Config, error) {
return NewConfig(
dto.ACME.Email,
dto.AdministrationServer.TLSHost,
dto.AdministrationServer.TLSAuto,
dto.AdministrationServer.TLSCertPath,
dto.AdministrationServer.TLSKeyPath,
dto.AdministrationServer.Address,
dto.PhishingServer.Http,
dto.PhishingServer.Https,
dto.Database,
file.FileWriter{},
dto.Log.Path,
dto.Log.ErrorPath,
dto.IPSecurity,
)
}
// ToDTO converts a *Config to a *ConfigDTO
func (c *Config) ToDTO() *ConfigDTO {
allowList := make([]string, 0)
return &ConfigDTO{
ACME: ACME{
Email: c.acme.Email,
},
AdministrationServer: AdministrationServer{
TLSHost: c.TLSHost(),
TLSAuto: c.TLSAuto(),
TLSCertPath: c.TLSCertPath(),
TLSKeyPath: c.TLSKeyPath(),
Address: c.AdminNetAddress(),
AllowList: allowList,
},
PhishingServer: PhishingServer{
Http: c.phishingHTTPNetAddress.String(),
Https: c.phishingHTTPSNetAddress.String(),
},
Database: Database{
Engine: c.database.Engine,
DSN: c.database.DSN,
},
Log: Log{
Path: c.LogPath,
ErrorPath: c.ErrLogPath,
},
IPSecurity: c.IPSecurity,
}
}
// AdminNetAddress returns the administration network address
func (c *Config) AdminNetAddress() string {
return c.adminNetAddress.String()
}
// AdminNetAddressPort returns the administration network address port
func (c *Config) AdminNetAddressPort() int {
return c.adminNetAddress.Port
}
// PhishingHTTPNetAddress returns the phishing network address
func (c *Config) PhishingHTTPNetAddress() string {
return c.phishingHTTPNetAddress.String()
}
// PhishingHTTPNetAddressPort returns the phishing network address port
func (c *Config) PhishingHTTPNetAddressPort() int {
return c.phishingHTTPNetAddress.Port
}
// PhishingHTTPSNetAddress returns the phishing network address
func (c *Config) PhishingHTTPSNetAddress() string {
return c.phishingHTTPSNetAddress.String()
}
// PhishingHTTPSNetAddressPort returns the phishing network address port
func (c *Config) PhishingHTTPSNetAddressPort() int {
return c.phishingHTTPSNetAddress.Port
}
// Database returns the database
func (c *Config) Database() Database {
return c.database
}
// NewDTOFromFile creates a *ConfigDTO from a file
func NewDTOFromFile(filesystem fs.FS, path string) (*ConfigDTO, error) {
var conf ConfigDTO
f, err := filesystem.Open(path)
if err != nil {
return nil, errs.Wrap(err)
}
dec := json.NewDecoder(f)
err = dec.Decode(&conf)
if err != nil {
return nil, errs.Wrap(err)
}
return &conf, nil
}
+455
View File
@@ -0,0 +1,455 @@
package config
import (
"encoding/json"
"fmt"
"io/fs"
"net"
"os"
"reflect"
"testing"
"testing/fstest"
"github.com/go-errors/errors"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/file"
"github.com/phishingclub/phishingclub/file/filemock"
)
const (
DEFAULT_ADMIN_ADDR = "127.0.0.1:8002"
DEFAULT_PHISHING_HTTP_ADDR = "127.0.0.1:8000"
DEFAULT_PHISHING_HTTPS_ADDR = "127.0.0.1:8001"
DEFAULT_ACME_EMAIL = ""
)
var (
adminHost = "phish.test"
adminTLS = false
adminPublicCertPath = fmt.Sprintf(
"%s/%s",
data.DefaultAdminCertDir,
data.DefaultAdminPublicCertFileName,
)
adminPrivateCertPath = fmt.Sprintf(
"%s/%s",
data.DefaultAdminCertDir,
data.DefaultAdminPrivateCertFileName,
)
configFileOK = []byte(`{
"administration": {
"address": "127.0.0.1:4000"
}
}`)
configFileEmpty = []byte("{")
databaseOK = Database{
Engine: DefaultAdministrationUseSqlite,
DSN: DefaultAdministrationDSN,
}
)
func newTestConfig() *Config {
return &Config{
acme: ACME{
Email: DEFAULT_ACME_EMAIL,
},
tlsCertPath: adminPublicCertPath,
tlsKeyPath: adminPrivateCertPath,
adminNetAddress: net.TCPAddr{
IP: net.IPv4(127, 0, 0, 1),
Port: DefaultDevAdministrationPort,
},
phishingHTTPNetAddress: net.TCPAddr{
IP: net.IPv4(127, 0, 0, 1),
Port: DefaultDevHTTPPhishingPort,
},
phishingHTTPSNetAddress: net.TCPAddr{
IP: net.IPv4(127, 0, 0, 1),
Port: DefaultDevHTTPSPhishingPort,
},
database: databaseOK,
fileWriter: &filemock.Writer{},
}
}
func TestNewConfig(t *testing.T) {
t.Run("happy path", testNewConfigHappyPath)
t.Run("invalid administration address and port split", testNewConfigInvalidAdministrationAddress)
t.Run("invalid administration ip", testNewConfigInvalidAdministrationIP)
t.Run("invalid administration port", testNewConfigInvalidAdministrationPort)
t.Run("invalid administration port string", testNewConfigInvalidAdministrationPortString)
t.Run("invalid database", testNewConfigInvalidDatabase)
t.Run("writer with nil", testNewConfigWithNilWriter)
}
func testNewConfigWithNilWriter(t *testing.T) {
_, err := NewConfig(
DEFAULT_ACME_EMAIL,
adminHost,
adminTLS,
adminPublicCertPath,
adminPrivateCertPath,
"127.0.0.1:8080",
DEFAULT_PHISHING_HTTP_ADDR,
DEFAULT_PHISHING_HTTPS_ADDR,
databaseOK,
nil,
"",
"",
IPSecurityConfig{
AdminAllowed: defaultAdminAllowed,
TrustedProxies: defaultTrustedProxies,
TrustedIPHeader: DefaultTrustedIPHeader,
},
)
if err == nil {
if !errors.Is(err, ErrWriterIsNil) {
t.Error("expected ErrWriterIsNil error from nil writer")
}
t.Error("expected error from nil writer")
return
}
}
func testNewConfigInvalidAdministrationAddress(t *testing.T) {
_, err := NewConfig(
DEFAULT_ACME_EMAIL,
adminHost,
adminTLS,
adminPublicCertPath,
adminPrivateCertPath,
"foobar",
DEFAULT_PHISHING_HTTP_ADDR,
DEFAULT_PHISHING_HTTPS_ADDR,
databaseOK,
&filemock.Writer{},
"",
"",
IPSecurityConfig{
AdminAllowed: defaultAdminAllowed,
TrustedProxies: defaultTrustedProxies,
TrustedIPHeader: DefaultTrustedIPHeader,
},
)
if err == nil {
t.Error("expected error from invalid address")
return
}
}
func testNewConfigInvalidAdministrationIP(t *testing.T) {
_, err := NewConfig(
DEFAULT_ACME_EMAIL,
adminHost,
adminTLS,
adminPublicCertPath,
adminPrivateCertPath,
"999.00.999.999:1234",
DEFAULT_PHISHING_HTTP_ADDR,
DEFAULT_PHISHING_HTTPS_ADDR,
databaseOK,
&filemock.Writer{},
"",
"",
IPSecurityConfig{
AdminAllowed: defaultAdminAllowed,
TrustedProxies: defaultTrustedProxies,
TrustedIPHeader: DefaultTrustedIPHeader,
},
)
if !errors.Is(err, ErrInvalidIP) {
t.Error(err)
return
}
}
func testNewConfigHappyPath(t *testing.T) {
addr := "127.0.0.1:1234"
c, err := NewConfig(
DEFAULT_ACME_EMAIL,
adminHost,
adminTLS,
adminPublicCertPath,
adminPrivateCertPath,
addr,
DEFAULT_PHISHING_HTTP_ADDR,
DEFAULT_PHISHING_HTTPS_ADDR,
databaseOK,
&filemock.Writer{},
"",
"",
IPSecurityConfig{
AdminAllowed: defaultAdminAllowed,
TrustedProxies: defaultTrustedProxies,
TrustedIPHeader: DefaultTrustedIPHeader,
},
)
if err != nil {
t.Error(err)
return
}
if c.AdminNetAddress() != addr {
t.Errorf("expected %s but got %s", addr, c.AdminNetAddress())
return
}
if c.database.DSN != databaseOK.DSN {
t.Errorf("expected %s but got %s", databaseOK.DSN, c.database.DSN)
return
}
if c.database.Engine != databaseOK.Engine {
t.Errorf("expected %s but got %s", databaseOK.Engine, c.database.Engine)
return
}
}
func testNewConfigInvalidAdministrationPort(t *testing.T) {
_, err := NewConfig(
DEFAULT_ACME_EMAIL,
adminHost,
adminTLS,
adminPublicCertPath,
adminPrivateCertPath,
"127.0.0.1:-1",
DEFAULT_PHISHING_HTTP_ADDR,
DEFAULT_PHISHING_HTTPS_ADDR,
databaseOK,
&filemock.Writer{},
"",
"",
IPSecurityConfig{
AdminAllowed: defaultAdminAllowed,
TrustedProxies: defaultTrustedProxies,
TrustedIPHeader: DefaultTrustedIPHeader,
},
)
if !errors.Is(err, ErrInvalidPort) {
t.Error(err)
return
}
}
func testNewConfigInvalidAdministrationPortString(t *testing.T) {
_, err := NewConfig(
DEFAULT_ACME_EMAIL,
adminHost,
adminTLS,
adminPublicCertPath,
adminPrivateCertPath,
"127.0.0.1:999999999999999999999999999999999999999999",
DEFAULT_PHISHING_HTTP_ADDR,
DEFAULT_PHISHING_HTTPS_ADDR,
databaseOK,
&filemock.Writer{},
"",
"",
IPSecurityConfig{
AdminAllowed: defaultAdminAllowed,
TrustedProxies: defaultTrustedProxies,
TrustedIPHeader: DefaultTrustedIPHeader,
},
)
if err == nil {
t.Error("expected error from invalid string port")
return
}
}
func testNewConfigInvalidDatabase(t *testing.T) {
_, err := NewConfig(
DEFAULT_ACME_EMAIL,
adminHost,
adminTLS,
adminPublicCertPath,
adminPrivateCertPath,
"127.0.0.1:1234",
DEFAULT_PHISHING_HTTP_ADDR,
DEFAULT_PHISHING_HTTPS_ADDR,
Database{
Engine: "foobar",
DSN: "file:./data.db?cache=shared&mode=rwc&_fk=1",
}, &filemock.Writer{},
"",
"",
IPSecurityConfig{
AdminAllowed: defaultAdminAllowed,
TrustedProxies: defaultTrustedProxies,
TrustedIPHeader: DefaultTrustedIPHeader,
},
)
if err == nil {
t.Errorf("expected %s but got %s", ErrInvalidDatabase, err)
return
}
}
func TestSetFileWriter(t *testing.T) {
t.Run("happypath", func(t *testing.T) {
c := newTestConfig()
err := c.SetFileWriter(&filemock.Writer{})
if err != nil {
t.Error(err)
return
}
})
t.Run("nil writer", func(t *testing.T) {
c := newTestConfig()
err := c.SetFileWriter(nil)
if err == nil {
if !errors.Is(err, ErrWriterIsNil) {
t.Error("expected ErrWriterIsNil error from nil writer")
}
t.Error("expected error from nil writer")
return
}
})
}
func TestWriteToFile(t *testing.T) {
filepath := "./testFile"
c := newTestConfig()
m := filemock.Writer{}
dto := c.ToDTO()
conf, err := json.MarshalIndent(dto, "", " ")
if err != nil {
t.Error(err)
return
}
m.
On("Write", filepath, conf, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, os.FileMode(0644)).
Return(0, nil)
err = c.SetFileWriter(&m)
if err != nil {
t.Error(err)
return
}
err = c.WriteToFile(filepath)
if err != nil {
t.Error(err)
return
}
}
func TestToDTO(t *testing.T) {
addr := "127.0.0.1:1234"
c, err := NewConfig(
DEFAULT_ACME_EMAIL,
adminHost,
adminTLS,
adminPublicCertPath,
adminPrivateCertPath,
addr,
DEFAULT_PHISHING_HTTP_ADDR,
DEFAULT_PHISHING_HTTPS_ADDR,
databaseOK,
&filemock.Writer{},
"",
"",
IPSecurityConfig{
AdminAllowed: defaultAdminAllowed,
TrustedProxies: defaultTrustedProxies,
TrustedIPHeader: DefaultTrustedIPHeader,
},
)
if err != nil {
t.Error(err)
return
}
dto := c.ToDTO()
if dto.AdministrationServer.Address != addr {
t.Errorf("expected %s but got %s", addr, dto.AdministrationServer.Address)
return
}
}
func TestNewDTOFromFile(t *testing.T) {
t.Run("happypath", testNewDTOFromFileHappyPath)
t.Run("file error", testNewDTOFromFileFileError)
t.Run("bad content", testNewDTOFromFileBadContent)
}
func testNewDTOFromFileHappyPath(t *testing.T) {
filesystem := fstest.MapFS{}
path := "config.json"
filesystem[path] = &fstest.MapFile{
Data: configFileOK,
}
dto, err := NewDTOFromFile(filesystem, path)
if err != nil {
t.Error(err)
return
}
if dto.AdministrationServer.Address != "127.0.0.1:4000" {
t.Errorf("Expected %s Got %s", "127.0.0.1:4000", dto.AdministrationServer.Address)
return
}
}
func testNewDTOFromFileFileError(t *testing.T) {
filesystem := fstest.MapFS{}
path := "config.json"
_, err := NewDTOFromFile(filesystem, path)
if !errors.Is(err, fs.ErrNotExist) {
t.Errorf("expected %s but got %s", fs.ErrNotExist, err)
return
}
}
func testNewDTOFromFileBadContent(t *testing.T) {
filesystem := fstest.MapFS{}
path := "config.json"
filesystem[path] = &fstest.MapFile{
Data: configFileEmpty,
}
_, err := NewDTOFromFile(filesystem, path)
if err == nil {
t.Error("expected error from invalid file contents")
return
}
}
func TestNewDefaultConfig(t *testing.T) {
tests := []struct {
name string
want *Config
}{
{
name: "happypath",
want: &Config{
tlsCertPath: adminPublicCertPath,
tlsKeyPath: adminPrivateCertPath,
adminNetAddress: net.TCPAddr{
IP: net.IPv4(127, 0, 0, 1),
Port: DefaultDevAdministrationPort,
},
database: Database{
Engine: DefaultAdministrationUseSqlite,
DSN: DefaultAdministrationDSN,
},
fileWriter: &file.FileWriter{},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := NewDevDefaultConfig(); !reflect.DeepEqual(got, tt.want) {
t.Errorf("NewDefaultConfig() = %v, want %v", got, tt.want)
}
})
}
}
func TestConfig_Database(t *testing.T) {
t.Run("happypath", func(t *testing.T) {
c := newTestConfig()
if !reflect.DeepEqual(c.Database(), databaseOK) {
t.Errorf("expected %v but got %v", databaseOK, c.Database())
return
}
})
}
+200
View File
@@ -0,0 +1,200 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
)
// AllowDenyColumnsMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var AllowDenyColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.ALLOW_DENY_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.ALLOW_DENY_TABLE, "updated_at"),
"hosting_website": repository.TableColumn(database.ALLOW_DENY_TABLE, "host_website"),
"redirects": repository.TableColumn(database.ALLOW_DENY_TABLE, "redirect_url"),
}
// AllowDeny is a controller
type AllowDeny struct {
Common
AllowDenyService *service.AllowDeny
}
// Create creates a new AllowDeny
func (c *AllowDeny) Create(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
var req model.AllowDeny
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// save
id, err := c.AllowDenyService.Create(g, session, &req)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
gin.H{
"id": id.String(),
},
)
}
// GetAll gets AllowDenies
func (c *AllowDeny) GetAll(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByName()
companyID := companyIDFromRequestQuery(g)
// get
allowDenies, err := c.AllowDenyService.GetAll(
g,
session,
companyID,
&repository.AllowDenyOption{
QueryArgs: queryArgs,
},
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
allowDenies,
)
}
// GetAllOverview gets AllowDenies
func (c *AllowDeny) GetAllOverview(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByName()
companyID := companyIDFromRequestQuery(g)
allowDenies, err := c.AllowDenyService.GetAll(
g,
session,
companyID,
&repository.AllowDenyOption{
Fields: []string{
"id",
"created_at",
"updated_at",
"company_id",
"name",
"allowed",
},
QueryArgs: queryArgs,
},
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
allowDenies,
)
}
// GetByID gets an AllowDeny by ID
func (c *AllowDeny) GetByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// get
allowDeny, err := c.AllowDenyService.GetByID(
g,
session,
id,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
allowDeny,
)
}
// UpdateByID updates an AllowDeny
func (c *AllowDeny) UpdateByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
var req model.AllowDeny
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// update
err := c.AllowDenyService.Update(g, session, id, &req)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
nil,
)
}
// DeleteByID deletes an AllowDeny
func (c *AllowDeny) DeleteByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// delete
err := c.AllowDenyService.DeleteByID(g, session, id)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
nil,
)
}
+196
View File
@@ -0,0 +1,196 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
)
// APISenderColumnsMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var APISenderColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.API_SENDER_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.API_SENDER_TABLE, "updated_at"),
"name": repository.TableColumn(database.API_SENDER_TABLE, "name"),
}
// APISender is a API sender controller
type APISender struct {
Common
APISenderService *service.APISender
}
// Create creates a new api sender
func (a *APISender) Create(g *gin.Context) {
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
var req model.APISender
if ok := a.handleParseRequest(g, &req); !ok {
return
}
id, err := a.APISenderService.Create(g, session, &req)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, gin.H{"id": id.String()})
}
// GetAll gets all api senders
func (a *APISender) GetAll(g *gin.Context) {
session, _, ok := a.handleSession(g)
if !ok {
return
}
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := a.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
queryArgs.RemapOrderBy(APISenderColumnsMap)
apiSenders, err := a.APISenderService.GetAll(
g.Request.Context(),
session,
companyID,
repository.APISenderOption{
QueryArgs: queryArgs,
},
)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, apiSenders)
}
// GetAllOverview gets all api senders with limited data
func (a *APISender) GetAllOverview(g *gin.Context) {
session, _, ok := a.handleSession(g)
if !ok {
return
}
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := a.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
queryArgs.RemapOrderBy(APISenderColumnsMap)
apiSenders, err := a.APISenderService.GetAllOverview(
g.Request.Context(),
session,
companyID,
repository.APISenderOption{
QueryArgs: queryArgs,
},
)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, apiSenders)
}
// GetByID gets a api sender by ID
func (a *APISender) GetByID(g *gin.Context) {
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse reqeuest
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
// get api sender
apiSender, err := a.APISenderService.GetByID(
g,
session,
id,
&repository.APISenderOption{},
)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, apiSender)
}
// Update updates a api sender
func (a *APISender) UpdateByID(g *gin.Context) {
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
var req model.APISender
if ok := a.handleParseRequest(g, &req); !ok {
return
}
err := a.APISenderService.UpdateByID(
g,
session,
id,
&req,
)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, gin.H{})
}
// DeletebyID deletes a api sender by ID
func (a *APISender) DeleteByID(g *gin.Context) {
// handle session
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
err := a.APISenderService.DeleteByID(
g.Request.Context(),
session,
id,
)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, gin.H{})
}
// SendTest sends a api request test and outputs the api sender and response
func (a *APISender) SendTest(g *gin.Context) {
// handle session
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
data, err := a.APISenderService.SendTest(
g.Request.Context(),
session,
id,
)
// output the error
if err != nil {
a.Response.BadRequestMessage(g, err.Error())
return
}
a.Response.OK(g, data)
}
+487
View File
@@ -0,0 +1,487 @@
package controller
import (
"encoding/base64"
"fmt"
"io/fs"
"net/http"
"net/url"
"os"
"path/filepath"
"github.com/go-errors/errors"
securejoin "github.com/cyphar/filepath-securejoin"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/utils"
"github.com/phishingclub/phishingclub/vo"
)
// AssetOrderByMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var AssetsColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.ASSET_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.ASSET_TABLE, "updated_at"),
"name": repository.TableColumn(database.ASSET_TABLE, "name"),
"description": repository.TableColumn(database.ASSET_TABLE, "description"),
"path": repository.TableColumn(database.ASSET_TABLE, "path"),
}
// Asset is an static Asset controller
type Asset struct {
Common
StaticAssetPath string
DomainService *service.Domain
OptionService *service.Option
AssetService *service.Asset
}
// GetContentByID get the content and mime type of an asset
func (a *Asset) GetContentByID(g *gin.Context) {
// handle session
session, _, ok := a.handleSession(g)
if !ok {
return
}
// check permissions
isAuthorized, err := service.IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
_ = handleServerError(g, a.Response, err)
return
}
if !isAuthorized {
a.Response.Unauthorized(g)
return
}
// get domain
domain, err := vo.NewString255(g.Param("domain"))
if err != nil {
a.Logger.Errorw("invalid domain",
"domain", domain,
)
a.Response.ValidationFailed(g, "Domain", err)
return
}
// if the target is the global folder, use the global folder
if domain.String() == data.ASSET_GLOBAL_FOLDER {
// TODO this shold require special permissions or be prefixed with a special path
// such as the company name or something that is prefixed
_ = data.ASSET_GLOBAL_FOLDER
}
staticPath, err := securejoin.SecureJoin(a.StaticAssetPath, domain.String())
if err != nil {
a.Logger.Debugw("insecure path",
"path", a.StaticAssetPath,
"domain", domain.String(),
"error", err,
)
return
}
// get the file path
pathDecoded, err := url.QueryUnescape(g.Param("path"))
if err != nil {
a.Logger.Debugw("failed to decode path",
"error", err,
)
a.Response.BadRequest(g)
return
}
filePath, err := securejoin.SecureJoin(staticPath, pathDecoded)
if err != nil {
a.Logger.Debugw("insecure path",
"path", pathDecoded,
"error", err,
)
a.Response.BadRequest(g)
return
}
// check if the file exists
a.Logger.Debugw("checking if asset exists",
"path", filePath,
)
_, err = os.Stat(filePath)
if errors.Is(err, fs.ErrNotExist) {
a.Logger.Debugw("asset not found",
"path", filePath,
)
a.Response.NotFound(g)
return
}
if err != nil {
a.Logger.Errorw("failed to get asset path info",
"path", filePath,
"error", err,
)
a.Response.ServerError(g)
return
}
// serve the file
// #nosec
content, err := os.ReadFile(filePath)
if err != nil {
a.Logger.Errorw("failed to read asset",
"path", filePath,
"error", err,
)
a.Response.ServerError(g)
return
}
fileExt := filepath.Ext(filePath)
mimeType := ""
switch fileExt {
case ".html":
mimeType = "text/html"
case ".htm":
mimeType = "text/html"
case ".xhtml":
mimeType = "application/xhtml+xml"
default:
mimeType = http.DetectContentType(content)
}
encodedContent := base64.StdEncoding.EncodeToString(content)
a.Response.OK(g, gin.H{
"mimeType": mimeType,
"file": encodedContent,
})
}
// GetAllForContext gets all static assets for a domain
// and has a special case 'shared' to get all global assets
func (a *Asset) GetAllForContext(g *gin.Context) {
// handle session
session, _, ok := a.handleSession(g)
if !ok {
return
}
// check permissions
isAuthorized, err := service.IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
_ = handleServerError(g, a.Response, err)
return
}
if !isAuthorized {
a.Response.Unauthorized(g)
return
}
// parse request
var domainID *uuid.UUID
companyID := companyIDFromRequestQuery(g)
domainParam := g.Param("domain")
queryArgs, ok := a.handleQueryArgs(g)
if !ok {
return
}
// set default sort by
queryArgs.RemapOrderBy(AssetsColumnsMap)
queryArgs.DefaultSortByUpdatedAt()
a.Logger.Debugw("getting assets for domain",
"domain", domainParam,
"companyID", companyID,
)
// if there is no domain then it is a global asset request
// else the domain name is the asset scope
if len(domainParam) > 0 {
domainName, err := vo.NewString255(domainParam)
if err != nil {
a.Logger.Errorw("invalid domain",
"domain", domainName,
)
a.Response.ValidationFailed(g, "Domain", err)
return
}
// get the domains id and also check if the user has permission to retrieve it
domain, err := a.DomainService.GetByName(
g.Request.Context(),
session,
domainName,
&repository.DomainOption{},
)
if ok := a.handleErrors(g, err); !ok {
return
}
did := domain.ID.MustGet()
domainID = &did
}
// get assets
a.Logger.Debugw("getting assets for domain by ID",
"domainID", domainID,
)
assets, err := a.AssetService.GetAll(
g,
session,
domainID,
companyID,
queryArgs,
)
// handle responses
a.handleErrors(g, err)
a.Response.OK(g, assets)
}
// Create uploads an static asset
func (a *Asset) Create(g *gin.Context) {
// handle session
session, _, ok := a.handleSession(g)
if !ok {
return
}
// this is a form data request, so we must handle all fields manually as is it not parsed from the struct
multipartData, err := g.MultipartForm()
if err != nil {
a.Logger.Errorw("failed to get multipart form",
"error", err,
)
a.Response.BadRequest(g)
return
}
if len(multipartData.File["files"]) == 0 {
a.Logger.Debug("no files to upload")
a.Response.BadRequestMessage(g, "No files selected")
return
}
contextParam := g.PostForm("domain")
// if no domain is set, use the global folder
var domain *model.Domain
// if a domain is supplied we look for its assets
if len(contextParam) > 0 {
// check that the domain exists
name, err := vo.NewString255(contextParam)
if err != nil {
a.Logger.Errorw("invalid domain name",
"error", err,
)
a.Response.ValidationFailed(g, "Domain", err)
return
}
d, err := a.DomainService.GetByName(
g,
session,
name,
&repository.DomainOption{},
)
if ok := a.handleErrors(g, err); !ok {
return
}
domain = d
a.Logger.Debugw("uploading assets to domain",
"domain", contextParam,
)
} else {
a.Logger.Debug("uploading shared assets")
}
// map files to assets
assets := []*model.Asset{}
for _, file := range multipartData.File["files"] {
// check max file size
maxFile, err := a.OptionService.GetOption(g, session, data.OptionKeyMaxFileUploadSizeMB)
if ok := a.handleErrors(g, err); !ok {
return
}
ok, err := utils.CompareFileSizeFromString(file.Size, maxFile.Value.String())
if err != nil {
a.Logger.Errorw("failed to compare file size",
"error", err,
)
}
if !ok {
a.Logger.Debugw("file too large",
"filename", file.Filename,
"size", file.Size,
"maxSize", maxFile.Value.String(),
)
a.Response.ValidationFailed(
g,
"File",
fmt.Errorf("file '%s' is too large", utils.ReadableFileName(file.Filename)),
)
return
}
// TODO multi user validate that the company id is the same as the session company id or that the session is a super admin
// TODO can the creation of the ID be moved to the repo
var domainID string
if domain != nil {
did := domain.ID.MustGet()
domainID = did.String()
}
name, err := vo.NewOptionalString127(g.Request.PostFormValue("name"))
if err != nil {
a.Logger.Debugw("failed to parse name",
"error", err,
)
a.Response.ValidationFailed(g, "Name", err)
return
}
description, err := vo.NewOptionalString255(g.Request.PostFormValue("description"))
if err != nil {
a.Logger.Debugw("failed to parse description",
"error", err,
)
a.Response.ValidationFailed(g, "Description", err)
return
}
path, err := vo.NewRelativeFilePath(g.Request.PostFormValue("path"))
if err != nil {
a.Logger.Debugw("failed to parse path",
"error", err,
)
a.Response.ValidationFailed(g, "Path", err)
return
}
companyID := nullable.NewNullNullable[uuid.UUID]()
companyIDParam := g.PostForm("companyID")
if len(companyIDParam) > 0 {
cid, err := uuid.Parse(companyIDParam)
if err != nil {
a.Logger.Debugw("failed to parse company id",
"error", err,
)
a.Response.ValidationFailed(g, "CompanyID", err)
}
companyID.Set(cid)
} else {
companyID.SetNull()
}
assetName := nullable.NewNullableWithValue(*name)
assetDescription := nullable.NewNullableWithValue(*description)
assetPath := nullable.NewNullableWithValue(*path)
assetDomainID := nullable.NewNullNullable[uuid.UUID]()
if len(domainID) > 0 {
did, err := uuid.Parse(domainID)
if err != nil {
a.Logger.Debugw("failed to parse domain id",
"error", err,
)
a.Response.ValidationFailed(g, "DomainID", err)
return
}
assetDomainID.Set(did)
// if the asset belongs to a domain it must not be 'global' context
if !companyID.IsSpecified() {
a.Logger.Debugw(
"cant add a shared asset to a company owned domain",
"domainID", domainID,
"domainOwnerCompanyID", companyID,
)
a.Response.ValidationFailed(
g,
"domainID",
errors.New("cant add a shared asset to a company owned domain"),
)
return
}
}
asset := model.Asset{
Name: assetName,
Description: assetDescription,
Path: assetPath,
File: *file,
DomainID: assetDomainID,
CompanyID: companyID,
}
if domain != nil {
asset.DomainName = domain.Name
}
assets = append(assets, &asset)
}
// store the files on disk and in database
ids, err := a.AssetService.Create(g, session, assets)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, gin.H{
"ids": ids,
"files_uploaded": len(assets),
})
}
// GetByID gets an static asset by id
func (a *Asset) GetByID(g *gin.Context) {
// handle session
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
// get the asset
ctx := g.Request.Context()
asset, err := a.AssetService.GetByID(ctx, session, id)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, asset)
}
// UpdateByID updates an static asset by id
func (a *Asset) UpdateByID(g *gin.Context) {
// handle session
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
var req model.Asset
if ok := a.handleParseRequest(g, &req); !ok {
return
}
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
// update the asset
ctx := g.Request.Context()
err := a.AssetService.UpdateByID(
ctx,
session,
id,
req.Name,
req.Description,
)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, gin.H{})
}
// RemoveByID removes an static asset
// if the asset is a directory, it will be removed recursively
func (a *Asset) RemoveByID(g *gin.Context) {
// handle session
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
// remove the asset
ctx := g.Request.Context()
err := a.AssetService.DeleteByID(
ctx,
session,
id,
)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, gin.H{})
}
+404
View File
@@ -0,0 +1,404 @@
package controller
import (
"encoding/base64"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/go-errors/errors"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/utils"
"github.com/phishingclub/phishingclub/vo"
)
// AttachmentColumnsMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var AttachmentColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.ATTACHMENT_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.ATTACHMENT_TABLE, "updated_at"),
"name": repository.TableColumn(database.ATTACHMENT_TABLE, "name"),
"description": repository.TableColumn(database.ATTACHMENT_TABLE, "description"),
"embedded content": repository.TableColumn(database.ATTACHMENT_TABLE, "embeddedContent"),
"filename": repository.TableColumn(database.ATTACHMENT_TABLE, "filename"),
}
// Attachment is an static Attachment controller
type Attachment struct {
Common
StaticAttachmentPath string
TemplateService *service.Template
AttachmentService *service.Attachment
OptionService *service.Option
CompanyService *service.Company
}
// GetContentByID returns the content and mime type of an attachment
func (a *Attachment) GetContentByID(g *gin.Context) {
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
// get the attachment
ctx := g.Request.Context()
attachment, err := a.AttachmentService.GetByID(
ctx,
session,
id,
)
if ok := a.handleErrors(g, err); !ok {
return
}
p := attachment.Path.MustGet().String()
// serve the file
// #nosec
content, err := os.ReadFile(p)
if err != nil {
a.Logger.Errorw("failed to read file",
"path", p,
"error", err,
)
a.Response.ServerError(g)
return
}
fileExt := filepath.Ext(p)
mimeType := ""
switch fileExt {
case ".html":
mimeType = "text/html"
case ".htm":
mimeType = "text/html"
case ".xhtml":
mimeType = "application/xhtml+xml"
default:
mimeType = http.DetectContentType(content)
}
// get by id is only used for admin viewing of an attachemnt, so all
// embedded content must contain example data
if attachment.EmbeddedContent.MustGet() {
// build email
domain := &model.Domain{
Name: nullable.NewNullableWithValue(
*vo.NewString255Must("example.test"),
),
}
recipient := model.NewRecipientExample()
campaignRecipient := model.CampaignRecipient{
ID: nullable.NewNullableWithValue(
uuid.New(),
),
Recipient: recipient,
}
email := model.NewEmailExample()
// hacky
email.Content = nullable.NewNullableWithValue(
*vo.NewUnsafeOptionalString1MB(string(content)),
)
apiSender := model.NewAPISenderExample()
b, err := a.TemplateService.CreateMailBody(
"id",
"/foo",
domain,
&campaignRecipient,
email,
apiSender,
)
if err != nil {
a.Logger.Errorw("failed to appy template to attachment",
"error", err,
)
a.Response.ServerError(g)
return
}
content = []byte(b)
}
a.Response.OK(g, gin.H{
"mimeType": mimeType,
"file": base64.StdEncoding.EncodeToString(content),
})
}
// GetAllForContext gets all attachments for a domain
// and has a special case 'shared' to get all global attachments
func (a *Attachment) GetAllForContext(g *gin.Context) {
session, _, ok := a.handleSession(g)
if !ok {
return
}
// check permissions
isAuthorized, err := service.IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
a.Logger.Errorw("failed to check permissions",
"error", err,
)
a.Response.ServerError(g)
return
}
if !isAuthorized {
// TODO audit log
_ = handleAuthorizationError(g, a.Response, errs.ErrAuthorizationFailed)
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
// if there is no companyID then it is a global attachment request
// else the company context name is the attachment scope
if companyID != nil {
// get the company id and to check if the user has permission to retrieve it
_, err := a.CompanyService.GetByID(
g.Request.Context(),
session,
companyID,
)
if ok := a.handleErrors(g, err); !ok {
return
}
}
queryArgs, ok := a.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
queryArgs.RemapOrderBy(AttachmentColumnsMap)
// get attachments
a.Logger.Debugw("getting attachments for company ID",
"companyID", companyID,
)
attachments, err := a.AttachmentService.GetAll(
g,
session,
companyID,
queryArgs,
)
// handle responses
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, attachments)
}
// Create uploads an attachment
func (a *Attachment) Create(g *gin.Context) {
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
multipartData, err := g.MultipartForm()
if err != nil {
a.Logger.Errorw("failed to get multipart form",
"error", err,
)
a.Response.BadRequest(g)
return
}
if len(multipartData.File["files"]) == 0 {
a.Logger.Debug("no files to upload")
a.Response.BadRequestMessage(g, "No files selected")
return
}
companyID := nullable.NewNullNullable[uuid.UUID]()
companyIDParam := g.PostForm("companyID")
if len(companyIDParam) > 0 {
cid, err := uuid.Parse(companyIDParam)
if err != nil {
a.Logger.Debugw("failed to parse company id",
"error", err,
)
a.Response.ValidationFailed(g, "companyID", err)
return
}
companyID.Set(cid)
}
nameParam, err := vo.NewOptionalString127(g.PostForm("name"))
if err != nil {
a.Logger.Debugw("failed to parse name",
"name", g.PostForm("name"),
"error", err,
)
a.Response.ValidationFailed(g, "name", err)
return
}
name := nullable.NewNullableWithValue(*nameParam)
descriptionParam, err := vo.NewOptionalString255(g.PostForm("description"))
if err != nil {
a.Logger.Debugw("failed to parse description",
"error", err,
)
a.Response.ValidationFailed(g, "description", err)
return
}
description := nullable.NewNullableWithValue(*descriptionParam)
embeddedContent := nullable.NewNullableWithValue(false)
embeddedContentString := g.PostForm("embeddedContent")
if strings.ToLower(embeddedContentString) == "true" {
embeddedContent.Set(true)
}
attachments := []*model.Attachment{}
for _, file := range multipartData.File["files"] {
// TODO multi user validate that the company id is the same as the session company id or that the session is a super admin
// check max file size
maxFile, err := a.OptionService.GetOption(g, session, data.OptionKeyMaxFileUploadSizeMB)
if ok := a.handleErrors(g, err); !ok {
return
}
ok, err := utils.CompareFileSizeFromString(file.Size, maxFile.Value.String())
if err != nil {
a.Logger.Errorw("failed to compare file size",
"error", err,
)
}
if !ok {
a.Logger.Debugw("file too large",
"filename", file.Filename,
"size", file.Size,
"maxSize", maxFile.Value.String(),
)
a.Response.ValidationFailed(
g,
"File",
fmt.Errorf("'%s' is too large", utils.ReadableFileName(file.Filename)),
)
return
}
fileNameParam, err := vo.NewFileName(file.Filename)
if err != nil {
a.Logger.Debugw("failed to parse filename",
"error", err,
)
a.Response.ValidationFailed(g, "filename", err)
return
}
fileName := nullable.NewNullableWithValue(*fileNameParam)
attachment := model.Attachment{
CompanyID: companyID,
Name: name,
Description: description,
EmbeddedContent: embeddedContent,
File: file,
FileName: fileName,
}
if err := attachment.Validate(); err != nil {
a.Logger.Debugw("failed to validate attachment",
"attachmentName", name,
"error", err,
)
a.Response.ValidationFailed(g, "attachment", err)
return
}
attachments = append(attachments, &attachment)
}
// store the files on disk and in database
createdIDs, err := a.AttachmentService.Create(
g,
session,
attachments,
)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, gin.H{
"ids": createdIDs,
"files_uploaded": len(attachments),
})
}
// GetByID gets an static attachment by id
func (a *Attachment) GetByID(g *gin.Context) {
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
// get the attachment
ctx := g.Request.Context()
attachment, err := a.AttachmentService.GetByID(
ctx,
session,
id,
)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, attachment)
}
// UpdateByID updates an static attachment by id
func (a *Attachment) UpdateByID(g *gin.Context) {
// handle session
session, _, ok := a.handleSession(g)
if !ok {
return
}
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
// parse request
var req model.Attachment
if ok := a.handleParseRequest(g, &req); !ok {
return
}
// update the attachment
ctx := g.Request.Context()
err := a.AttachmentService.UpdateByID(
ctx,
session,
id,
&req,
)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, gin.H{})
}
// RemoveByID removes an static attachment
// if the attachment is a directory, it will be removed recursively
func (a *Attachment) RemoveByID(g *gin.Context) {
// handle session
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
// remove the attachment
ctx := g.Request.Context()
err := a.AttachmentService.DeleteByID(
ctx,
session,
id,
)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, gin.H{})
}
+927
View File
@@ -0,0 +1,927 @@
package controller
import (
"bytes"
"encoding/csv"
"time"
"github.com/go-errors/errors"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/build"
"github.com/phishingclub/phishingclub/cache"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/embedded"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/utils"
)
// allowedCampaignColumns is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var allowedCampaignColumns = map[string]string{
"created_at": repository.TableColumn(database.CAMPAIGN_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.CAMPAIGN_TABLE, "updated_at"),
"closed_at": repository.TableColumn(database.CAMPAIGN_TABLE, "closed_at"),
"close_at": repository.TableColumn(database.CAMPAIGN_TABLE, "close_at"),
"anonymized_at": repository.TableColumn(database.CAMPAIGN_TABLE, "anonymized_at"),
"is_test": repository.TableColumn(database.CAMPAIGN_TABLE, "is_test"),
"send_start_at": repository.TableColumn(database.CAMPAIGN_TABLE, "send_start_at"),
"send_end_at": repository.TableColumn(database.CAMPAIGN_TABLE, "send_end_at"),
"template": repository.TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "name"),
"name": repository.TableColumn(database.CAMPAIGN_TABLE, "name"),
}
// campaignEventColumns is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var campaignEventColumns = map[string]string{
"created_at": repository.TableColumn(database.CAMPAIGN_EVENT_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.CAMPAIGN_EVENT_TABLE, "updated_at"),
"details": repository.TableColumn(database.CAMPAIGN_EVENT_TABLE, "data"),
"ip": repository.TableColumn(database.CAMPAIGN_EVENT_TABLE, "ip_address"),
"user-agent": repository.TableColumn(database.CAMPAIGN_EVENT_TABLE, "user_agent"),
"email": repository.TableColumn(database.RECIPIENT_TABLE, "email"),
"first_name": repository.TableColumn(database.RECIPIENT_TABLE, "first_name"),
"last_name": repository.TableColumn(database.RECIPIENT_TABLE, "last_name"),
"event": repository.TableColumn(database.EVENT_TABLE, "name"),
}
// allowedCampaignRecipientColumns is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var allowedCampaignRecipientColumns = map[string]string{
"created_at": "campaign_recipients.created_at",
"updated_at": "campaign_recipients.updated_at",
"send_at": "campaign_recipients.send_at",
"sent_at": "campaign_recipients.sent_at",
"cancelled_at": "campaign_recipients.cancelled_at",
"status": "campaign_recipients.notable_event_id",
"first_name": "recipients.first_name",
"last_name": "recipients.last_name",
"email": "recipients.email",
}
// Campaign is a Campaign controller
type Campaign struct {
Common
CampaignService *service.Campaign
}
// CloseCampaignByID closes campaign
func (c *Campaign) CloseCampaignByID(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// close campaigns
err := c.CampaignService.CloseCampaignByID(
g.Request.Context(),
session,
id,
)
// handle responses
if errors.Is(err, errs.ErrCampaignAlreadyClosed) {
c.Response.ValidationFailed(g, "", err)
return
}
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// Create creates a new campaign
func (c *Campaign) Create(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse req
var req model.Campaign
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// create and schedule the campaign
id, err := c.CampaignService.Create(g.Request.Context(), session, &req)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{
"id": id.String(),
})
}
// GetAllEventTypes gets all event types
func (c *Campaign) GetAllEventTypes(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// check permissions
isAuthorized, err := service.IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
_ = handleServerError(g, c.Response, err)
return
}
if !isAuthorized {
c.Response.Unauthorized(g)
return
}
// get all event names
// we pick them out from the in memory cache
ev := []gin.H{}
for name, id := range cache.EventIDByName {
ev = append(ev, gin.H{
"id": id,
"name": name,
})
}
c.Response.OK(g, ev)
}
// GetByID gets a campaign by its id
func (c *Campaign) GetByID(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// get the campaign that needs to be updated
campaign, err := c.CampaignService.GetByID(
g.Request.Context(),
session,
id,
&repository.CampaignOption{
WithRecipientGroups: true,
WithAllowDeny: true,
WithDenyPage: true,
},
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, campaign)
}
// GetByName gets a campaign by name
func (c *Campaign) GetByName(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
name := g.Param("name")
if !ok {
return
}
// get the campaign that needs to be updated
campaign, err := c.CampaignService.GetByName(
g,
session,
name,
companyID,
&repository.CampaignOption{
WithRecipientGroups: true,
WithAllowDeny: true,
WithDenyPage: true,
},
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, campaign)
}
// GetResultStats get campaign result stats
func (c *Campaign) GetResultStats(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// get
stats, err := c.CampaignService.GetResultStats(
g.Request.Context(),
session,
id,
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, stats)
}
// GetCampaignStats get campaign stats
// if no company id is provided it gets the global stats including all companies
func (c *Campaign) GetStats(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
// get
stats, err := c.CampaignService.GetStats(
g.Request.Context(),
session,
companyID,
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, stats)
}
// GetAll gets all campaigns with pagination
func (c *Campaign) GetAll(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.RemapOrderBy(allowedCampaignColumns)
queryArgs.DefaultSortByUpdatedAt()
// get all campaigns
campaigns, err := c.CampaignService.GetAll(
g.Request.Context(),
session,
companyID,
&repository.CampaignOption{
QueryArgs: queryArgs,
WithCampaignTemplate: true,
},
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, campaigns)
}
// GetAll gets all campaigns within dates
func (c *Campaign) GetAllWithinDates(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.RemapOrderBy(allowedCampaignColumns)
queryArgs.DefaultSortByUpdatedAt()
// get start and end date for query
startDate, err := time.Parse(time.RFC3339Nano, g.Query("start"))
if err != nil {
c.Response.ValidationFailed(g, "start", err)
return
}
endDate, err := time.Parse(time.RFC3339Nano, g.Query("end"))
if err != nil {
c.Response.ValidationFailed(g, "end", err)
return
}
// get all campaigns
campaigns, err := c.CampaignService.GetAllWithinDates(
g.Request.Context(),
session,
startDate,
endDate,
companyID,
&repository.CampaignOption{
QueryArgs: queryArgs,
WithCampaignTemplate: true,
},
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, campaigns)
}
// GetAllActive gets all active campaigns with pagination
// if no company id is given it gets all globals including company
func (c *Campaign) GetAllActive(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.RemapOrderBy(allowedCampaignColumns)
if queryArgs.OrderBy == "" {
queryArgs.OrderBy = "send_start_at"
queryArgs.Desc = false
}
// get all campaigns
campaigns, err := c.CampaignService.GetAllActive(
g.Request.Context(),
session,
companyID,
&repository.CampaignOption{
QueryArgs: queryArgs,
WithCompany: true,
WithCampaignTemplate: true,
},
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, campaigns)
}
// GetAllUpcoming gets all upcoming campaigns with pagination
// if no company id is given it gets all globals including company
func (c *Campaign) GetAllUpcoming(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.RemapOrderBy(allowedCampaignColumns)
if queryArgs.OrderBy == "" {
queryArgs.OrderBy = "send_start_at"
queryArgs.Desc = false
}
// get all campaigns
campaigns, err := c.CampaignService.GetAllUpcoming(
g.Request.Context(),
session,
companyID,
&repository.CampaignOption{
QueryArgs: queryArgs,
WithCompany: true,
WithCampaignTemplate: true,
},
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, campaigns)
}
// GetAllFinished gets all finished campaigns with pagination
// if no company id is given it gets all globals including company
func (c *Campaign) GetAllFinished(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.RemapOrderBy(allowedCampaignColumns)
if queryArgs.OrderBy == "" {
queryArgs.OrderBy = "send_start_at"
queryArgs.Desc = true
}
// get all campaigns
campaigns, err := c.CampaignService.GetAllFinished(
g.Request.Context(),
session,
companyID,
&repository.CampaignOption{
QueryArgs: queryArgs,
WithCompany: true,
WithCampaignTemplate: true,
},
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, campaigns)
}
// GetEventsByCampaignID gets events by campaign id
func (c *Campaign) GetEventsByCampaignID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
// remap query args
queryArgs.RemapOrderBy(campaignEventColumns)
// set default sort order to desc
sortOrder := g.DefaultQuery("sortOrder", "desc")
if sortOrder == "desc" {
queryArgs.Desc = true
}
var since *time.Time
s, err := time.Parse(time.RFC3339Nano, g.Query("since"))
if err == nil {
since = &s
}
// get events by campaign id
events, err := c.CampaignService.GetEventsByCampaignID(
g.Request.Context(),
session,
id,
queryArgs,
since,
nil,
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, events)
}
// ExportEventsAsCSV exports a all campaign events as a CSV
func (c *Campaign) ExportEventsAsCSV(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByCreatedAt()
queryArgs.RemapOrderBy(campaignEventColumns)
sortOrder := g.DefaultQuery("sortOrder", "desc")
if sortOrder == "desc" {
queryArgs.Desc = true
}
// get all rows
queryArgs.Limit = 0
queryArgs.Offset = 0
// get events by campaign id
events, err := c.CampaignService.GetEventsByCampaignID(
g.Request.Context(),
session,
id,
queryArgs,
nil,
nil,
)
if ok := c.handleErrors(g, err); !ok {
return
}
buffer := &bytes.Buffer{}
writer := csv.NewWriter(buffer)
headers := []string{
"Created at",
"Recipient name",
"Recipient email",
"Event name",
"Event Details",
"User-Agent",
"IP",
}
err = writer.Write(headers)
if ok := c.handleErrors(g, err); !ok {
return
}
for _, event := range events.Rows {
row := []string{}
// if the recipient has been deleted or anonymized
if event.Recipient == nil {
row = []string{
utils.CSVFromDate(event.CreatedAt),
"anonymized",
"anonymized",
utils.CSVRemoveFormulaStart(cache.EventNameByID[event.EventID.String()]),
utils.CSVRemoveFormulaStart(event.Data.String()),
utils.CSVRemoveFormulaStart(event.UserAgent.String()),
utils.CSVRemoveFormulaStart(event.IP.String()),
}
} else {
row = []string{
utils.CSVFromDate(event.CreatedAt),
utils.CSVRemoveFormulaStart(event.Recipient.FirstName.MustGet().String()),
utils.CSVRemoveFormulaStart(event.Recipient.LastName.MustGet().String()),
utils.CSVRemoveFormulaStart(event.Recipient.Email.MustGet().String()),
utils.CSVRemoveFormulaStart(cache.EventNameByID[event.EventID.String()]),
utils.CSVRemoveFormulaStart(event.Data.String()),
utils.CSVRemoveFormulaStart(event.UserAgent.String()),
utils.CSVRemoveFormulaStart(event.IP.String()),
}
}
err = writer.Write(row)
if ok := c.handleErrors(g, err); !ok {
return
}
}
c.responseWithCSV(g, buffer, writer, "campaign_events.csv")
}
// ExportSubmissionsAsCSV exports all campaign submissions as a CSV
func (c *Campaign) ExportSubmissionsAsCSV(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByCreatedAt()
queryArgs.RemapOrderBy(campaignEventColumns)
sortOrder := g.DefaultQuery("sortOrder", "desc")
if sortOrder == "desc" {
queryArgs.Desc = true
}
// get all rows
queryArgs.Limit = 0
queryArgs.Offset = 0
// filter for submission events only
submissionEventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA]
eventTypeFilter := []string{submissionEventID.String()}
// get submission events by campaign id
events, err := c.CampaignService.GetEventsByCampaignID(
g.Request.Context(),
session,
id,
queryArgs,
nil,
eventTypeFilter,
)
if ok := c.handleErrors(g, err); !ok {
return
}
buffer := &bytes.Buffer{}
writer := csv.NewWriter(buffer)
headers := []string{
"Submitted at",
"Recipient first name",
"Recipient last name",
"Recipient email",
"Submitted data",
"User-Agent",
"IP",
}
err = writer.Write(headers)
if ok := c.handleErrors(g, err); !ok {
return
}
for _, event := range events.Rows {
row := []string{}
// if the recipient has been deleted or anonymized
if event.Recipient == nil {
row = []string{
utils.CSVFromDate(event.CreatedAt),
"anonymized",
"anonymized",
"anonymized",
utils.CSVRemoveFormulaStart(event.Data.String()),
utils.CSVRemoveFormulaStart(event.UserAgent.String()),
utils.CSVRemoveFormulaStart(event.IP.String()),
}
} else {
row = []string{
utils.CSVFromDate(event.CreatedAt),
utils.CSVRemoveFormulaStart(event.Recipient.FirstName.MustGet().String()),
utils.CSVRemoveFormulaStart(event.Recipient.LastName.MustGet().String()),
utils.CSVRemoveFormulaStart(event.Recipient.Email.MustGet().String()),
utils.CSVRemoveFormulaStart(event.Data.String()),
utils.CSVRemoveFormulaStart(event.UserAgent.String()),
utils.CSVRemoveFormulaStart(event.IP.String()),
}
}
err = writer.Write(row)
if ok := c.handleErrors(g, err); !ok {
return
}
}
c.responseWithCSV(g, buffer, writer, "campaign_submissions.csv")
}
func (c *Campaign) GetCampaignEmail(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// get email
email, err := c.CampaignService.GetCampaignEmailBody(
g.Request.Context(),
session,
id,
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, email)
}
// GetCampaignURL gets a recipient landing page URL
func (c *Campaign) GetCampaignURL(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
url, err := c.CampaignService.GetLandingPageURLByCampaignRecipientID(
g.Request.Context(),
session,
id,
)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, url)
}
// GetRecipientsByCampaignID gets recipients by campaign id
func (c *Campaign) GetRecipientsByCampaignID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// endpoints is handled a bit differently and allows to
// fetch an unlimited amount of rows if no offset and limit is set.
// TODO this endpoint should be changed to a Result<T> so we fetch the rows as needed.
offset := g.DefaultQuery("offset", "")
limit := g.DefaultQuery("limit", "")
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
// special case to retrieve ALL rows
if offset == "" && limit == "" {
queryArgs.Offset = 0
queryArgs.Limit = 0
}
// remap query args
queryArgs.DefaultSortBy("created_at")
queryArgs.RemapOrderBy(allowedCampaignRecipientColumns)
// get recipients by campaign id
recipients, err := c.CampaignService.GetRecipientsByCampaignID(
g.Request.Context(),
session,
id,
&repository.CampaignRecipientOption{
QueryArgs: queryArgs,
WithRecipient: true,
},
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, recipients)
}
// TrackingPixel returns a tracking pixel
func (c *Campaign) TrackingPixel(g *gin.Context) {
// get the campaign recipient id from the query
campaignRecipientID := g.Query("upn") // expect the campaign recipient id to be in here
if campaignRecipientID == "" {
c.Response.NotFound(g)
return
}
campaignRecipientUUID, err := uuid.Parse(campaignRecipientID)
if err != nil {
c.Logger.Debugw(errs.MsgFailedToParseRequest,
"error", err,
)
c.Response.NotFound(g)
return
}
err = c.CampaignService.SaveTrackingPixelLoaded(
g,
&campaignRecipientUUID,
)
if err != nil {
c.Logger.Debugw("failed to save tracking pixel loaded event",
"error", err,
)
c.Response.NotFound(g)
return
}
g.Header("Content-Type", "image/gif")
if !build.Flags.Production {
g.File("./embedded/tracking-pixel/sendgrid/open.gif")
return
}
_, err = g.Writer.Write(embedded.TrackingPixel)
if err != nil {
c.Logger.Errorw("failed to write tracking pixel", "error", err)
}
}
// UpdateByID updates a campaign by its id
func (c *Campaign) UpdateByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
var req model.Campaign
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// update the campaign
err := c.CampaignService.UpdateByID(g.Request.Context(), session, id, &req)
if ok := c.handleErrors(g, err); !ok {
return
}
// handle responses
c.Response.OK(g, gin.H{})
}
// SetSentAtByCampaignRecipientID sets the sent at time for a campaign recipient
func (c *Campaign) SetSentAtByCampaignRecipientID(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// set sent at time
err := c.CampaignService.SetSentAtByCampaignRecipientID(g.Request.Context(), session, id)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// DeleteByID deletes a campaign by its id
func (c *Campaign) DeleteByID(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// delete
err := c.CampaignService.DeleteByID(g, session, id)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// AnonymizeByID anonymizes a campaign by its id
func (c *Campaign) AnonymizeByID(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// anonymize
err := c.CampaignService.AnonymizeByID(g, session, id)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// GetCampaignStats gets campaign statistics by campaign ID
func (c *Campaign) GetCampaignStats(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// get stats
stats, err := c.CampaignService.GetCampaignStats(g.Request.Context(), session, id)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, stats)
}
// GetAllCampaignStats gets all campaign statistics with pagination
func (c *Campaign) GetAllCampaignStats(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.RemapOrderBy(allowedCampaignColumns)
companyID := companyIDFromRequestQuery(g)
// get stats
stats, err := c.CampaignService.GetAllCampaignStats(g.Request.Context(), session, queryArgs, companyID)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, stats)
}
+199
View File
@@ -0,0 +1,199 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
)
// CampaignTemplateColumnsMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var CampaignTemplateColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "updated_at"),
"name": repository.TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "name"),
"after_landing_page_redirect_url": repository.TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "after_landing_page_redirect_url"),
"is_complete": repository.TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "is_usable"),
"domain": repository.TableColumn(database.DOMAIN_TABLE, "name"),
"before_landing_page": repository.TableColumn("before_landing_page", "name"),
"landing_page": repository.TableColumn("landing_page", "name"),
"after_landing_page": repository.TableColumn("after_landing_page", "name"),
"smtp": repository.TableColumn(database.SMTP_CONFIGURATION_TABLE, "name"),
"api_sender": repository.TableColumn(database.API_SENDER_TABLE, "name"),
"email": repository.TableColumn(database.EMAIL_TABLE, "name"),
}
// CampaignTemplate is a campaign template controller
type CampaignTemplate struct {
Common
CampaignTemplateService *service.CampaignTemplate
}
// Create creates a campaign template
func (c *CampaignTemplate) Create(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
var req model.CampaignTemplate
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// save
ctx := g.Request.Context()
id, err := c.CampaignTemplateService.Create(ctx, session, &req)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
gin.H{
"id": id.String(),
},
)
}
// GetByID gets a campaign template by id
func (c *CampaignTemplate) GetByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// check if full data set should be loaded
options := &repository.CampaignTemplateOption{}
_, ok = g.GetQuery("full")
if ok {
options = &repository.CampaignTemplateOption{
WithDomain: true,
WithSMTPConfiguration: true,
WithAPISender: true,
WithEmail: true,
WithLandingPage: true,
WithBeforeLandingPage: true,
WithAfterLandingPage: true,
WithIdentifier: true,
}
}
// get
ctx := g.Request.Context()
campaignTemplate, err := c.CampaignTemplateService.GetByID(
ctx,
session,
id,
options,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, campaignTemplate)
}
// GetAll gets all campaign templates
func (c *CampaignTemplate) GetAll(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
pagination, ok := c.handlePagination(g)
if !ok {
return
}
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
usableOnlyQuery := g.Query("usableOnly")
usableOnly := false
if usableOnlyQuery == "true" {
usableOnly = true
}
queryArgs.DefaultSortByUpdatedAt()
queryArgs.RemapOrderBy(CampaignTemplateColumnsMap)
columns := repository.SelectTable(database.CAMPAIGN_TEMPLATE_TABLE)
templates, err := c.CampaignTemplateService.GetAll(
g,
session,
companyID,
pagination,
&repository.CampaignTemplateOption{
QueryArgs: queryArgs,
Columns: columns,
WithDomain: true,
WithSMTPConfiguration: true,
WithAPISender: true,
WithEmail: true,
WithLandingPage: true,
WithBeforeLandingPage: true,
WithAfterLandingPage: true,
UsableOnly: usableOnly,
},
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, templates)
}
// UpdateByID updates a campaign template by id
func (c *CampaignTemplate) UpdateByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
var req model.CampaignTemplate
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// update
err := c.CampaignTemplateService.UpdateByID(
g.Request.Context(),
session,
id,
&req,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// DeleteByID deletes a campaign template by id
func (c *CampaignTemplate) DeleteByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// delete
err := c.CampaignTemplateService.DeleteByID(g, session, id)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
+594
View File
@@ -0,0 +1,594 @@
package controller
import (
"archive/zip"
"bytes"
"encoding/csv"
"fmt"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/api"
"github.com/phishingclub/phishingclub/cache"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/utils"
"github.com/phishingclub/phishingclub/vo"
)
// DomainColumnsMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var CompanyColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.COMPANY_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.COMPANY_TABLE, "updated_at"),
"name": repository.TableColumn(database.COMPANY_TABLE, "name"),
}
// Company is a Company controller
type Company struct {
Common
CompanyService *service.Company
CampaignService *service.Campaign
RecipientService *service.Recipient
}
// GetByID gets a company by id
func (c *Company) GetByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
companyID, err := uuid.Parse(g.Param("id"))
if err != nil {
// ignore err as caused by bad user input
_ = err
c.Response.BadRequestMessage(g, api.InvalidCompanyID)
return
}
// get company
ctx := g.Request.Context()
company, err := c.CompanyService.GetByID(
ctx,
session,
&companyID,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, company)
}
// ExportByCompanyID outputs a CSV with all events related to the recipient
func (c *Company) ExportByCompanyID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
companyID, ok := c.handleParseIDParam(g)
if !ok {
return
}
// get the company exported
company, err := c.CompanyService.GetByID(
g,
session,
companyID,
)
// create ZIP file in memory
zipBuffer := new(bytes.Buffer)
zipWriter := zip.NewWriter(zipBuffer)
zipFileName := fmt.Sprintf("company_export_%s.zip", company.Name.MustGet().String())
// add company data to zip
{
buffer := &bytes.Buffer{}
writer := csv.NewWriter(buffer)
headers := []string{
"Created at",
"Updated at",
"Name",
}
err = writer.Write(headers)
if ok := c.handleErrors(g, err); !ok {
return
}
row := []string{
utils.CSVFromDate(company.CreatedAt),
utils.CSVFromDate(company.UpdatedAt),
utils.CSVRemoveFormulaStart(utils.NullableToString(company.Name)),
}
err = writer.Write(row)
if ok := c.handleErrors(g, err); !ok {
return
}
writer.Flush()
// add to zip
f, err := zipWriter.Create("company.csv")
if ok := c.handleErrors(g, err); !ok {
return
}
_, err = f.Write(buffer.Bytes())
if ok := c.handleErrors(g, err); !ok {
return
}
}
// add recipients to zip
{
// get the recipients
recipients, err := c.RecipientService.GetByCompanyID(
g,
session,
companyID,
&repository.RecipientOption{
WithCompany: true,
WithGroups: true,
},
)
if ok := c.handleErrors(g, err); !ok {
return
}
// write a csv buffer with all recipient and their groups
buffer := &bytes.Buffer{}
writer := csv.NewWriter(buffer)
headers := []string{
"Created at",
"Updated at",
"Email",
"Phone",
"Extra Identifier",
"Name",
"Position",
"Department",
"City",
"Country",
"Misc",
}
// find the recipient with the most groups and add that number of
// extra headers for groups
maxGroups := 0
for _, recipient := range recipients.Rows {
groups, _ := recipient.Groups.Get()
if groupLen := len(groups); groupLen > maxGroups {
maxGroups = groupLen
}
}
for i := 1; i <= maxGroups; i++ {
headers = append(headers, fmt.Sprintf("Group %d", i))
}
err = writer.Write(headers)
if ok := c.handleErrors(g, err); !ok {
return
}
for _, recipient := range recipients.Rows {
groups, _ := recipient.Groups.Get()
row := []string{
utils.CSVFromDate(recipient.CreatedAt),
utils.CSVFromDate(recipient.UpdatedAt),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Email)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Phone)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.ExtraIdentifier)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.FirstName)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.LastName)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Position)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Department)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.City)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Country)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Misc)),
}
for _, group := range groups {
row = append(row, group.Name.MustGet().String())
}
err = writer.Write(row)
if ok := c.handleErrors(g, err); !ok {
return
}
writer.Flush()
}
// add to zip
f, err := zipWriter.Create("recipients.csv")
if ok := c.handleErrors(g, err); !ok {
return
}
_, err = f.Write(buffer.Bytes())
if ok := c.handleErrors(g, err); !ok {
return
}
}
// get all campaigns all recipient events
{
campaigns, err := c.CampaignService.GetByCompanyID(
g,
session,
companyID,
&repository.CampaignOption{},
)
for _, campaign := range campaigns.Rows {
headers := []string{
"Campaign",
"Created at",
"Recipient name",
"Recipient email",
"Event name",
"Event Details",
"User-Agent",
"IP",
}
buffer := &bytes.Buffer{}
writer := csv.NewWriter(buffer)
err = writer.Write(headers)
if ok := c.handleErrors(g, err); !ok {
return
}
queryArgs := vo.QueryArgs{}
queryArgs.OrderBy = repository.TableColumn(
database.CAMPAIGN_EVENT_TABLE,
"created_at",
)
sortOrder := g.DefaultQuery("sortOrder", "desc")
if sortOrder == "desc" {
queryArgs.Desc = true
}
// get all rows
queryArgs.Limit = 0
queryArgs.Offset = 0
// get events by campaign id
cid := campaign.ID.MustGet()
events, err := c.CampaignService.GetEventsByCampaignID(
g.Request.Context(),
session,
&cid,
&queryArgs,
nil,
nil,
)
if ok := c.handleErrors(g, err); !ok {
return
}
for _, event := range events.Rows {
firstName := "anonymized"
lastName := "anonymized"
recpEmail := "anonymized"
if event.Recipient != nil {
firstName = event.Recipient.FirstName.MustGet().String()
lastName = event.Recipient.LastName.MustGet().String()
recpEmail = event.Recipient.Email.MustGet().String()
}
row := []string{
utils.CSVRemoveFormulaStart(campaign.Name.MustGet().String()),
utils.CSVFromDate(event.CreatedAt),
utils.CSVRemoveFormulaStart(firstName),
utils.CSVRemoveFormulaStart(lastName),
utils.CSVRemoveFormulaStart(recpEmail),
utils.CSVRemoveFormulaStart(cache.EventNameByID[event.EventID.String()]),
utils.CSVRemoveFormulaStart(event.Data.String()),
utils.CSVRemoveFormulaStart(event.UserAgent.String()),
utils.CSVRemoveFormulaStart(event.IP.String()),
}
err = writer.Write(row)
if ok := c.handleErrors(g, err); !ok {
return
}
}
// add a new subdirectory wit the event file in the zip
writer.Flush()
// add to zip
filename := fmt.Sprintf("campaign_events/%s.csv", campaign.Name.MustGet().String())
f, err := zipWriter.Create(filename)
if ok := c.handleErrors(g, err); !ok {
return
}
_, err = f.Write(buffer.Bytes())
if ok := c.handleErrors(g, err); !ok {
return
}
}
}
// close zip
err = zipWriter.Close()
if ok := c.handleErrors(g, err); !ok {
return
}
c.responseWithZIP(g, zipBuffer, zipFileName)
}
// ExportShared outputs a CSV with all shared recipients and events
func (c *Company) ExportShared(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// create ZIP file in memory
zipBuffer := new(bytes.Buffer)
zipWriter := zip.NewWriter(zipBuffer)
zipFileName := "shared_export_%s.zip"
// add recipients to zip
{
// get the recipients
recipients, err := c.RecipientService.GetByCompanyID(
g,
session,
nil,
&repository.RecipientOption{
WithCompany: true,
WithGroups: true,
},
)
if ok := c.handleErrors(g, err); !ok {
return
}
// write a csv buffer with all recipient and their groups
buffer := &bytes.Buffer{}
writer := csv.NewWriter(buffer)
headers := []string{
"Created at",
"Updated at",
"Email",
"Phone",
"Extra Identifier",
"Name",
"Position",
"Department",
"City",
"Country",
"Misc",
}
// find the recipient with the most groups and add that number of
// extra headers for groups
maxGroups := 0
for _, recipient := range recipients.Rows {
groups, _ := recipient.Groups.Get()
if groupLen := len(groups); groupLen > maxGroups {
maxGroups = groupLen
}
}
for i := 1; i <= maxGroups; i++ {
headers = append(headers, fmt.Sprintf("Group %d", i))
}
err = writer.Write(headers)
if ok := c.handleErrors(g, err); !ok {
return
}
for _, recipient := range recipients.Rows {
groups, _ := recipient.Groups.Get()
row := []string{
utils.CSVFromDate(recipient.CreatedAt),
utils.CSVFromDate(recipient.UpdatedAt),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Email)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Phone)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.ExtraIdentifier)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.FirstName)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.LastName)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Position)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Department)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.City)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Country)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Misc)),
}
for _, group := range groups {
row = append(row, group.Name.MustGet().String())
}
err = writer.Write(row)
if ok := c.handleErrors(g, err); !ok {
return
}
writer.Flush()
}
// add to zip
f, err := zipWriter.Create("recipients.csv")
if ok := c.handleErrors(g, err); !ok {
return
}
_, err = f.Write(buffer.Bytes())
if ok := c.handleErrors(g, err); !ok {
return
}
}
// get all campaigns all recipient events
{
campaigns, err := c.CampaignService.GetByCompanyID(
g,
session,
nil,
&repository.CampaignOption{},
)
for _, campaign := range campaigns.Rows {
headers := []string{
"Campaign",
"Created at",
"Recipient name",
"Recipient email",
"Event name",
"Event Details",
"User-Agent",
"IP",
}
buffer := &bytes.Buffer{}
writer := csv.NewWriter(buffer)
err = writer.Write(headers)
if ok := c.handleErrors(g, err); !ok {
return
}
queryArgs := vo.QueryArgs{}
queryArgs.OrderBy = repository.TableColumn(
database.CAMPAIGN_EVENT_TABLE,
"created_at",
)
sortOrder := g.DefaultQuery("sortOrder", "desc")
if sortOrder == "desc" {
queryArgs.Desc = true
}
// get all rows
queryArgs.Limit = 0
queryArgs.Offset = 0
// get events by campaign id
cid := campaign.ID.MustGet()
events, err := c.CampaignService.GetEventsByCampaignID(
g.Request.Context(),
session,
&cid,
&queryArgs,
nil,
nil,
)
if ok := c.handleErrors(g, err); !ok {
return
}
for _, event := range events.Rows {
firstName := "anonymized"
lastName := "anonymized"
recpEmail := "anonymized"
if event.Recipient != nil {
firstName = event.Recipient.FirstName.MustGet().String()
lastName = event.Recipient.LastName.MustGet().String()
recpEmail = event.Recipient.Email.MustGet().String()
}
row := []string{
utils.CSVRemoveFormulaStart(campaign.Name.MustGet().String()),
utils.CSVFromDate(event.CreatedAt),
utils.CSVRemoveFormulaStart(firstName),
utils.CSVRemoveFormulaStart(lastName),
utils.CSVRemoveFormulaStart(recpEmail),
utils.CSVRemoveFormulaStart(cache.EventNameByID[event.EventID.String()]),
utils.CSVRemoveFormulaStart(event.Data.String()),
utils.CSVRemoveFormulaStart(event.UserAgent.String()),
utils.CSVRemoveFormulaStart(event.IP.String()),
}
err = writer.Write(row)
if ok := c.handleErrors(g, err); !ok {
return
}
}
// add a new subdirectory wit the event file in the zip
writer.Flush()
// add to zip
filename := fmt.Sprintf("campaign_events/%s.csv", campaign.Name.MustGet().String())
f, err := zipWriter.Create(filename)
if ok := c.handleErrors(g, err); !ok {
return
}
_, err = f.Write(buffer.Bytes())
if ok := c.handleErrors(g, err); !ok {
return
}
}
}
// close zip
err := zipWriter.Close()
if ok := c.handleErrors(g, err); !ok {
return
}
c.responseWithZIP(g, zipBuffer, zipFileName)
}
// ChangeName changes a company name
func (c *Company) ChangeName(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
var req model.Company
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// change company name
err := c.CompanyService.UpdateByID(
g,
session,
id,
&req,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, nil)
}
// SoftDelete soft deletes a company
func (c *Company) DeleteByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// TODO company delete should FAIL if it has any relations to anything
// delete company
_, err := c.CompanyService.DeleteByID(g, session, id)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// Create creates a company
func (c *Company) Create(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse req
var req model.Company
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// save company
ctx := g.Request.Context()
company, err := c.CompanyService.Create(
ctx,
session,
&req,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{
"id": company.ID,
})
}
// GetAll gets all companies with pagination
func (c *Company) GetAll(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
queryArgs.RemapOrderBy(CompanyColumnsMap)
// get companies
ctx := g.Request.Context()
companies, err := c.CompanyService.GetAll(
ctx,
session,
queryArgs,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, companies)
}
+219
View File
@@ -0,0 +1,219 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/vo"
)
// DomainColumnsMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var DomainColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.DOMAIN_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.DOMAIN_TABLE, "updated_at"),
"hosting_website": repository.TableColumn(database.DOMAIN_TABLE, "host_website"),
"redirects": repository.TableColumn(database.DOMAIN_TABLE, "redirect_url"),
}
// Domain
type Domain struct {
Common
DomainService *service.Domain
}
// Create creates a domain
func (d *Domain) Create(g *gin.Context) {
session, _, ok := d.handleSession(g)
if !ok {
return
}
// parse request
var req model.Domain
if ok := d.handleParseRequest(g, &req); !ok {
return
}
// save domain
id, err := d.DomainService.Create(g, session, &req)
// handle response
if ok := d.handleErrors(g, err); !ok {
return
}
d.Response.OK(
g,
gin.H{
"id": id,
},
)
}
// GetAll gets domains
func (d *Domain) GetAll(g *gin.Context) {
// handle session
session, _, ok := d.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := d.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
queryArgs.RemapOrderBy(DomainColumnsMap)
// get domain
domains, err := d.DomainService.GetAll(
companyID,
g.Request.Context(),
session,
queryArgs,
true, // TODO there might not be any reason to retrieve the full relation here - optimize by removing it (false)
)
if ok := d.handleErrors(g, err); !ok {
return
}
d.Response.OK(g, domains)
}
// GetAllOverview gets domains with limited data
func (d *Domain) GetAllOverview(g *gin.Context) {
// handle session
session, _, ok := d.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := d.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
queryArgs.RemapOrderBy(DomainColumnsMap)
// get domains
domains, err := d.DomainService.GetAllOverview(
companyID,
g.Request.Context(),
session,
queryArgs,
)
if ok := d.handleErrors(g, err); !ok {
return
}
d.Response.OK(g, domains)
}
// GetByID gets a domain by id
func (d *Domain) GetByID(g *gin.Context) {
// handle session
session, _, ok := d.handleSession(g)
if !ok {
return
}
// parse request
id, ok := d.handleParseIDParam(g)
if !ok {
return
}
// get domain
ctx := g.Request.Context()
domain, err := d.DomainService.GetByID(
ctx,
session,
id,
&repository.DomainOption{
WithCompany: true,
},
)
if ok := d.handleErrors(g, err); !ok {
return
}
d.Response.OK(g, domain)
}
// GetByName gets a domain by name
func (d *Domain) GetByName(g *gin.Context) {
// handle session
session, _, ok := d.handleSession(g)
if !ok {
return
}
// parse request
name, err := vo.NewString255(g.Param("domain"))
if ok := d.handleErrors(g, err); !ok {
return
}
// get domain
ctx := g.Request.Context()
domain, err := d.DomainService.GetByName(
ctx,
session,
name,
&repository.DomainOption{},
)
if ok := d.handleErrors(g, err); !ok {
return
}
d.Response.OK(g, domain)
}
// UpdateByID updates a domain by id
func (d *Domain) UpdateByID(g *gin.Context) {
// handle session
session, _, ok := d.handleSession(g)
if !ok {
return
}
// parse request
id, ok := d.handleParseIDParam(g)
if !ok {
return
}
var req model.Domain
if ok := d.handleParseRequest(g, &req); !ok {
return
}
// update domain
err := d.DomainService.UpdateByID(
g,
session,
id,
&req,
)
// handle response
if ok := d.handleErrors(g, err); !ok {
return
}
d.Response.OK(g, gin.H{})
}
// DeleteByID deletes a domain by id
func (d *Domain) DeleteByID(g *gin.Context) {
// handle session
session, _, ok := d.handleSession(g)
if !ok {
return
}
// parse request
id, ok := d.handleParseIDParam(g)
if !ok {
return
}
// delete domain
err := d.DomainService.DeleteByID(
g,
session,
id,
)
// handle response
if ok := d.handleErrors(g, err); !ok {
return
}
d.Response.OK(g, gin.H{})
}
+375
View File
@@ -0,0 +1,375 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/vo"
)
// EmailOrderByMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var EmailOrderByMap = map[string]string{
"created_at": repository.TableColumn(database.EMAIL_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.EMAIL_TABLE, "created_at"),
"name": repository.TableColumn(database.EMAIL_TABLE, "name"),
"mail_from": repository.TableColumn(database.EMAIL_TABLE, "mail_from"),
"from": repository.TableColumn(database.EMAIL_TABLE, "from"),
"subject": repository.TableColumn(database.EMAIL_TABLE, "subject"),
"tracking_pixel": repository.TableColumn(database.EMAIL_TABLE, "add_tracking_pixel"),
}
// AddAttachmentsToEmailRequest is a request to add attachments to a message
type AddAttachmentsToEmailRequest struct {
Attachments []string `json:"ids"` // attachment IDs
}
// RemoveAttachmentFromEmailRequest is a request to remove an attachment from a message
type RemoveAttachmentFromEmailRequest struct {
AttachmentID string `json:"attachmentID"`
}
// SendTestEmailRequest is a request for sending a test of an e-mail
type SendTestEmailRequest struct {
SMTPID *uuid.UUID
DomainID *uuid.UUID
RecipientID *uuid.UUID
}
// Email is a Email controller
type Email struct {
Common
EmailService *service.Email
TemplateService *service.Template
EmailRepository *repository.Email
}
// AddAttachments adds attachments to a email
func (m *Email) AddAttachments(g *gin.Context) {
session, _, ok := m.handleSession(g)
if !ok {
return
}
// parse request
var request AddAttachmentsToEmailRequest
if ok := m.handleParseRequest(g, &request); !ok {
return
}
id, ok := m.handleParseIDParam(g)
if !ok {
return
}
if len(request.Attachments) == 0 {
m.Response.BadRequestMessage(g, "No attachments provided")
return
}
attachmentIDs := []*uuid.UUID{}
for _, idParam := range request.Attachments {
id, err := uuid.Parse(idParam)
if err != nil {
m.Logger.Debugw(errs.MsgFailedToParseUUID,
"error", err,
)
m.Response.BadRequestMessage(g, "Invalid attachment ID")
return
}
attachmentIDs = append(attachmentIDs, &id)
}
// add attachments to email
err := m.EmailService.AddAttachments(
g.Request.Context(),
session,
id,
attachmentIDs,
)
// handle responses
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, gin.H{})
}
// RemoveAttachment removes an attachment from a email
func (m *Email) RemoveAttachment(g *gin.Context) {
// handle session
session, _, ok := m.handleSession(g)
if !ok {
return
}
// parse req
var req RemoveAttachmentFromEmailRequest
if ok := m.handleParseRequest(g, &req); !ok {
return
}
attachmentID, err := uuid.Parse(req.AttachmentID)
if err != nil {
m.Logger.Debugw(errs.MsgFailedToParseUUID,
"error", err,
)
m.Response.BadRequestMessage(g, "Invalid attachment ID")
return
}
emailID, err := uuid.Parse(g.Param("id"))
if err != nil {
m.Logger.Debugw(errs.MsgFailedToParseUUID, "error", err)
m.Response.BadRequestMessage(g, "Invalid message ID")
return
}
// remove attachment from email
err = m.EmailService.RemoveAttachment(
g.Request.Context(),
session,
&emailID,
&attachmentID,
)
// handle responses
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, gin.H{})
}
// Create creates a email
func (m *Email) Create(g *gin.Context) {
session, _, ok := m.handleSession(g)
if !ok {
return
}
// parse req
var req model.Email
if ok := m.handleParseRequest(g, &req); !ok {
return
}
// save email
id, err := m.EmailService.Create(
g,
session,
&req,
)
// handle responses
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, gin.H{
"id": id,
})
}
// SendTestEmail
func (m *Email) SendTestEmail(g *gin.Context) {
session, _, ok := m.handleSession(g)
if !ok {
return
}
// parse request
id, ok := m.handleParseIDParam(g)
if !ok {
return
}
var req SendTestEmailRequest
if ok := m.handleParseRequest(g, &req); !ok {
return
}
// send test email
err := m.EmailService.SendTestEmail(
g,
session,
id,
req.SMTPID,
req.DomainID,
req.RecipientID,
)
// handle responses
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, gin.H{})
}
// GetByID gets a email by ID
func (m *Email) GetByID(g *gin.Context) {
session, _, ok := m.handleSession(g)
if !ok {
return
}
// parse request
id, ok := m.handleParseIDParam(g)
if !ok {
return
}
// get email
email, err := m.EmailService.GetByID(
g.Request.Context(),
session,
id,
)
// handle responses
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, email)
}
// GetContentByID gets a email content by ID
func (m *Email) GetContentByID(g *gin.Context) {
session, _, ok := m.handleSession(g)
if !ok {
return
}
// parse request
id, ok := m.handleParseIDParam(g)
if !ok {
return
}
// get
email, err := m.EmailService.GetByID(
g.Request.Context(),
session,
id,
)
if ok := m.handleErrors(g, err); !ok {
return
}
// build email
domain := &model.Domain{
Name: nullable.NewNullableWithValue(
*vo.NewString255Must("example.test"),
),
}
recipient := model.NewRecipientExample()
campaignRecipient := model.CampaignRecipient{
ID: nullable.NewNullableWithValue(
uuid.New(),
),
Recipient: recipient,
}
apiSender := model.NewAPISenderExample()
emailBody, err := m.TemplateService.CreateMailBody(
"id",
"/foo",
domain,
&campaignRecipient,
email,
apiSender,
)
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, emailBody)
}
// GetAll gets all emails using pagination
func (m *Email) GetAll(g *gin.Context) {
// handle session
session, _, ok := m.handleSession(g)
if !ok {
return
}
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := m.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByName()
queryArgs.RemapOrderBy(EmailOrderByMap)
emails, err := m.EmailService.GetAll(
g.Request.Context(),
session,
companyID,
queryArgs,
)
// handle responses
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, emails)
}
// GetOverviews gets all email overviews using pagination
func (m *Email) GetOverviews(g *gin.Context) {
// handle session
session, _, ok := m.handleSession(g)
if !ok {
return
}
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := m.handleQueryArgs(g)
if !ok {
return
}
queryArgs.RemapOrderBy(EmailOrderByMap)
queryArgs.DefaultSortByName()
emails, err := m.EmailService.GetOverviews(
g.Request.Context(),
session,
companyID,
queryArgs,
)
// handle responses
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, emails)
}
// UpdateByID updates a message by ID
func (m *Email) UpdateByID(g *gin.Context) {
session, _, ok := m.handleSession(g)
if !ok {
return
}
// parse request
id, ok := m.handleParseIDParam(g)
if !ok {
return
}
var email model.Email
if ok := m.handleParseRequest(g, &email); !ok {
return
}
// update message
err := m.EmailService.UpdateByID(
g.Request.Context(),
session,
id,
&email,
)
// handle response
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, gin.H{})
}
// DeleteByID deletes a message by ID
func (m *Email) DeleteByID(g *gin.Context) {
session, _, ok := m.handleSession(g)
if !ok {
return
}
// parse request
id, ok := m.handleParseIDParam(g)
if !ok {
return
}
// delete message
err := m.EmailService.DeleteByID(
g.Request.Context(),
session,
id,
)
// handle response
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, gin.H{})
}
+15
View File
@@ -0,0 +1,15 @@
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
)
// Health is the Health controller
type Health struct{}
// Health returns a 200 OK
func (c *Health) Health(g *gin.Context) {
g.Status(http.StatusOK)
}
+51
View File
@@ -0,0 +1,51 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
)
// IdentifierColumnsMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var IdentifierColumnsMap = map[string]string{
"name": repository.TableColumn(database.IDENTIFIER_TABLE, "name"),
}
type Identifier struct {
Common
IdentifierService *service.Identifier
}
// GetAll gets all identifiers
func (c *Identifier) GetAll(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByName()
// get
identifiers, err := c.IdentifierService.GetAll(
g,
session,
&repository.IdentifierOption{
QueryArgs: queryArgs,
},
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
identifiers,
)
}
+47
View File
@@ -0,0 +1,47 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/service"
)
// Import handles import for templates like emails, landing pages and so on
type Import struct {
Common
ImportService *service.Import
}
// Import imports a .zip file
func (im *Import) Import(g *gin.Context) {
session, _, ok := im.handleSession(g)
if !ok {
return
}
// parse request
f, err := g.FormFile("file")
// handle responses
if ok := im.handleErrors(g, err); !ok {
return
}
// Read forCompany flag from form (treat "1" or "true" as true)
forCompany := false
if v := g.PostForm("forCompany"); v == "1" || v == "true" {
forCompany = true
}
// Read companyID from form data if provided
var companyID *uuid.UUID
if companyIDStr := g.PostForm("companyID"); companyIDStr != "" {
if cid, err := uuid.Parse(companyIDStr); err == nil {
companyID = &cid
}
}
summary, err := im.ImportService.Import(g, session, f, forCompany, companyID)
if ok := im.handleErrors(g, err); !ok {
return
}
im.Response.OK(g, summary)
}
+329
View File
@@ -0,0 +1,329 @@
package controller
import (
"fmt"
"github.com/go-errors/errors"
"github.com/google/uuid"
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/cli"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/password"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/vo"
"golang.org/x/net/context"
"gorm.io/gorm"
)
// SetupAdminRequest is the request for the install action
type SetupAdminRequest struct {
Username string `json:"username" binding:"required"`
UserFullname string `json:"userFullname" binding:"required"`
NewPassword string `json:"newPassword" binding:"required"`
}
// InitialSetup is a controller used by the CLI in the
// initial setup process - it is not an API controller
type InitialSetup struct {
Common
CLIOutputter cli.Outputter
OptionRepository *repository.Option
InstallService *service.InstallSetup
OptionService *service.Option
}
// IsInstalled checks if the application is installed
// not as a
func (is *InitialSetup) IsInstalled(ctx context.Context) (bool, error) {
isInstalledOption, err := is.OptionRepository.GetByKey(
ctx,
data.OptionKeyIsInstalled,
)
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("could not get '%s' option: %w", data.OptionKeyIsInstalled, err)
}
return isInstalledOption.Value.String() == data.OptionValueIsInstalled, nil
}
// HandleInitialSetup handles the initial setup of the application
// this includes inserting the isInstalled option to not installed
// and making or updating the sacrificial admin account
func (is *InitialSetup) HandleInitialSetup(ctx context.Context) error {
// setup option for is installed
isInstalledOption, err := is.OptionRepository.GetByKey(
ctx,
data.OptionKeyIsInstalled,
)
// if the option does not exist, create it
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("%w: could not get '%s' option", err, data.OptionKeyIsInstalled)
}
key := vo.NewString64Must(data.OptionKeyIsInstalled)
value := vo.NewOptionalString1MBMust(data.OptionValueIsNotInstalled)
isInstalledOptionWithoutID := model.Option{
Key: *key,
Value: *value,
}
_, err = is.OptionRepository.Insert(
ctx,
&isInstalledOptionWithoutID,
)
if err != nil {
return fmt.Errorf("%w: could not insert entity for option '%s'", err, data.OptionKeyIsInstalled)
}
isInstalledOption, err = is.OptionRepository.GetByKey(
ctx,
isInstalledOptionWithoutID.Key.String(),
)
if err != nil {
return fmt.Errorf("%w: could not get created '%s' option", err, data.OptionKeyIsInstalled)
}
}
// if no instance ID exists, add it
instanceIDOption, err := is.OptionRepository.GetByKey(
ctx,
data.OptionKeyInstanceID,
)
// if the instance id option does not exist, create it
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("%w: could not get '%s' option", err, data.OptionKeyInstanceID)
}
key := vo.NewString64Must(data.OptionKeyInstanceID)
instanceID := uuid.New()
value := vo.NewOptionalString1MBMust(instanceID.String())
instanceIDOption = &model.Option{
Key: *key,
Value: *value,
}
_, err = is.OptionRepository.Insert(
ctx,
instanceIDOption,
)
if err != nil {
return fmt.Errorf("could not insert instance ID: %w", err)
}
}
// if installation is already complete, return error
if isInstalledOption.Value.String() == data.OptionValueIsInstalled {
return errs.ErrAlreadyInstalled
}
// setup accounts
admin, password, err := is.InstallService.SetupAccounts(ctx)
if err != nil {
return fmt.Errorf("could not setup initial admin account: %w", err)
}
is.CLIOutputter.PrintInitialAdminAccount(
admin.Username.MustGet().String(),
password.String(),
)
return nil
}
// Install is the Install controller used by the API
type Install struct {
Common
UserRepository *repository.User
CompanyRepository *repository.Company
OptionRepository *repository.Option
DB *gorm.DB
PasswordHasher password.Argon2Hasher
}
// Install completes the installation by setting the initial administrators and options
func (in *Install) Install(g *gin.Context) {
tx := in.DB.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
ok := in.install(g, tx)
if !ok {
if tx.Rollback().Error != nil {
in.Logger.Errorw("failed to install - could not rollback transaction",
"error", tx.Rollback().Error,
)
}
return
}
result := tx.Commit()
if result.Error != nil {
in.Logger.Errorw("failed to install - could not commit transaction",
"error", result.Error,
)
in.Response.ServerError(g)
return
}
// the admin user changed username and password
// however as the install process is a special case, we wont
// require re-authentication
in.Response.OK(g, gin.H{})
}
// Install completes the installation by setting the initial administrators
// username, password, email, name and company name
func (in *Install) install(g *gin.Context, tx *gorm.DB) bool {
// handle session
_, user, ok := in.handleSession(g)
if !ok {
return false
}
role := user.Role
if role == nil {
in.Logger.Error("failed to install - session contain no role")
in.Response.ServerError(g)
return false
}
if !role.IsSuperAdministrator() {
in.Logger.Info("failed to install - not super admin")
// TODO add audit log
in.Response.Forbidden(g)
return false
}
// defer rollback or commit tx
var request SetupAdminRequest
if err := g.ShouldBindJSON(&request); err != nil {
in.Logger.Debugw("failed to parse request",
"error", err,
)
in.Response.BadRequest(g)
return false
}
ctx := g.Request.Context()
// check if already installed
isInstalled, err := in.OptionRepository.GetByKey(ctx, data.OptionKeyIsInstalled)
if err != nil {
in.Logger.Errorw("failed to install - could not get option",
"optionKey", data.OptionKeyIsInstalled,
"error", err,
)
in.Response.ServerError(g)
return false
}
if isInstalled.Value.String() == data.OptionValueIsInstalled {
in.Logger.Info("failed to install - already installed")
in.Response.ServerErrorMessage(
g,
"Installation is already complete",
)
return false
}
// update the username
newUsername, err := vo.NewUsername(request.Username)
if err != nil {
in.Logger.Infow("failed to install - invalid username",
"username", request.Username,
"error", err,
)
in.Response.ValidationFailed(g, "Username", err)
return false
}
if newUsername.String() == user.Username.MustGet().String() {
in.Logger.Infow("failed to install - new username is the same as the current",
"username", newUsername.String(),
"error", err,
)
in.Response.BadRequestMessage(
g,
"Username may not be the same as the current",
)
return false
}
userID := user.ID.MustGet()
err = in.UserRepository.UpdateUsernameByIDWithTransaction(
ctx,
tx,
&userID,
newUsername,
)
if err != nil {
in.Logger.Infow("failed to install - could not update username",
"username", newUsername.String(),
"error", err,
)
in.Response.ServerError(g)
return false
}
// update the password
newPassword, err := vo.NewReasonableLengthPassword(request.NewPassword)
if err != nil {
in.Logger.Infow("failed to install - invalid password",
"error", err,
)
in.Response.BadRequestMessage(g, "invalid password")
return false
}
hash, err := in.PasswordHasher.Hash(newPassword.String())
if err != nil {
in.Logger.Errorw("failed to install - could not hash password",
"error", err,
)
in.Response.ServerError(g)
return false
}
err = in.UserRepository.UpdatePasswordHashByIDWithTransaction(
ctx,
tx,
&userID,
hash,
)
if err != nil {
in.Logger.Errorw("failed to install - could not update password",
"error", err,
)
in.Response.ServerError(g)
return false
}
// update the name
newName, err := vo.NewUserFullname(request.UserFullname)
if err != nil {
in.Logger.Infow("failed to install - invalid name",
"error", err,
)
in.Response.ValidationFailed(g, "Name", err)
return false
}
err = in.UserRepository.UpdateFullNameByIDWithTransaction(
ctx,
tx,
&userID,
newName,
)
if err != nil {
in.Logger.Infow("failed to install - could not update name",
"error", err,
)
in.Response.ServerError(g)
return false
}
// update installed option to installed
option := model.Option{
Key: *vo.NewString64Must(data.OptionKeyIsInstalled),
Value: *vo.NewOptionalString1MBMust(data.OptionValueIsInstalled),
}
err = in.OptionRepository.UpdateByKeyWithTransaction(
ctx,
tx,
&option,
)
if err != nil {
in.Logger.Errorw("failed to install - could not create install option",
"error", err,
)
in.Response.ServerErrorMessage(g, "failed to create install option")
return false
}
return true
}
+214
View File
@@ -0,0 +1,214 @@
package controller
import (
"context"
"time"
"github.com/go-errors/errors"
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/vo"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type SetLevelRequest struct {
Level string `json:"level"`
DBLevel string `json:"dbLevel"`
}
type Log struct {
Common
OptionService *service.Option
Database *gorm.DB
LoggerAtom *zap.AtomicLevel
}
// Panic is a test utility
func (c *Log) Panic(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
if session == nil {
if ok := c.handleErrors(g, errors.New("no session")); !ok {
return
}
}
c.Deeper()
}
func (c *Log) Deeper() {
panic("panic test")
}
// Slow is a test utility
func (c *Log) Slow(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
if session == nil {
if ok := c.handleErrors(g, errors.New("no session")); !ok {
return
}
}
c.Logger.Debugf("Slow request testing start")
time.Sleep(10 * time.Second)
c.Logger.Debugf("Slow request testing stop")
c.Response.OK(g, gin.H{})
}
// GetLevel gets the log level
func (c *Log) GetLevel(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// get the log levels
logLevelOption, err := c.OptionService.GetOption(
g,
session,
data.OptionKeyLogLevel,
)
// handle errors
if ok := c.handleErrors(g, err); !ok {
return
}
dbLogLevelOption, err := c.OptionService.GetOption(g, session, data.OptionKeyDBLogLevel)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{
"level": logLevelOption.Value,
"dbLevel": dbLogLevelOption.Value,
})
}
// SetLevel sets the log level
func (c *Log) SetLevel(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
var request SetLevelRequest
if ok := c.handleParseRequest(g, &request); !ok {
return
}
if request.Level == "" && request.DBLevel == "" {
c.Response.BadRequestMessage(g, "level or dbLevel is required")
return
}
if request.DBLevel != "" {
switch request.DBLevel {
case "silent":
c.Database.Logger = c.Database.Logger.LogMode(logger.Silent)
case "info":
c.Database.Logger = c.Database.Logger.LogMode(logger.Info)
case "warn":
c.Database.Logger = c.Database.Logger.LogMode(logger.Warn)
case "error":
c.Database.Logger = c.Database.Logger.LogMode(logger.Error)
default:
c.Logger.Debugw("invalid db log level",
"level", request.DBLevel,
)
c.Response.BadRequestMessage(g, "unknown DB log level")
return
}
// set db log level in database
dbLevel := vo.NewOptionalString1MBMust(request.DBLevel)
dbLogLevelOption := model.Option{
Key: *vo.NewString64Must(data.OptionKeyDBLogLevel),
Value: *dbLevel,
}
err := c.persist(
g,
session,
&dbLogLevelOption,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
}
if request.Level != "" {
switch request.Level {
case "debug":
c.LoggerAtom.SetLevel(zap.DebugLevel)
case "info":
c.LoggerAtom.SetLevel(zap.InfoLevel)
case "warn":
c.LoggerAtom.SetLevel(zap.WarnLevel)
case "error":
c.LoggerAtom.SetLevel(zap.ErrorLevel)
default:
c.Logger.Debugw("invalid log level",
"level", request.Level,
)
c.Response.BadRequestMessage(g, "Unknown log level")
return
}
// set log level in in memory logger struct
logLevel := model.Option{
Key: *vo.NewString64Must(data.OptionKeyLogLevel),
Value: *vo.NewOptionalString1MBMust(request.Level),
}
err := c.persist(
g,
session,
&logLevel,
)
if ok := c.handleErrors(g, err); !ok {
return
}
}
c.Response.OK(g, nil)
}
// TestLog tests the log
// Sends a log message for each log level debug, info, warn, error
func (c *Log) TestLog(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// check permissions
isAuthorized, err := service.IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
handleServerError(g, c.Response, err)
return
}
if !isAuthorized {
// TODO audit log
c.Response.Unauthorized(g)
return
}
c.Logger.Debug("Log: DEBUG Test")
c.Logger.Info("Log: INFO Test")
c.Logger.Warn("Log: WARN Test")
c.Logger.Error("Log: ERROR Test")
c.Response.OK(g, nil)
}
// persit saves the log level
// TODO this has become empty and superflous
func (c *Log) persist(
ctx context.Context,
session *model.Session,
logLevel *model.Option,
) error {
return c.OptionService.SetOptionByKey(
ctx,
session,
logLevel,
)
}
+67
View File
@@ -0,0 +1,67 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/service"
)
// Option is a Option controller
type Option struct {
Common
OptionService *service.Option
}
// Get a update option
func (c *Option) Get(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
key := g.Param("key")
if key == "" {
c.Response.BadRequestMessage(g, "option is required")
return
}
ctx := g.Request.Context()
option, err := c.OptionService.GetOption(
ctx,
session,
key,
)
if ok := handleServerError(g, c.Response, err); !ok {
return
}
if key == data.OptionKeyAdminSSOLogin {
option, err = c.OptionService.MaskSSOSecret(option)
if ok := handleServerError(g, c.Response, err); !ok {
return
}
}
c.Response.OK(g, option)
}
// Update sets a option
func (c *Option) Update(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse req
var req model.Option
if ok := c.handleParseRequest(g, &req); !ok {
return
}
err := c.OptionService.SetOptionByKey(g, session, &req)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
gin.H{},
)
}
+230
View File
@@ -0,0 +1,230 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
)
// PageColumnsMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var PageColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.PAGE_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.PAGE_TABLE, "updated_at"),
"name": repository.TableColumn(database.PAGE_TABLE, "name"),
}
// Page is a Page controller
type Page struct {
Common
PageService *service.Page
TemplateService *service.Template
}
// Create creates a page
func (p *Page) Create(g *gin.Context) {
// handle session
session, _, ok := p.handleSession(g)
if !ok {
return
}
// parse req
var req model.Page
if ok := p.handleParseRequest(g, &req); !ok {
return
}
// save page
id, err := p.PageService.Create(
g.Request.Context(),
session,
&req,
)
// handle response
if ok := p.handleErrors(g, err); !ok {
return
}
p.Response.OK(
g,
gin.H{
"id": id.String(),
},
)
}
// GetContentByID serves a page by id
func (p *Page) GetContentByID(g *gin.Context) {
session, _, ok := p.handleSession(g)
if !ok {
return
}
// parse request
id, ok := p.handleParseIDParam(g)
if !ok {
return
}
// get page
page, err := p.PageService.GetByID(
g,
session,
id,
&repository.PageOption{},
)
// handle response
if ok := p.handleErrors(g, err); !ok {
return
}
content, err := page.Content.Get()
if ok := p.handleErrors(g, err); !ok {
return
}
// build response
phishingPage, err := p.TemplateService.ApplyPageMock(content.String())
if ok := p.handleErrors(g, err); !ok {
return
}
p.Response.OK(g, phishingPage.String())
}
// GetAll gets pages using pagination
func (p *Page) GetAll(g *gin.Context) {
session, _, ok := p.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := p.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
companyID := companyIDFromRequestQuery(g)
// get pages
pages, err := p.PageService.GetAll(
g,
session,
companyID,
&repository.PageOption{
QueryArgs: queryArgs,
},
)
// handle response
if ok := p.handleErrors(g, err); !ok {
return
}
p.Response.OK(g, pages)
}
// GetOverview gets pages overview using pagination
func (p *Page) GetOverview(g *gin.Context) {
session, _, ok := p.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := p.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
companyID := companyIDFromRequestQuery(g)
// get pages
pages, err := p.PageService.GetAll(
g,
session,
companyID,
&repository.PageOption{
Fields: []string{"id", "created_at", "updated_at", "name", "company_id"},
QueryArgs: queryArgs,
},
)
// handle response
if ok := p.handleErrors(g, err); !ok {
return
}
p.Response.OK(g, pages)
}
// GetByID gets a page by id
func (p *Page) GetByID(g *gin.Context) {
session, _, ok := p.handleSession(g)
if !ok {
return
}
// parse request
id, ok := p.handleParseIDParam(g)
if !ok {
return
}
// get page
page, err := p.PageService.GetByID(
g.Request.Context(),
session,
id,
// do I really need to preload this?
&repository.PageOption{
WithCompany: true,
},
)
// handle response
if ok := p.handleErrors(g, err); !ok {
return
}
p.Response.OK(g, page)
}
// UpdateByID updates a page by id
func (p *Page) UpdateByID(g *gin.Context) {
session, _, ok := p.handleSession(g)
if !ok {
return
}
// parse request
id, ok := p.handleParseIDParam(g)
if !ok {
return
}
var req model.Page
if ok := p.handleParseRequest(g, &req); !ok {
return
}
// update page
err := p.PageService.UpdateByID(
g.Request.Context(),
session,
id,
&req,
)
// handle response
if ok := p.handleErrors(g, err); !ok {
return
}
p.Response.OK(g, gin.H{})
}
// DeleteByID deletes a page by id
func (p *Page) DeleteByID(g *gin.Context) {
session, _, ok := p.handleSession(g)
if !ok {
return
}
// parse request
id, ok := p.handleParseIDParam(g)
if !ok {
return
}
// delete page
err := p.PageService.DeleteByID(
g.Request.Context(),
session,
id,
)
// handle response
if ok := p.handleErrors(g, err); !ok {
return
}
p.Response.OK(g, gin.H{})
}
+92
View File
@@ -0,0 +1,92 @@
package controller
import (
"image/png"
"net/http"
"github.com/boombuler/barcode"
"github.com/boombuler/barcode/qr"
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/service"
)
// QRCodeRequest is the request to generate a QR code from a TOTP URL
type QRCodeRequest struct {
URL string `json:"url"`
DotSize int `json:"dotSize"`
}
// QRGenerator is the QR controller
type QRGenerator struct {
Common
}
// QRGenerator creates a HTML QR code
// It is returned in an JSON response
func (q *QRGenerator) ToHTML(g *gin.Context) {
_, _, ok := q.handleSession(g)
if !ok {
return
}
// parse request
var req QRCodeRequest
if ok := q.handleParseRequest(g, &req); !ok {
return
}
// generate QR code
qrCodeBuf, err := service.GenerateQRCode(req.URL, req.DotSize)
if err != nil {
q.Logger.Debugw("failed to genereate QR code",
"error", err,
)
q.Response.ServerError(g)
return
}
q.Response.OK(g, qrCodeBuf)
}
// ToTOTPURL generates a QR code from a TOTP URL
func (q *QRGenerator) ToTOTPURL(g *gin.Context) {
_, _, ok := q.handleSession(g)
if !ok {
return
}
// parse request
var req QRCodeRequest
if ok := q.handleParseRequest(g, &req); !ok {
return
}
// generate QR code
qrCode, err := qr.Encode(
req.URL,
qr.M,
qr.Auto,
)
if err != nil {
q.Logger.Debugw("failed to generate QR code",
"error", err,
)
q.Response.ServerError(g)
return
}
qrCode, err = barcode.Scale(qrCode, 200, 200)
if err != nil {
q.Logger.Debugw("failed to scale QR code",
"error", err,
)
q.Response.ServerError(g)
return
}
// output QR code as png
g.Writer.Header().Set("Content-Type", "image/png")
err = png.Encode(g.Writer, qrCode)
if err == nil {
q.Logger.Debugw("failed to encode QR code",
"error", err,
)
q.Response.ServerError(g)
return
}
// respond
g.Status(http.StatusOK)
}
+470
View File
@@ -0,0 +1,470 @@
package controller
import (
"archive/zip"
"bytes"
"encoding/csv"
"fmt"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/cache"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/utils"
)
// recipientColumnByMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var recipientColumnByMap = map[string]string{
"created_at": repository.TableColumn(database.RECIPIENT_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.RECIPIENT_TABLE, "updated_at"),
"email": repository.TableColumn(database.RECIPIENT_TABLE, "email"),
"phone": repository.TableColumn(database.RECIPIENT_TABLE, "phone"),
"extra identifier": repository.TableColumn(database.RECIPIENT_TABLE, "extra_identifier"),
"first_name": repository.TableColumn(database.RECIPIENT_TABLE, "first_name"),
"last_name": repository.TableColumn(database.RECIPIENT_TABLE, "last_name"),
"position": repository.TableColumn(database.RECIPIENT_TABLE, "position"),
"department": repository.TableColumn(database.RECIPIENT_TABLE, "department"),
"city": repository.TableColumn(database.RECIPIENT_TABLE, "city"),
"country": repository.TableColumn(database.RECIPIENT_TABLE, "country"),
"misc": repository.TableColumn(database.RECIPIENT_TABLE, "misc"),
"repeat_offender": "is_repeat_offender", // Special case - don't use TableColumn
}
var recipientCampaignEventColumnMap = utils.MergeStringMaps(
campaignEventColumns,
map[string]string{
"event": repository.TableColumnName(database.EVENT_TABLE),
"created": repository.TableColumn(database.CAMPAIGN_EVENT_TABLE, "created_at"),
"campaign": repository.TableColumn(database.CAMPAIGN_TABLE, "name"),
},
)
// Recipient is a Recipient controller
type Recipient struct {
Common
RecipientService *service.Recipient
}
// Create inserts a new recipient
func (r *Recipient) Create(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse request
var req model.Recipient
if ok := r.handleParseRequest(g, &req); !ok {
return
}
// save recipient
id, err := r.RecipientService.Create(
g.Request.Context(),
session,
&req,
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(
g,
gin.H{
"id": id.String(),
},
)
}
// GetCampaignEvents gets all campaign events by recipient id and campaign id
// gets all events if campaign id is nil
func (r *Recipient) GetCampaignEvents(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse request
recipientID, ok := r.handleParseIDParam(g)
if !ok {
return
}
// optional param
var campaignID *uuid.UUID
cid, err := uuid.Parse(g.Query("campaignID"))
if err == nil {
campaignID = &cid
}
queryArgs, ok := r.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByCreatedAt()
// remap query args
queryArgs.RemapOrderBy(recipientCampaignEventColumnMap)
// get events
events, err := r.RecipientService.GetAllCampaignEvents(
g.Request.Context(),
session,
recipientID,
campaignID,
queryArgs,
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, events)
}
// Export outputs a zip with recipient, groups and all events related to the recipient
func (r *Recipient) Export(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse request
recipientID, ok := r.handleParseIDParam(g)
if !ok {
return
}
// get the recipient
recp, err := r.RecipientService.GetByID(
g,
session,
recipientID,
&repository.RecipientOption{
WithCompany: true,
WithGroups: true,
},
)
if ok := r.handleErrors(g, err); !ok {
return
}
recipientBuffer := &bytes.Buffer{}
recipientWriter := csv.NewWriter(recipientBuffer)
recpHeaders := []string{
"Created at",
"Updated at",
"Email",
"Phone",
"Extra Identifier",
"Name",
"Position",
"Department",
"City",
"Country",
"Misc",
}
groups, _ := recp.Groups.Get()
for i := range groups {
recpHeaders = append(recpHeaders, fmt.Sprintf("Group %d", i+1))
}
err = recipientWriter.Write(recpHeaders)
if ok := r.handleErrors(g, err); !ok {
return
}
row := []string{
utils.CSVFromDate(recp.CreatedAt),
utils.CSVFromDate(recp.UpdatedAt),
utils.CSVRemoveFormulaStart(utils.NullableToString(recp.Email)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recp.Phone)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recp.ExtraIdentifier)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recp.FirstName)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recp.LastName)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recp.Position)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recp.Department)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recp.City)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recp.Country)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recp.Misc)),
}
for _, group := range groups {
row = append(row, group.Name.MustGet().String())
}
err = recipientWriter.Write(row)
if ok := r.handleErrors(g, err); !ok {
return
}
recipientWriter.Flush()
queryArgs, ok := r.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByCreatedAt()
// remap query args
queryArgs.RemapOrderBy(recipientCampaignEventColumnMap)
sortOrder := g.DefaultQuery("sortOrder", "desc")
if sortOrder == "desc" {
queryArgs.Desc = true
}
// get all rows
queryArgs.Limit = 0
queryArgs.Offset = 0
// get events
events, err := r.RecipientService.GetAllCampaignEvents(
g.Request.Context(),
session,
recipientID,
nil,
queryArgs,
)
// handle response
eventsBuffer := &bytes.Buffer{}
eventsWriter := csv.NewWriter(eventsBuffer)
headers := []string{
"Created at",
"Campaign",
"IP",
"User-Agent",
"Event Details",
"Event",
}
err = eventsWriter.Write(headers)
if ok := r.handleErrors(g, err); !ok {
return
}
for _, event := range events.Rows {
row := []string{}
row = []string{
utils.CSVFromDate(event.CreatedAt),
utils.CSVRemoveFormulaStart(event.CampaignName),
utils.CSVRemoveFormulaStart(event.IP.String()),
utils.CSVRemoveFormulaStart(event.UserAgent.String()),
utils.CSVRemoveFormulaStart(event.Data.String()),
utils.CSVRemoveFormulaStart(cache.EventNameByID[event.EventID.String()]),
}
err = eventsWriter.Write(row)
if ok := r.handleErrors(g, err); !ok {
return
}
}
eventsWriter.Flush()
// create ZIP file in memory
zipBuffer := new(bytes.Buffer)
zipWriter := zip.NewWriter(zipBuffer)
zipFileName := fmt.Sprintf("recipient_export_%s.zip", recp.Email.MustGet().String())
// add events to zip
{
f, err := zipWriter.Create("recipient.csv")
if ok := r.handleErrors(g, err); !ok {
return
}
_, err = f.Write(recipientBuffer.Bytes())
if ok := r.handleErrors(g, err); !ok {
return
}
}
// add events to zip
{
f, err := zipWriter.Create("events.csv")
if ok := r.handleErrors(g, err); !ok {
return
}
_, err = f.Write(eventsBuffer.Bytes())
if ok := r.handleErrors(g, err); !ok {
return
}
}
// close zip
err = zipWriter.Close()
if ok := r.handleErrors(g, err); !ok {
return
}
r.responseWithZIP(g, zipBuffer, zipFileName)
}
// GetRepeatOffenderCount gets the repeat offender count
func (r *Recipient) GetRepeatOffenderCount(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
// get count
count, err := r.RecipientService.GetRepeatOffenderCount(
g.Request.Context(),
session,
companyID,
)
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, count)
}
// GetAll gets all recipients
func (r *Recipient) GetAll(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := r.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortBy("first_name")
// remap query args
queryArgs.RemapOrderBy(recipientColumnByMap)
// get recipients
recipients, err := r.RecipientService.GetAll(
g.Request.Context(),
companyID,
session,
&repository.RecipientOption{
QueryArgs: queryArgs,
},
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, recipients)
}
// GetByID gets a recipient by id
func (r *Recipient) GetByID(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse id
id, ok := r.handleParseIDParam(g)
if !ok {
return
}
// get recipient
recipient, err := r.RecipientService.GetByID(
g.Request.Context(),
session,
id,
&repository.RecipientOption{
WithCompany: true,
WithGroups: true,
},
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, recipient)
}
// GetStatsByID gets a recipient campaign stats by id
func (r *Recipient) GetStatsByID(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse id
id, ok := r.handleParseIDParam(g)
if !ok {
return
}
// get recipient stats
stats, err := r.RecipientService.GetStatsByID(
g.Request.Context(),
session,
id,
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, stats)
}
// UpdateByID updates a recipient by id
func (r *Recipient) UpdateByID(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse request
id, ok := r.handleParseIDParam(g)
if !ok {
return
}
var req model.Recipient
if ok := r.handleParseRequest(g, &req); !ok {
return
}
err := r.RecipientService.UpdateByID(
g.Request.Context(),
session,
id,
&req,
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, gin.H{})
}
// Import imports recipients
func (r *Recipient) Import(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse request
var req struct {
Recipients []*model.Recipient `json:"recipients"`
CompanyID *uuid.UUID `json:"companyID"`
IgnoreOverwriteEmptyFields nullable.Nullable[bool] `json:"ignoreOverwriteEmptyFields"`
}
if ok := r.handleParseRequest(g, &req); !ok {
return
}
// IgnoreOverwriteEmptyFields default value is true
if !req.IgnoreOverwriteEmptyFields.IsSpecified() || req.IgnoreOverwriteEmptyFields.IsNull() {
req.IgnoreOverwriteEmptyFields = nullable.NewNullableWithValue(true)
}
_, err := r.RecipientService.Import(
g,
session,
req.Recipients,
req.IgnoreOverwriteEmptyFields.MustGet(),
req.CompanyID,
)
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, &gin.H{})
}
// DeleteByID deletes a recipient by id
func (r *Recipient) DeleteByID(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse id
id, ok := r.handleParseIDParam(g)
if !ok {
return
}
// delete recipient
err := r.RecipientService.DeleteByID(g, session, id)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, gin.H{})
}
+351
View File
@@ -0,0 +1,351 @@
package controller
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
)
// RecipientGroupColumnsMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var RecipientGroupColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.RECIPIENT_GROUP_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.RECIPIENT_GROUP_TABLE, "updated_at"),
"name": repository.TableColumn(database.RECIPIENT_GROUP_TABLE, "name"),
}
// AddRecipientRequest is a request to add recipients to a recipient group
type AddRecipientRequest struct {
RecipientIDs []string `json:"recipientIDs"`
}
// RemoveRecipientRequest is a request to remove recipients from a recipient group
type RemoveRecipientRequest struct {
RecipientIDs []string `json:"recipientIDs"`
}
// RecipientGroup is a recipient group controller
type RecipientGroup struct {
Common
RecipientGroupService *service.RecipientGroup
}
// Create creates a new recipient group
func (r *RecipientGroup) Create(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse request
var req model.RecipientGroup
if ok := r.handleParseRequest(g, &req); !ok {
return
}
// save recipient group
recipientGroupID, err := r.RecipientGroupService.Create(
g.Request.Context(),
session,
&req,
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(
g,
&gin.H{
"id": recipientGroupID.String(),
},
)
}
// GetAll returns all recipient groups using pagination
func (r *RecipientGroup) GetAll(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := r.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByName()
queryArgs.RemapOrderBy(RecipientGroupColumnsMap)
companyContextID := companyIDFromRequestQuery(g)
// get recipient groups
recipientGroups, err := r.RecipientGroupService.GetAll(
g,
session,
companyContextID,
&repository.RecipientGroupOption{
QueryArgs: queryArgs,
WithCompany: true,
WithRecipientCount: true,
},
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, recipientGroups)
}
// GetByID gets a recipient group by id
func (r *RecipientGroup) GetByID(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse id
id, ok := r.handleParseIDParam(g)
if !ok {
return
}
recipientGroup, err := r.RecipientGroupService.GetByID(
g.Request.Context(),
session,
id,
&repository.RecipientGroupOption{
WithCompany: true,
},
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, recipientGroup)
}
// GetRecipientsByGroupID gets recipients by recipient group id
func (r *RecipientGroup) GetRecipientsByGroupID(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse id
id, ok := r.handleParseIDParam(g)
if !ok {
return
}
queryArgs, ok := r.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortBy("email")
// remap query args
queryArgs.RemapOrderBy(recipientColumnByMap)
if !ok {
return
}
// get recipients
ctx := g.Request.Context()
recipients, err := r.RecipientGroupService.GetRecipientsByGroupID(
ctx,
session,
id,
&repository.RecipientOption{
QueryArgs: queryArgs,
WithCompany: true,
},
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, recipients)
}
// UpdateByID updates a recipient group by id
// updates only the name and company relations
func (r *RecipientGroup) UpdateByID(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse id
id, ok := r.handleParseIDParam(g)
if !ok {
return
}
// parse request
var req model.RecipientGroup
if ok := r.handleParseRequest(g, &req); !ok {
return
}
// check if recipient group exists already exists
err := r.RecipientGroupService.UpdateByID(
g.Request.Context(),
session,
id,
&req,
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, &gin.H{})
}
// Import imports recipients to a recipient group
func (r *RecipientGroup) Import(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse request
groupID, ok := r.handleParseIDParam(g)
if !ok {
return
}
var req struct {
Recipients []*model.Recipient `json:"recipients"`
CompanyID *uuid.UUID `json:"companyID"`
IgnoreOverwriteEmptyFields nullable.Nullable[bool] `json:"ignoreOverwriteEmptyFields"`
}
if ok := r.handleParseRequest(g, &req); !ok {
return
}
// IgnoreOverwriteEmptyFields default value is true
if !req.IgnoreOverwriteEmptyFields.IsSpecified() || req.IgnoreOverwriteEmptyFields.IsNull() {
req.IgnoreOverwriteEmptyFields = nullable.NewNullableWithValue(true)
}
err := r.RecipientGroupService.Import(
g,
session,
req.Recipients,
req.IgnoreOverwriteEmptyFields.MustGet(),
groupID,
req.CompanyID,
)
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, &gin.H{})
}
// AddRecipients adds recipients to a recipient group
func (r *RecipientGroup) AddRecipients(g *gin.Context) {
// handle session
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse group ID
groupID, ok := r.handleParseIDParam(g)
if !ok {
return
}
// parse request
var req AddRecipientRequest
if ok := r.handleParseRequest(g, &req); !ok {
return
}
// parse recipient ids
recipientIDs := []*uuid.UUID{}
for _, id := range req.RecipientIDs {
rid, err := uuid.Parse(id)
if err != nil {
r.Logger.Debugw("failed to add recipients to recipient group",
"error", fmt.Errorf("failed to parse recipient id: %w", err),
)
r.Response.BadRequestMessage(g, "invalid recipient id")
return
}
recipientIDs = append(recipientIDs, &rid)
}
// add recipients
err := r.RecipientGroupService.AddRecipients(
g.Request.Context(),
session,
groupID,
recipientIDs,
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, &gin.H{})
}
// RemoveRecipients removes a recipient from a recipient group
func (r *RecipientGroup) RemoveRecipients(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse id
id, ok := r.handleParseIDParam(g)
if !ok {
return
}
// parse request
var req RemoveRecipientRequest
if ok := r.handleParseRequest(g, &req); !ok {
return
}
// parse recipient ids
recipientIDs := []*uuid.UUID{}
for _, id := range req.RecipientIDs {
rid, err := uuid.Parse(id)
if err != nil {
r.Logger.Debugw("failed to remove recipients from recipient group",
"error", fmt.Errorf("failed to parse recipient id: %w", err),
)
r.Response.BadRequestMessage(g, "invalid recipient id")
return
}
recipientIDs = append(recipientIDs, &rid)
}
// remove recipients
err := r.RecipientGroupService.RemoveRecipients(
g.Request.Context(),
session,
id,
recipientIDs,
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, &gin.H{})
}
// DeleteByID deletes a recipient group by id
// deleting a group also deletes all recipients in that group
func (r *RecipientGroup) DeleteByID(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse id
id, ok := r.handleParseIDParam(g)
if !ok {
return
}
// delete recipient group
err := r.RecipientGroupService.DeleteByID(
g.Request.Context(),
session,
id,
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(
g,
&gin.H{},
)
}
+269
View File
@@ -0,0 +1,269 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/api"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/vo"
)
// SMTPConfigurationColumnsMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var SMTPConfigurationColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.SMTP_CONFIGURATION_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.SMTP_CONFIGURATION_TABLE, "updated_at"),
"name": repository.TableColumn(database.SMTP_CONFIGURATION_TABLE, "name"),
"host": repository.TableColumn(database.SMTP_CONFIGURATION_TABLE, "host"),
"port": repository.TableColumn(database.SMTP_CONFIGURATION_TABLE, "port"),
"username": repository.TableColumn(database.SMTP_CONFIGURATION_TABLE, "username"),
}
// SMTPConfiguration is a controller
type SMTPConfiguration struct {
Common
SMTPConfigurationService *service.SMTPConfiguration
}
// Create creates a new SMTPConfiguration
func (c *SMTPConfiguration) Create(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
var req model.SMTPConfiguration
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// save SMTP configuration
id, err := c.SMTPConfigurationService.Create(g, session, &req)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
gin.H{
"id": id.String(),
},
)
}
// GetAll gets SMTP configurations
func (c *SMTPConfiguration) GetAll(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
queryArgs.RemapOrderBy(SMTPConfigurationColumnsMap)
companyID := companyIDFromRequestQuery(g)
// get
smtpConfigs, err := c.SMTPConfigurationService.GetAll(
g.Request.Context(),
session,
companyID,
&repository.SMTPConfigurationOption{
QueryArgs: queryArgs,
WithCompany: true,
WithHeaders: true,
},
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, smtpConfigs)
}
// GetByID gets a SMTP configuration by an ID
func (c *SMTPConfiguration) GetByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// get SMTP configuration
smtpConfig, err := c.SMTPConfigurationService.GetByID(
g.Request.Context(),
session,
id,
&repository.SMTPConfigurationOption{
WithCompany: true,
WithHeaders: true,
},
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, smtpConfig)
}
type SMTPConfigurationTestEmailRequest struct {
Email vo.Email `json:"email" binding:"required,email"`
MailFrom vo.Email `json:"mailFrom" binding:"required,mailFrom"`
}
// TestEmail tests the connection to a SMTP configuration
func (c *SMTPConfiguration) TestEmail(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
var req SMTPConfigurationTestEmailRequest
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// test dial
err := c.SMTPConfigurationService.SendTestEmail(
g,
session,
id,
&req.Email,
&req.MailFrom,
)
// handle any error as a validation error
if err != nil {
err = errs.NewValidationError(err)
}
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// UpdateByID updates a SMTP configuration - but not the headers
func (c *SMTPConfiguration) UpdateByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
var req model.SMTPConfiguration
if ok := c.handleParseRequest(g, &req); !ok {
return
}
err := c.SMTPConfigurationService.UpdateByID(
g.Request.Context(),
session,
id,
&req,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// AddHeader adds a header to a SMTP configuration
func (c *SMTPConfiguration) AddHeader(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
var req model.SMTPHeader
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// save header
smtpID, ok := c.handleParseIDParam(g)
if !ok {
return
}
createdID, err := c.SMTPConfigurationService.AddHeader(
g.Request.Context(),
session,
smtpID,
&req,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{
"id": createdID.String(),
})
}
// RemoveHeader removes a header from a SMTP configuration
func (c *SMTPConfiguration) RemoveHeader(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
headerID, err := uuid.Parse(g.Param("headerID"))
if err != nil {
c.Logger.Debugw("invalid header id",
"headerID", g.Param("headerID"),
"error", err,
)
c.Response.BadRequestMessage(g, api.InvalidSMTPConfigurationID)
return
}
// remove header
err = c.SMTPConfigurationService.RemoveHeader(
g.Request.Context(),
session,
id,
&headerID,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// DeleteByID deletes a SMTP configuration
func (c *SMTPConfiguration) DeleteByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// delete
err := c.SMTPConfigurationService.DeleteByID(g, session, id)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
+88
View File
@@ -0,0 +1,88 @@
package controller
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/service"
)
// SSO the single sign on controller
type SSO struct {
Common
*service.SSO
}
// Upsert upserts a SSO configuration
func (s *SSO) Upsert(g *gin.Context) {
session, _, ok := s.handleSession(g)
if !ok {
return
}
// parse request
var request model.SSOOption
if ok := s.handleParseRequest(g, &request); !ok {
return
}
// handle upsert
err := s.SSO.Upsert(
g.Request.Context(),
session,
&request,
)
// handle responses
if ok := s.handleErrors(g, err); !ok {
return
}
s.Response.OK(g, gin.H{})
}
func (s *SSO) IsEnabled(g *gin.Context) {
// if no sso client is setup, then it is not enabled
if s.SSO.MSALClient == nil {
s.Response.OK(g, false)
return
}
s.Response.OK(g, true)
}
func (s *SSO) EntreIDLogin(g *gin.Context) {
authURL, err := s.SSO.EntreIDLogin(g)
if errors.Is(err, errs.ErrSSODisabled) {
s.Response.BadRequest(g)
return
}
if ok := s.handleErrors(g, err); !ok {
s.Response.BadRequest(g)
return
}
g.Redirect(http.StatusTemporaryRedirect, authURL)
}
func (s *SSO) EntreIDCallBack(g *gin.Context) {
code := g.Query("code")
session, err := s.SSO.HandlEntraIDCallback(g, code)
if err != nil {
g.Redirect(http.StatusTemporaryRedirect, "/login?ssoAuthError=1")
return
}
if ok := s.handleErrors(g, err); !ok {
return
}
// Set the session in the cookie
cookie := &http.Cookie{
Name: data.SessionCookieKey,
Value: session.ID.String(),
Path: "/",
SameSite: http.SameSiteStrictMode,
HttpOnly: true,
Secure: true,
Expires: *session.MaxAgeAt,
}
http.SetCookie(g.Writer, cookie)
g.Redirect(http.StatusTemporaryRedirect, "/dashboard")
}
+93
View File
@@ -0,0 +1,93 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/go-errors/errors"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/service"
)
type Update struct {
Common
UpdateService *service.Update
OptionService *service.Option
}
// CheckForUpdateCached checks if there is a new update from cache
func (u *Update) CheckForUpdateCached(g *gin.Context) {
session, _, ok := u.handleSession(g)
if !ok {
return
}
updateAvailable, usingSystemd, err := u.UpdateService.CheckForUpdateCached(g, session)
if ok := u.handleErrors(g, err); !ok {
return
}
u.Response.OK(g, gin.H{
"updateAvailable": updateAvailable,
"updateInApp": usingSystemd,
})
}
// CheckForUpdate checks if there is a new update
func (u *Update) CheckForUpdate(g *gin.Context) {
session, _, ok := u.handleSession(g)
if !ok {
return
}
updateAvailable, usingSystemd, err := u.UpdateService.CheckForUpdate(g, session)
if ok := u.handleErrors(g, err); !ok {
return
}
u.Response.OK(g, gin.H{
"updateAvailable": updateAvailable,
"updateInApp": usingSystemd,
})
}
// GetUpdateDetails gets details about the newest software update
func (u *Update) GetUpdateDetails(g *gin.Context) {
session, _, ok := u.handleSession(g)
if !ok {
return
}
opt, err := u.OptionService.GetOption(g, session, data.OptionKeyUsingSystemd)
if ok := u.handleErrors(g, err); !ok {
return
}
details, err := u.UpdateService.GetUpdateDetails(g, session)
if err != nil && !errors.Is(err, errs.ErrNoUpdateAvailable) {
if ok := u.handleErrors(g, err); !ok {
return
}
}
if errors.Is(err, errs.ErrNoUpdateAvailable) {
u.Response.OK(g, gin.H{
"updateAvailable": false,
"updateInApp": opt.Value.String() == data.OptionValueUsingSystemdYes,
"downloadURL": "",
"latestVersion": "",
})
return
}
u.Response.OK(g, gin.H{
"updateAvailable": true,
"updateInApp": opt.Value.String() == data.OptionValueUsingSystemdYes,
"downloadURL": details.DownloadURL,
"latestVersion": details.LatestVersion,
})
}
// RunUpdate performs an update
func (u *Update) RunUpdate(g *gin.Context) {
session, _, ok := u.handleSession(g)
if !ok {
return
}
err := u.UpdateService.RunUpdate(g, session)
if ok := u.handleErrors(g, err); !ok {
return
}
u.Response.OK(g, gin.H{})
}
+922
View File
@@ -0,0 +1,922 @@
package controller
import (
"net/http"
"github.com/go-errors/errors"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/vo"
"gorm.io/gorm"
)
var SessionColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.SESSION_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.SESSION_TABLE, "updated_at"),
"ip_address": repository.TableColumn(database.SESSION_TABLE, "ip_address"),
}
var UserColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.USER_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.USER_TABLE, "updated_at"),
"name": repository.TableColumn(database.USER_TABLE, "name"),
"username": repository.TableColumn(database.USER_TABLE, "username"),
"email": repository.TableColumn(database.USER_TABLE, "email"),
}
// UserLoginRequest is a request for login with username and password
type UserLoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
TOTP string `json:"totp"`
MFARecoveryCode string `json:"recoveryCode"`
}
// UserSetupTOTPRequest is a request for setting up TOTP
type UserSetupTOTPRequest struct {
Password string `json:"password"`
}
// UserSetupDisableTOTPRequest is a request for disabling TOTP
type UserDisableTOTPRequest struct {
Token string `json:"token"`
}
// UserVerifyTOTPRequest is a request for verifying TOTP
type UserVerifyTOTPRequest struct {
TOTP string `json:"token"`
}
// UserLoginWithMFARecoveryCodeRequest is a request for login with MFA recovery code
type UserLoginWithMFARecoveryCodeRequest struct {
RecoveryCode string `json:"recoveryCode"`
Username string `json:"username"`
Password string `json:"password"`
}
// User is the change email controller
type User struct {
Common
UserService *service.User
}
// Create creates a new user
func (c *User) Create(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse req
var req model.UserUpsertRequest
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// create user
newUserID, err := c.UserService.Create(
g,
session,
&req,
)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
gin.H{
"id": newUserID.String(),
},
)
}
// GetMaskedAPIKey gets logged-in users masked API key
func (c *User) GetMaskedAPIKey(g *gin.Context) {
session, user, ok := c.handleSession(g)
if !ok {
return
}
if user == nil {
c.handleErrors(g, errors.New("no user in session"))
}
// get
cid := user.ID.MustGet()
apiKey, err := c.UserService.GetMaskedAPIKey(
g,
session,
&cid,
)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
gin.H{
"apiKey": apiKey,
},
)
}
// UpsertAPIKey create/update API key
func (c *User) UpsertAPIKey(g *gin.Context) {
session, user, ok := c.handleSession(g)
if !ok {
return
}
if user == nil {
c.handleErrors(g, errors.New("no user in session"))
}
// create user
uid := user.ID.MustGet()
apiKey, err := c.UserService.UpsertAPIKey(
g,
session,
&uid,
)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
gin.H{
"apiKey": apiKey,
},
)
}
// RemoveAPIKey removes a api key
func (c *User) RemoveAPIKey(g *gin.Context) {
session, user, ok := c.handleSession(g)
if !ok {
return
}
if user == nil {
c.handleErrors(g, errors.New("no user in session"))
}
// create user
uid := user.ID.MustGet()
err := c.UserService.RemoveAPIKey(
g,
session,
&uid,
)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
gin.H{},
)
}
// UpdateByID updates a user by ID
func (c *User) UpdateByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
var req model.User
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// update user
err := c.UserService.Update(
g,
session,
id,
&req,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// Delete deletes a user
func (c *User) Delete(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// delete user
err := c.UserService.Delete(g, session, id)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// GetAll gets all users using pagination
func (c *User) GetAll(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
queryArgs.RemapOrderBy(UserColumnsMap)
// get user
users, err := c.UserService.GetAll(g, session, &repository.UserOption{
QueryArgs: queryArgs,
WithRole: true,
WithCompany: true,
})
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, users)
}
// GetByID gets a user by ID
func (c *User) GetByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// get user
user, err := c.UserService.GetByID(g, session, id)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, user)
}
// ChangeEmailOnLoggedInUser changes email on logged in user
// this is an administrator action
func (c *User) ChangeEmailOnLoggedInUser(g *gin.Context) {
session, sessionUser, ok := c.handleSession(g)
if !ok {
return
}
// parse and validate request
var request model.UserChangeEmailRequest
if ok := c.handleParseRequest(g, &request); !ok {
return
}
// change email
userID := sessionUser.ID.MustGet()
changedEmail, err := c.UserService.ChangeEmailAsAdministrator(
g,
session,
&userID,
&request.Email,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
gin.H{"email": changedEmail.String()},
)
}
// ChangeFullnameOnLoggedInUser is the handler for change fullname
func (c *User) ChangeFullnameOnLoggedInUser(g *gin.Context) {
session, sessionUser, ok := c.handleSession(g)
if !ok {
return
}
// parse req
var req model.UserChangeFullnameRequest
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// change fullname
userID := sessionUser.ID.MustGet()
_, err := c.UserService.ChangeFullname(
g,
session,
&userID,
&req.NewFullname,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// ChangePasswordOnLoggedInUser changes the password on the logged in user
func (c *User) ChangePasswordOnLoggedInUser(g *gin.Context) {
session, sessionUser, ok := c.handleSession(g)
if !ok {
return
}
// parse req
var req model.UserChangePasswordRequest
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// change password
err := c.UserService.ChangePassword(
g,
session,
&req.CurrentPassword,
&req.NewPassword,
)
// handle response
if errors.Is(err, errs.ErrUserWrongPasword) {
c.Response.BadRequestMessage(g, "Invalid current password")
return
}
if ok := c.handleErrors(g, err); !ok {
return
}
// invalidate all currently running sessions
userID := sessionUser.ID.MustGet()
err = c.SessionService.ExpireAllByUserID(g, session, &userID)
// partial error, the password is changed but the sessions are not invalidated
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
"password changed - all sessions have been invalidated",
)
}
// ChangeUsernameOnLoggedInUser changes the username
func (c *User) ChangeUsernameOnLoggedInUser(g *gin.Context) {
session, sessionUser, ok := c.handleSession(g)
if !ok {
return
}
// parse req
var req model.UserChangeUsernameOnLoggedInRequest
if ok := c.handleParseRequest(g, &req); !ok {
return
}
userID := sessionUser.ID.MustGet()
// change username
err := c.UserService.ChangeUsername(
g.Request.Context(),
session,
&userID,
&req.NewUsername,
)
// handle error
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// ExpireSessionByID expires a session by ID
// a administrator can expire any session
// a user can expire their own sessions
func (c *User) ExpireSessionByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
isAuthorized, err := service.IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if ok := c.handleErrors(g, err); !ok {
return
}
if !isAuthorized {
c.Response.Forbidden(g)
return
}
err = c.SessionService.Expire(g, id)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
"session expired",
)
}
// GetSessionsByUserID gets all sessions by user ID
func (c *User) GetSessionsOnLoggedInUser(g *gin.Context) {
session, sessionUser, ok := c.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
queryArgs.RemapOrderBy(SessionColumnsMap)
userID := sessionUser.ID.MustGet()
sessions, err := c.SessionService.GetSessionsByUserID(
g,
session,
&userID,
&repository.SessionOption{
QueryArgs: queryArgs,
},
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
data := []map[string]interface{}{}
for _, sess := range sessions.Rows {
idStr := sess.ID.String()
data = append(data, map[string]interface{}{
"id": idStr,
"current": idStr == session.ID.String(),
"ip": sess.IP,
"createdAt": sess.CreatedAt,
"updatedAt": sess.UpdatedAt,
})
}
c.Response.OK(
g,
gin.H{"sessions": data},
)
}
// Login logs in a user
func (c *User) Login(g *gin.Context) {
// parse req
var req UserLoginRequest
if ok := c.handleParseRequest(g, &req); !ok {
return
}
user, err := c.UserService.AuthenticateUsernameWithPassword(
g,
req.Username,
req.Password,
g.ClientIP(),
)
if errors.Is(err, errs.ErrUserWrongPasword) {
c.Response.BadRequestMessage(g, "Invalid password")
return
}
if errors.Is(err, gorm.ErrRecordNotFound) {
c.Response.BadRequestMessage(g, "Invalid credentials")
return
}
if ok := c.handleErrors(g, err); !ok {
return
}
// if the user has MFA enabled then we check the MFA flow
// if the user has MFA enabled, we must check if there is a
// valid MFA or a valid recovery code
userID := user.ID.MustGet()
MFATokenSupplied := len(req.TOTP) > 0
MFARecoveryCodeSupplied := len(req.MFARecoveryCode) > 0
mfaEnabled, err := c.UserService.IsTOTPEnabledByUserID(
g,
&userID,
)
if errors.Is(err, errs.ErrUserWrongTOTP) {
c.Response.BadRequestMessage(g, "Invalid TOTP")
return
}
if ok := c.handleErrors(g, err); !ok {
return
}
if mfaEnabled {
// if tokens or recovery codes are supplied
// return mfa is required
if !MFATokenSupplied && !MFARecoveryCodeSupplied {
c.Response.OK(
g,
gin.H{
"mfa": true,
},
)
return
}
// if the client has given both a TOTP and a recovery code
// we return a bad request
if MFATokenSupplied && MFARecoveryCodeSupplied {
c.Response.BadRequestMessage(g, "Cannot supply both MFA token and MFA recovery code")
return
}
// verify the TOTP MFA token
userID := user.ID.MustGet()
if MFATokenSupplied && !MFARecoveryCodeSupplied {
// if MFA is enabled, verify the TOTP
totpToken, err := vo.NewString64(req.TOTP)
if err != nil {
c.Logger.Debugw("failed to create TOTP",
"error", err,
)
c.Response.ValidationFailed(g, "TOTP", err)
return
}
err = c.UserService.CheckTOTP(
g,
&userID,
totpToken,
)
if err != nil {
if errors.Is(err, errs.ErrUserWrongTOTP) {
c.Response.BadRequestMessage(g, "Invalid TOTP")
return
}
if ok := c.handleErrors(g, err); !ok {
return
}
}
}
// if the user has MFA enabled and the client has supplied a recovery code
// we verify the recovery code
if !MFATokenSupplied && MFARecoveryCodeSupplied {
recoveryCode, err := vo.NewString64(req.MFARecoveryCode)
if err != nil {
c.Logger.Debugw("failed to create recovery code",
"error", err,
)
c.Response.ValidationFailed(g, "RecoveryCode", err)
return
}
verifiedMFA, err := c.UserService.CheckMFARecoveryCode(
g,
&userID,
recoveryCode,
)
if err != nil {
if errors.Is(err, errs.ErrUserWrongRecoveryCode) {
c.Response.BadRequestMessage(g, "Invalid recovery code")
return
}
if ok := c.handleErrors(g, err); !ok {
return
}
}
if !verifiedMFA {
c.Response.BadRequestMessage(g, "Invalid recovery code")
return
}
// as the recovery code is valid, we can now disable MFA
err = c.UserService.DisableTOTP(g, &userID)
if ok := c.handleErrors(g, err); !ok {
return
}
}
}
// create a new session
session, err := c.SessionService.Create(
g,
user,
g.ClientIP(),
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
// Set the session in the cookie
cookie := &http.Cookie{
Name: data.SessionCookieKey,
Value: session.ID.String(),
Path: "/",
SameSite: http.SameSiteStrictMode,
HttpOnly: true,
Secure: true,
Expires: *session.MaxAgeAt,
}
http.SetCookie(g.Writer, cookie)
c.Response.OK(g, session)
}
// expireCookieAndStatusOK expires the cookie and returns a 200 OK
func (c *User) expireCookieAndStatusOK(g *gin.Context) {
g.SetCookie(
data.SessionCookieKey,
"",
-1,
"/",
"",
false,
true,
)
c.logoutOK(g)
}
// logoutOK returns a 200 OK
func (c *User) logoutOK(g *gin.Context) {
c.Response.OK(
g,
gin.H{"message": "logged out"},
)
}
// Logout logs out the user
// only invalidates the session if the session cookie is
// in the request, this should reduce the risk of CSRF logout
func (c *User) Logout(g *gin.Context) {
sessionCookie, err := g.Cookie(data.SessionCookieKey)
if err != nil {
c.logoutOK(g)
return
}
sessionID, err := uuid.Parse(sessionCookie)
if err != nil {
c.logoutOK(g)
return
}
ctx := g.Request.Context()
err = c.SessionService.Expire(ctx, &sessionID)
if err != nil {
c.expireCookieAndStatusOK(g)
return
}
c.expireCookieAndStatusOK(g)
}
// SessionPing pings the session
func (c *User) SessionPing(g *gin.Context) {
// handle session
session, sessionUser, ok := c.handleSession(g)
if !ok {
return
}
c.Logger.Debugw("pinged session for user",
"userID", sessionUser.ID.MustGet().String(),
)
sessionRole := sessionUser.Role
if sessionRole == nil {
c.Logger.Error("failed to load role from session user")
c.Response.ServerError(g)
return
}
sessionCompany := sessionUser.Company
companyName := ""
if sessionCompany != nil {
companyName = sessionCompany.Name.MustGet().String()
}
c.Response.OK(
g,
gin.H{
"userID": sessionUser.ID,
"username": sessionUser.Username.MustGet().String(),
"name": sessionUser.Name.MustGet().String(),
"role": sessionRole.Name,
"company": companyName,
"ip": session.IP,
},
)
}
// InvalidateAllSessionByUserID is the nuclear session button for a user
func (c *User) InvalidateAllSessionByUserID(g *gin.Context) {
session, user, ok := c.handleSession(g)
if !ok {
return
}
var userID *uuid.UUID
// parse req
var req model.InvalidateAllSessionRequest
err := g.ShouldBindJSON(&req)
if err != nil {
if user == nil || !user.ID.IsSpecified() {
c.Response.BadRequest(g)
return
}
uid := user.ID.MustGet()
userID = &uid
} else {
if req.UserID == nil {
c.Response.BadRequest(g)
return
}
userID = req.UserID
}
// invalidate
err = c.SessionService.ExpireAllByUserID(g, session, userID)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// SetupTOTP generates a new TOTP MFA secrets
func (c *User) SetupTOTP(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
var request UserSetupTOTPRequest
if ok := c.handleParseRequest(g, &request); !ok {
return
}
passwd, err := vo.NewReasonableLengthPassword(request.Password)
if err != nil {
c.Logger.Debugw("failed to create password",
"error", err,
)
c.Response.ValidationFailed(g, "Password", err)
return
}
// get and save TOTP for user
totpValues, err := c.UserService.SetupTOTP(
g.Request.Context(),
session,
passwd,
)
// handle response
if errors.Is(err, errs.ErrAuthenticationFailed) {
c.Response.BadRequestMessage(g, "Incorrect password")
return
}
if ok := handleServerError(g, c.Response, err); !ok {
return
}
c.Response.OK(
g,
gin.H{
"base32": totpValues.Secret,
"url": totpValues.URL,
"recoveryCode": totpValues.RecoveryCode,
},
)
}
// SetupVerifyTOTP verifies a TOTP
func (c *User) SetupVerifyTOTP(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse req
var req UserVerifyTOTPRequest
if ok := c.handleParseRequest(g, &req); !ok {
return
}
totp, err := vo.NewString64(req.TOTP)
if err != nil {
c.Logger.Debugw("failed to create TOTP",
"error", err,
)
c.Response.ValidationFailed(g, "TOTP", err)
return
}
// verify TOTP
err = c.UserService.SetupCheckTOTP(
g.Request.Context(),
session,
totp,
)
if errors.Is(err, errs.ErrUserWrongTOTP) {
c.Response.BadRequestMessage(g, "Invalid token")
return
}
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
"TOTP verified",
)
}
// IsTOTPEnabled checks if TOTP is enabled
func (c *User) IsTOTPEnabled(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// check if TOTP is enabled
isEnabled, err := c.UserService.IsTOTPEnabled(
g.Request.Context(),
session,
)
// handle response
if ok := handleServerError(g, c.Response, err); !ok {
return
}
c.Response.OK(
g,
gin.H{"enabled": isEnabled},
)
}
// DisableTOTP disables TOTP
func (c *User) DisableTOTP(g *gin.Context) {
_, user, ok := c.handleSession(g)
if !ok {
return
}
// parse request
var request UserDisableTOTPRequest
if ok := c.handleParseRequest(g, &request); !ok {
return
}
token, err := vo.NewString64(request.Token)
if err != nil {
c.Logger.Debugw("failed to create token",
"error", err,
)
c.Response.ValidationFailed(g, "Token", err)
return
}
// check TOTP
userID := user.ID.MustGet()
err = c.UserService.CheckTOTP(
g.Request.Context(),
&userID,
token,
)
if err != nil {
if errors.Is(err, errs.ErrUserWrongTOTP) {
c.Response.BadRequestMessage(g, "Invalid token")
return
}
if ok := c.handleErrors(g, err); !ok {
return
}
}
// disable TOTP
err = c.UserService.DisableTOTP(
g.Request.Context(),
&userID,
)
// handle response
if err != nil {
if errors.Is(err, errs.ErrUserWrongTOTP) {
c.Response.BadRequestMessage(g, "Invalid token")
return
}
if ok := c.handleErrors(g, err); !ok {
return
}
}
c.Response.OK(
g,
"TOTP disabled",
)
}
// VerifyTOTP verifies a TOTP
func (c *User) VerifyTOTP(g *gin.Context) {
_, user, ok := c.handleSession(g)
if !ok {
return
}
// parse req
var req UserVerifyTOTPRequest
if ok := c.handleParseRequest(g, &req); !ok {
return
}
totp, err := vo.NewString64(req.TOTP)
if err != nil {
c.Logger.Debugw("failed to create TOTP",
"error", err,
)
c.Response.ValidationFailed(g, "TOTP", err)
return
}
// verify TOTP
userID := user.ID.MustGet()
err = c.UserService.CheckTOTP(
g.Request.Context(),
&userID,
totp,
)
if errors.Is(err, errs.ErrUserWrongTOTP) {
c.Response.BadRequestMessage(g, "Invalid token")
return
}
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
"TOTP verified",
)
}
+358
View File
@@ -0,0 +1,358 @@
package controller
import (
"bytes"
"encoding/csv"
"fmt"
"io"
"mime"
"path/filepath"
"strings"
"time"
"github.com/go-errors/errors"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/api"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/utils"
"github.com/phishingclub/phishingclub/vo"
"go.uber.org/zap"
"gorm.io/gorm"
)
// Common is a common controller base struct it holds common operations on the
// common dependencies
type Common struct {
Response api.JSONResponseHandler
Logger *zap.SugaredLogger
SessionService *service.Session
}
// handleSession handles the session and returns the session and user
// if the session is not valid, a 401 response is sent
func (c *Common) handleSession(
g *gin.Context,
) (*model.Session, *model.User, bool) {
s, ok := g.Get("session")
if !ok {
c.Logger.Debug("session not found in context")
c.Response.Unauthorized(g)
return nil, nil, false
}
session, ok := s.(*model.Session)
if !ok {
c.Logger.Error("session in context is not of type model.Session")
c.Response.Unauthorized(g)
return nil, nil, false
}
user := session.User
if user == nil {
c.Logger.Error("user not found in session")
c.Response.Unauthorized(g)
return nil, nil, false
}
return session, user, true
}
// HandleParseRequest parses the request and returns true if successful
// if the request is not parsable, a 400 response is sent
func (c *Common) handleParseRequest(
g *gin.Context,
req any,
) bool {
body, err := io.ReadAll(g.Request.Body)
if err != nil {
c.Logger.Debugw("failed to read request body",
"error", err,
)
c.Response.BadRequest(g)
return false
}
if err := utils.Unmarshal(body, &req); err != nil {
c.Logger.Debugw("failed to parse request",
"error", err,
)
c.Response.BadRequestMessage(g, err.Error())
return false
}
return true
}
// handleParseIDParam parses the id parameter from the request
// and returns it if successful
// if the id is not parsable, a 400 response is sent
func (c *Common) handleParseIDParam(
g *gin.Context,
) (*uuid.UUID, bool) {
id, err := uuid.Parse(g.Param("id"))
if err != nil {
c.Logger.Debugw("failed to parse id",
"error", err,
)
c.Response.BadRequestMessage(g, errs.MsgFailedToParseUUID)
return nil, false
}
return &id, true
}
// handlePagination parses the pagination from the request and returns it
// if the pagination is not valid, a 400 response is sent
func (c *Common) handlePagination(
g *gin.Context,
) (*vo.Pagination, bool) {
pagination, err := vo.NewPaginationFromRequest(g)
if err != nil {
c.Logger.Debugw("invalid offset or limit",
"error", err,
)
c.Response.ValidationFailed(g, "pagination", err)
return nil, false
}
return pagination, true
}
// handleQueryArgs parses the query from the request and returns it
func (c *Common) handleQueryArgs(g *gin.Context) (*vo.QueryArgs, bool) {
q, err := vo.QueryFromRequest(g)
if err != nil {
c.Logger.Debugw("failed to parse query",
"error", err,
)
c.Response.ValidationFailed(g, "query args", err)
return nil, false
}
return q, true
}
// handleErrors is a helper function to handle common handleErrors
// it most often checks for more than what is needed, but is
// useful to avoid missing any error handling and saving time
// it returns true if no errors are found
// it returns false if an error is found and a response is sent
func (c *Common) handleErrors(
g *gin.Context,
err error,
) bool {
if err != nil {
if ok := handleAuthorizationError(g, c.Response, err); !ok {
c.Logger.Debugw("authorization error",
"auth_error", err,
)
return false
}
if ok := handleValidationError(g, c.Response, err); !ok {
c.Logger.Debugw("validation error",
"validation_error", err,
)
return false
}
if ok := handleCustomError(g, c.Response, err); !ok {
c.Logger.Debugw("custom error",
"custom_error", err,
)
return false
}
if ok := handleDBRowNotFound(g, c.Response, err); !ok {
c.Logger.Debugw("DB row not found error",
"error", err,
)
return false
}
c.Logger.Errorw("API unknown error type", "error", err)
_ = handleServerError(g, c.Response, err)
return false
}
return true
}
// responseWithCSV
func (c *Common) responseWithCSV(
g *gin.Context,
buffer *bytes.Buffer,
writer *csv.Writer,
name string,
) {
writer.Flush()
if err := writer.Error(); err != nil {
c.handleErrors(g, err)
return
}
// Set CSV response headers
setSecureContentDisposition(g, name)
g.Header("Content-Type", "text/csv")
g.Header("Content-Length", fmt.Sprint(buffer.Len()))
// Write the CSV buffer to the response
_, err := g.Writer.Write(buffer.Bytes())
if err != nil {
c.handleErrors(g, err)
}
}
// responseWithZIP
func (c *Common) responseWithZIP(
g *gin.Context,
buffer *bytes.Buffer,
name string,
) {
g.Header("Content-Type", "application/zip")
setSecureContentDisposition(g, name)
g.Header("Content-Transfer-Encoding", "binary")
g.Header("Expires", "0")
g.Header("Cache-Control", "must-revalidate")
g.Header("Pragma", "public")
g.Header("Content-Length", fmt.Sprintf("%d", buffer.Len()))
_, err := g.Writer.Write(buffer.Bytes())
if err != nil {
c.handleErrors(g, err)
}
}
// companyIDFromRequestQuery returns the companyID as a UUID from the query
// or nil if not found
func companyIDFromRequestQuery(g *gin.Context) *uuid.UUID {
companyID := g.Query("companyID")
if companyID != "" {
cid, err := uuid.Parse(companyID)
if err != nil {
return nil
}
return &cid
}
return nil
}
// SetSessionInGinContext sets the session in the gin context
func SetSessionInGinContext(c *gin.Context, s *model.Session) {
c.Set("session", s)
}
// handleDBRowNotFound checks if the error is a not found error
// if it is, a 404 response is sent
// if it is not, true is returned
func handleDBRowNotFound(
g *gin.Context,
responseHandler api.JSONResponseHandler,
err error,
) bool {
if errors.Is(err, gorm.ErrRecordNotFound) {
// error is logged in service
_ = err
responseHandler.NotFound(g)
return false
}
return true
}
// handleAuthorizationError checks if the error is an authorization error
// if it is, a 403 response is sent
// if it is not, true is returned
func handleAuthorizationError(
g *gin.Context,
responseHandler api.JSONResponseHandler,
err error,
) bool {
if errors.Is(err, errs.ErrAuthorizationFailed) {
// error is logged in service
_ = err
responseHandler.Forbidden(g)
return false
}
return true
}
// handleValidationError checks if the error is a validation error
// if it is, a 400 response is sent
// if it is not, true is returned
func handleValidationError(
g *gin.Context,
responseHandler api.JSONResponseHandler,
err error,
) bool {
if errors.As(err, &errs.ValidationError{}) {
// error is logged in service
_ = err
responseHandler.BadRequestMessage(g, err.Error())
return false
}
return true
}
// handleCustomError checks if the error is a custom error
// if it is a 400 response is sent
// if it is not, true is returned
func handleCustomError(
g *gin.Context,
responseHandler api.JSONResponseHandler,
err error,
) bool {
if errors.As(err, &errs.CustomError{}) {
// error is logged in service
_ = err
responseHandler.BadRequestMessage(g, err.Error())
return false
}
return true
}
// handleServerError checks if the error is a server error
// if it is, a 500 response is sent
// if it is not, true is returned
func handleServerError(
g *gin.Context,
responseHandler api.JSONResponseHandler,
err error,
) bool {
if err != nil {
// error is logged in service
_ = err
responseHandler.ServerError(g)
return false
}
return true
}
func setSecureContentDisposition(c *gin.Context, filename string) {
// Strip any directory components
filename = filepath.Base(filename)
// Remove any potentially problematic characters
filename = strings.Map(func(r rune) rune {
// Keep only alphanumeric, space, dash, underscore and dot
if (r >= 'a' && r <= 'z') ||
(r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') ||
(r == ' ' || r == '-' || r == '_' || r == '.') {
return r
}
return -1
}, filename)
// Ensure we still have a valid filename
if filename == "" || filename == "." || filename == ".." {
filename = time.Now().UTC().Format("20060102150405")
}
// Properly encode the filename for Content-Disposition
encodedFilename := mime.QEncoding.Encode("utf-8", filename)
c.Header("Content-Disposition",
fmt.Sprintf(`attachment; filename="%s";`,
encodedFilename,
),
)
}
func (c *Common) requiresFlag(g *gin.Context, featureFlag string) {
// handle session
_, _, ok := c.handleSession(g)
if !ok {
return
}
c.Response.ServerErrorMessage(g, "requires "+featureFlag+" edition")
}
+26
View File
@@ -0,0 +1,26 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/service"
)
// Version is a controller
type Version struct {
Common
versionService *service.Version
}
// Get application version
func (c *Version) Get(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
version, err := c.versionService.Get(g.Request.Context(), session)
if ok := handleServerError(g, c.Response, err); !ok {
return
}
c.Response.OK(g, version)
}
+171
View File
@@ -0,0 +1,171 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
)
// WebhookColumnsMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var WebhookColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.WEBHOOK_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.WEBHOOK_TABLE, "updated_at"),
"name": repository.TableColumn(database.WEBHOOK_TABLE, "name"),
}
// Webhook is a controller
type Webhook struct {
Common
WebhookService *service.Webhook
}
// Create creates a new webhook
func (w *Webhook) Create(g *gin.Context) {
session, _, ok := w.handleSession(g)
if !ok {
return
}
// parse request
var req model.Webhook
if ok := w.handleParseRequest(g, &req); !ok {
return
}
// save webhook
id, err := w.WebhookService.Create(g.Request.Context(), session, &req)
// handle response
if ok := w.handleErrors(g, err); !ok {
return
}
w.Response.OK(
g,
gin.H{
"id": id.String(),
},
)
}
// GetAll gets the webhooks
func (w *Webhook) GetAll(g *gin.Context) {
session, _, ok := w.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := w.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
companyID := companyIDFromRequestQuery(g)
// get
webhooks, err := w.WebhookService.GetAll(
g.Request.Context(),
session,
companyID,
&repository.WebhookOption{
QueryArgs: queryArgs,
},
)
// handle response
if ok := w.handleErrors(g, err); !ok {
return
}
w.Response.OK(
g,
webhooks,
)
}
// GetByID gets a webhook by id
func (w *Webhook) GetByID(g *gin.Context) {
session, _, ok := w.handleSession(g)
if !ok {
return
}
// parse request
id, ok := w.handleParseIDParam(g)
if !ok {
return
}
// get
webhook, err := w.WebhookService.GetByID(
g.Request.Context(),
session,
id,
)
// handle response
if ok := w.handleErrors(g, err); !ok {
return
}
w.Response.OK(g, webhook)
}
// Update updates a webhook
func (w *Webhook) UpdateByID(g *gin.Context) {
session, _, ok := w.handleSession(g)
if !ok {
return
}
// parse request
id, ok := w.handleParseIDParam(g)
if !ok {
return
}
var req model.Webhook
if ok := w.handleParseRequest(g, &req); !ok {
return
}
// save
err := w.WebhookService.Update(g.Request.Context(), session, id, &req)
// handle response
if ok := w.handleErrors(g, err); !ok {
return
}
w.Response.OK(g, nil)
}
// DeleteByID deletes a webhook by id
func (w *Webhook) DeleteByID(g *gin.Context) {
session, _, ok := w.handleSession(g)
if !ok {
return
}
// parse request
id, ok := w.handleParseIDParam(g)
if !ok {
return
}
// delete
err := w.WebhookService.DeleteByID(g, session, id)
// handle response
if ok := w.handleErrors(g, err); !ok {
return
}
w.Response.OK(g, nil)
}
// SendTest sends a test webhook
func (w *Webhook) SendTest(g *gin.Context) {
session, _, ok := w.handleSession(g)
if !ok {
return
}
// parse request
id, ok := w.handleParseIDParam(g)
if !ok {
return
}
// send
data, err := w.WebhookService.SendTest(g.Request.Context(), session, id)
// handle response
if ok := w.handleErrors(g, err); !ok {
return
}
w.Response.OK(g, data)
}
+4
View File
@@ -0,0 +1,4 @@
package data
const ASSET_GLOBAL_FOLDER = "shared"
const ATTACHMENT_GLOBAL_FOLDER = "shared"
+36
View File
@@ -0,0 +1,36 @@
package data
const (
EVENT_CAMPAIGN_SCHEDULED = "campaign_scheduled"
EVENT_CAMPAIGN_ACTIVE = "campaign_active"
EVENT_CAMPAIGN_SELF_MANAGED = "campaign_self_managed"
EVENT_CAMPAIGN_CLOSED = "campaign_closed"
EVENT_CAMPAIGN_RECIPIENT_SCHEDULED = "campaign_recipient_scheduled"
EVENT_CAMPAIGN_RECIPIENT_MESSAGE_SENT = "campaign_recipient_message_sent"
EVENT_CAMPAIGN_RECIPIENT_MESSAGE_FAILED = "campaign_recipient_message_failed"
EVENT_CAMPAIGN_RECIPIENT_MESSAGE_READ = "campaign_recipient_message_read"
EVENT_CAMPAIGN_RECIPIENT_BEFORE_PAGE_VISITED = "campaign_recipient_before_page_visited"
EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED = "campaign_recipient_page_visited"
EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED = "campaign_recipient_after_page_visited"
EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA = "campaign_recipient_submitted_data"
EVENT_CAMPAIGN_RECIPIENT_CANCELLED = "campaign_recipient_cancelled"
)
var Events = []string{
// campaign events
EVENT_CAMPAIGN_SCHEDULED,
EVENT_CAMPAIGN_ACTIVE,
EVENT_CAMPAIGN_SELF_MANAGED,
EVENT_CAMPAIGN_CLOSED,
// campaign recipient events
EVENT_CAMPAIGN_RECIPIENT_SCHEDULED,
EVENT_CAMPAIGN_RECIPIENT_MESSAGE_SENT,
EVENT_CAMPAIGN_RECIPIENT_MESSAGE_FAILED,
EVENT_CAMPAIGN_RECIPIENT_MESSAGE_READ,
EVENT_CAMPAIGN_RECIPIENT_BEFORE_PAGE_VISITED,
EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED,
EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED,
EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA,
EVENT_CAMPAIGN_RECIPIENT_CANCELLED,
}
+15
View File
@@ -0,0 +1,15 @@
package data
const (
DefaultAdminCertDir = "certs/admin"
DefaultAdminPublicCertFileName = "public.pem"
DefaultAdminPrivateCertFileName = "private.pem"
)
const (
// default admin user
DefaultSacrificalAccountUsername = "admin"
DefaultSacrificalAccountName = "admin"
DefaultSacrificalAccountEmail = "admin@localhost.invalid" // RFC 2606
DefaultSacrificalCompanyName = "company"
)
+6
View File
@@ -0,0 +1,6 @@
package data
const (
MESSAGE_TYPE_EMAIL = "email"
MESSAGE_TYPE_SMS = "sms"
)
+26
View File
@@ -0,0 +1,26 @@
package data
const (
OptionKeyIsInstalled = "is_installed"
OptionValueIsInstalled = "true"
OptionValueIsNotInstalled = "false"
// KeyIsInstalled is the key for the is_installed option
OptionKeyInstanceID = "instance_id"
OptionKeyLogLevel = "log_level"
OptionKeyDBLogLevel = "db_log_level"
OptionKeyUsingSystemd = "systemd_install"
OptionValueUsingSystemdYes = "true"
OptionValueUsingSystemdNo = "false"
OptionKeyDevelopmentSeeded = "development_seeded"
OptionValueSeeded = "true"
OptionKeyMaxFileUploadSizeMB = "max_file_upload_size_mb"
OptionValueKeyMaxFileUploadSizeMBDefault = "100"
OptionKeyRepeatOffenderMonths = "repeat_offender_months"
OptionKeyAdminSSOLogin = "sso_login"
)
+8
View File
@@ -0,0 +1,8 @@
package data
const (
PAGE_TYPE_BEFORE = "before"
PAGE_TYPE_LANDING = "landing"
PAGE_TYPE_AFTER = "after"
PAGE_TYPE_DONE = "done"
)
+6
View File
@@ -0,0 +1,6 @@
package data
const (
// PERMISSION_ALLOW_GLOBAL allows all permissions, it is the god mode of permissions
PERMISSION_ALLOW_GLOBAL = "*"
)
+26
View File
@@ -0,0 +1,26 @@
package data
// This is name key for the different roles
const (
// RoleSystem is the system role
// is is reserved for system actions only
RoleSystem = "system"
// RoleSuperAdministrator is the super administrator role
// this role has access to everything a user can do
RoleSuperAdministrator = "superadministrator"
// RoleCompanyAdministrator is the company role
// this role had read access to their associated company
RoleCompanyUser = "companyuser"
)
// RolePermissions is a map of roles to their permissions
// these are the roles and their permissions
var RolePermissions = map[string][]string{
RoleSystem: {
PERMISSION_ALLOW_GLOBAL,
},
RoleSuperAdministrator: {
PERMISSION_ALLOW_GLOBAL,
},
RoleCompanyUser: {},
}
+8
View File
@@ -0,0 +1,8 @@
package data
const SessionCookieKey = "session"
const APIHeaderKey = "x-API"
const RequestAPISessionKey = "apiSession"
const SystemSessionID = "00000000-0000-0111-0777-000000000000"
const APISessionID = "00000000-0000-0100-0000-000000000000"
+11
View File
@@ -0,0 +1,11 @@
package data
import "github.com/phishingclub/phishingclub/build"
// GetCrmURL returns the URL for the CRM system depending on the environment
func GetCrmURL() string {
if build.Flags.Production {
return "https://user.phishing.club"
}
return "https://crm:8009"
}
+33
View File
@@ -0,0 +1,33 @@
package database
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
const (
ALLOW_DENY_TABLE = "allow_denies"
)
// AllowDeny is a gorm data model for allow deny listing
type AllowDeny struct {
ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`
CreatedAt *time.Time `gorm:"not null;index;"`
UpdatedAt *time.Time `gorm:"not null;index"`
CompanyID *uuid.UUID `gorm:"uniqueIndex:idx_allow_denies_unique_name_and_company_id;type:uuid"`
Name string `gorm:"not null;uniqueIndex:idx_allow_denies_unique_name_and_company_id;"`
Cidrs string `gorm:"not null;"`
Allowed bool `gorm:"not null;"`
}
func (AllowDeny) TableName() string {
return ALLOW_DENY_TABLE
}
func (e *AllowDeny) Migrate(db *gorm.DB) error {
// SQLITE
// ensure name + company id is unique
return UniqueIndexNameAndNullCompanyID(db, "allow_denies")
}
+48
View File
@@ -0,0 +1,48 @@
package database
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
const (
API_SENDER_TABLE = "api_senders"
)
type APISender struct {
ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`
CreatedAt *time.Time `gorm:"not null;index;"`
UpdatedAt *time.Time `gorm:"not null;index"`
Name string `gorm:"not null;uniqueIndex:idx_api_senders_name_company_id;"`
CompanyID *uuid.UUID `gorm:"uniqueIndex:idx_api_senders_name_company_id;type:uuid"`
// Extra fields
APIKey string
CustomField1 string
CustomField2 string
CustomField3 string
CustomField4 string
// Request fields
RequestMethod string
RequestURL string
RequestHeaders string
RequestBody string
// Response fields
ExpectedResponseStatusCode int
ExpectedResponseHeaders string
ExpectedResponseBody string
}
func (e *APISender) Migrate(db *gorm.DB) error {
// SQLITE
// ensure name + null company id is unique
return UniqueIndexNameAndNullCompanyID(db, "api_senders")
}
func (APISender) TableName() string {
return API_SENDER_TABLE
}
+26
View File
@@ -0,0 +1,26 @@
package database
import (
"time"
"github.com/google/uuid"
)
type APISenderHeader struct {
ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`
CreatedAt *time.Time `gorm:"not null;index;"`
UpdatedAt *time.Time `gorm:"not null;index"`
Key string `gorm:"not null;"`
Value string `gorm:"not null;"`
// IsRequestHeader is true if the header is a request header
// and false if it is a expected response header
IsRequestHeader bool `gorm:"not null;"`
// belongs to
APISenderID *uuid.UUID `gorm:"index;not null;type:uuid"`
}
func (APISenderHeader) TableName() string {
return "api_sender_headers"
}
+33
View File
@@ -0,0 +1,33 @@
package database
import (
"time"
"github.com/google/uuid"
)
const (
ASSET_TABLE = "assets"
)
// Asset is gorm data model
type Asset struct {
ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`
CreatedAt *time.Time `gorm:"not null;index;"`
UpdatedAt *time.Time `gorm:"not null;index;"`
// has one
DomainID *uuid.UUID `gorm:"index;type:uuid;"`
DomainName string
// can has one
CompanyID *uuid.UUID `gorm:"index;type:uuid;"`
Name string `gorm:";index"`
Description string `gorm:";"`
Path string `gorm:"not null;index"`
}
func (Asset) TableName() string {
return ASSET_TABLE
}
+33
View File
@@ -0,0 +1,33 @@
package database
import (
"time"
"github.com/google/uuid"
)
const (
ATTACHMENT_TABLE = "attachments"
)
// Attachment is gorm data model
type Attachment struct {
ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`
CreatedAt *time.Time `gorm:"not null;index;"`
UpdatedAt *time.Time `gorm:"not null;index;"`
// can has one
CompanyID *uuid.UUID `gorm:"index;type:uuid;"`
// many to many
Mails []Email `gorm:"many2many:message_attachments;"`
Name string `gorm:";index"`
Description string `gorm:";"`
Filename string `gorm:"not null;index"`
EmbeddedContent bool `gorm:"not null;default:false;index"`
}
func (Attachment) TableName() string {
return ATTACHMENT_TABLE
}
+76
View File
@@ -0,0 +1,76 @@
package database
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
const (
CAMPAIGN_TABLE = "campaigns"
)
// Campaign is gorm data model
type Campaign struct {
ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`
CreatedAt *time.Time `gorm:"not null;index;"`
UpdatedAt *time.Time `gorm:"not null;index;"`
CloseAt *time.Time `gorm:"index;"`
ClosedAt *time.Time `gorm:"index;"`
AnonymizeAt *time.Time `gorm:"index;"`
AnonymizedAt *time.Time `gorm:"index;"`
SortField string `gorm:";"`
SortOrder string `gorm:";"` // 'asc,desc,random'
SendStartAt *time.Time `gorm:"index;"`
SendEndAt *time.Time `gorm:"index;"`
// ConstraintWeekDays is a binary format.
// 0b00000001 = 1 = sunday
// 0b00000010 = 2 = monday
// 0b00000100 = 4 = tuesday
// 0b00001000 = 8 = ...
// 0b00010000 = 16 =
// 0b00100000 = 32 =
// 0b01000000 = 64 =
ConstraintWeekDays *int `gorm:";"`
// hh:mm
ConstraintStartTime *string `gorm:"index;"`
// hh:mm
ConstraintEndTime *string `gorm:"index;"`
SaveSubmittedData bool `gorm:"not null;default:false"`
IsAnonymous bool `gorm:"not null;default:false"`
IsTest bool `gorm:"not null;default:false"`
// has one
CampaignTemplateID *uuid.UUID `gorm:"index;type:uuid;"`
CampaignTemplate *CampaignTemplate
// can has one
CompanyID *uuid.UUID `gorm:"index;type:uuid;index;uniqueIndex:idx_campaigns_unique_name_and_company_id;"`
Company *Company
DenyPageID *uuid.UUID `gorm:"type:uuid;index;"`
DenyPage *Page `gorm:"foreignKey:DenyPageID;references:ID"`
// NotableEventID notable event for this campaign
NotableEvent *Event `gorm:"foreignKey:NotableEventID;references:ID"`
NotableEventID *uuid.UUID `gorm:"type:uuid;index"`
WebhookID *uuid.UUID `gorm:"type:uuid;index;"`
// has many-to-many
RecipientGroups []*RecipientGroup `gorm:"many2many:campaign_recipient_groups"`
AllowDeny []*AllowDeny `gorm:"many2many:campaign_allow_denies"`
Name string `gorm:"not null;uniqueIndex:idx_campaigns_unique_name_and_company_id"`
}
func (c *Campaign) Migrate(db *gorm.DB) error {
// SQLITE
// ensure name + company id is unique
return UniqueIndexNameAndNullCompanyID(db, "campaigns")
}
func (Campaign) TableName() string {
return CAMPAIGN_TABLE
}
+22
View File
@@ -0,0 +1,22 @@
package database
import (
"github.com/google/uuid"
)
const (
CAMPAIGN_ALLOW_DENY_TABLE = "campaign_allow_denies"
)
// CampaignAllowDeny is a gorm data model
// is a table of those allow deny lists that belong to a campaign
type CampaignAllowDeny struct {
CampaignID *uuid.UUID `gorm:"not null;index;type:uuid;uniqueIndex:idx_campaign_allow_denies;"`
Campaign *Campaign
AllowDenyID *uuid.UUID `gorm:"not null;index;type:uuid;uniqueIndex:idx_campaign_allow_denies;"`
AllowDeny *AllowDeny
}
func (CampaignAllowDeny) TableName() string {
return CAMPAIGN_ALLOW_DENY_TABLE
}
+52
View File
@@ -0,0 +1,52 @@
package database
import (
"reflect"
"time"
"github.com/google/uuid"
)
const (
CAMPAIGN_EVENT_TABLE = "campaign_events"
)
// Campaign is gorm data model
type CampaignEvent struct {
ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`
CreatedAt *time.Time `gorm:"not null;index;"`
UpdatedAt *time.Time `gorm:"not null;"`
// arbitrary data
Data string `gorm:"not null;"`
// has one
CampaignID *uuid.UUID `gorm:"not null;index;type:uuid;"`
EventID *uuid.UUID `gorm:"not null;index;type:uuid;"`
// can has one
UserAgent string `gorm:";"`
IPAddress string `gorm:";"`
// AnonymizedID is set when the recipient has been anonymized
AnonymizedID *uuid.UUID `gorm:"type:uuid;index;"`
// if null either the event has no recipient or the recipient has been anonymized
RecipientID *uuid.UUID `gorm:"index;type:uuid;"`
Recipient *Recipient
CompanyID *uuid.UUID `gorm:"index;type:uuid;index;"`
}
// RecipientCampaignEvent is a aggregated read-only model
type RecipientCampaignEvent struct {
CampaignEvent
Name string // event name
CampaignName string
}
func (CampaignEvent) TableName() string {
return CAMPAIGN_EVENT_TABLE
}
var _ = reflect.TypeOf(RecipientCampaignEvent{})
+52
View File
@@ -0,0 +1,52 @@
package database
import (
"time"
"github.com/google/uuid"
)
const (
CAMPAIGN_RECIPIENT_TABLE_NAME = "campaign_recipients"
)
// CampaigReciever is gorm data model
// this model/table is primarily used to keep track of who and when should recieve a campaign
type CampaignRecipient struct {
ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`
CreatedAt *time.Time `gorm:"not null;index;"`
UpdatedAt *time.Time `gorm:"not null;index;"`
Campaign *Campaign
CampaignID *uuid.UUID `gorm:"not null;type:uuid;uniqueIndex:idx_campaign_recipients_campaign_id_recipient_id;"`
// CancelledAt *time.Time `gorm:"index;"`
CancelledAt *time.Time `gorm:"index;"`
// when it should be send
SendAt *time.Time `gorm:"index;"`
// when it was last attempted send
LastAttemptAt *time.Time `gorm:"index;"`
// when it was sent
SentAt *time.Time `gorm:"index;"`
// self-managed
SelfManaged bool `gorm:"not null;default:false;"`
// AnonymizedID is set when the recipient has been anonymized
AnonymizedID *uuid.UUID `gorm:"type:uuid;"`
Recipient *Recipient
// A null recipientID means that the data has been anonymized
RecipientID *uuid.UUID `gorm:"type:uuid;index;uniqueIndex:idx_campaign_recipients_campaign_id_recipient_id;"`
// NotableEventID is the most notable event for this recipient
NotableEvent *Event `gorm:"foreignKey:NotableEventID;references:ID"`
NotableEventID *uuid.UUID `gorm:"type:uuid;index"`
}
func (CampaignRecipient) TableName() string {
return CAMPAIGN_RECIPIENT_TABLE_NAME
}
@@ -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"
}
+51
View File
@@ -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
}
+72
View File
@@ -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
}
+26
View File
@@ -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
}
+32
View File
@@ -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
}
+47
View File
@@ -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
}
+16
View File
@@ -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"
}
+21
View File
@@ -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
}
+43
View File
@@ -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
}
+18
View File
@@ -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
}
+16
View File
@@ -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"
}
+35
View File
@@ -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
}
+42
View File
@@ -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