All checks were successful
continuous-integration/drone/push Build is passing
Split api/openapi.yaml schemas into api/schemas.yaml so structs can be generated independently from the API server code. Models now generate into internal/model/ via oapi-codegen, with the server referencing them through import-mapping. Moved PtrTo helper to internal/utils and removed storage.ReportSummary in favor of model.TestSummary.
249 lines
7.5 KiB
Go
249 lines
7.5 KiB
Go
// This file is part of the happyDeliver (R) project.
|
|
// Copyright (c) 2025 happyDomain
|
|
// Authors: Pierre-Olivier Mercier, et al.
|
|
//
|
|
// This program is offered under a commercial and under the AGPL license.
|
|
// For commercial licensing, contact us at <contact@happydomain.org>.
|
|
//
|
|
// For AGPL licensing:
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
package storage
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"gorm.io/driver/postgres"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
|
|
"git.happydns.org/happyDeliver/internal/model"
|
|
"git.happydns.org/happyDeliver/internal/utils"
|
|
)
|
|
|
|
var (
|
|
ErrNotFound = errors.New("not found")
|
|
ErrAlreadyExists = errors.New("already exists")
|
|
)
|
|
|
|
// Storage interface defines operations for persisting and retrieving data
|
|
type Storage interface {
|
|
// Report operations
|
|
CreateReport(testID uuid.UUID, rawEmail []byte, reportJSON []byte) (*Report, error)
|
|
GetReport(testID uuid.UUID) (reportJSON []byte, rawEmail []byte, err error)
|
|
ReportExists(testID uuid.UUID) (bool, error)
|
|
UpdateReport(testID uuid.UUID, reportJSON []byte) error
|
|
DeleteOldReports(olderThan time.Time) (int64, error)
|
|
ListReportSummaries(offset, limit int) ([]model.TestSummary, int64, error)
|
|
|
|
// Close closes the database connection
|
|
Close() error
|
|
}
|
|
|
|
// DBStorage implements Storage using GORM
|
|
type DBStorage struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
// NewStorage creates a new storage instance based on database type
|
|
func NewStorage(dbType, dsn string) (Storage, error) {
|
|
var dialector gorm.Dialector
|
|
|
|
switch dbType {
|
|
case "sqlite":
|
|
dialector = sqlite.Open(dsn)
|
|
case "postgres":
|
|
dialector = postgres.Open(dsn)
|
|
default:
|
|
return nil, fmt.Errorf("unsupported database type: %s", dbType)
|
|
}
|
|
|
|
db, err := gorm.Open(dialector, &gorm.Config{})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
|
}
|
|
|
|
// Auto-migrate the schema
|
|
if err := db.AutoMigrate(&Report{}); err != nil {
|
|
return nil, fmt.Errorf("failed to migrate database schema: %w", err)
|
|
}
|
|
|
|
return &DBStorage{db: db}, nil
|
|
}
|
|
|
|
// CreateReport creates a new report for a test
|
|
func (s *DBStorage) CreateReport(testID uuid.UUID, rawEmail []byte, reportJSON []byte) (*Report, error) {
|
|
dbReport := &Report{
|
|
TestID: testID,
|
|
RawEmail: rawEmail,
|
|
ReportJSON: reportJSON,
|
|
}
|
|
|
|
if err := s.db.Create(dbReport).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to create report: %w", err)
|
|
}
|
|
|
|
return dbReport, nil
|
|
}
|
|
|
|
// ReportExists checks if a report exists for the given test ID
|
|
func (s *DBStorage) ReportExists(testID uuid.UUID) (bool, error) {
|
|
var count int64
|
|
if err := s.db.Model(&Report{}).Where("test_id = ?", testID).Count(&count).Error; err != nil {
|
|
return false, fmt.Errorf("failed to check report existence: %w", err)
|
|
}
|
|
return count > 0, nil
|
|
}
|
|
|
|
// GetReport retrieves a report by test ID, returning the raw JSON and email bytes
|
|
func (s *DBStorage) GetReport(testID uuid.UUID) ([]byte, []byte, error) {
|
|
var dbReport Report
|
|
if err := s.db.Where("test_id = ?", testID).Order("created_at DESC").First(&dbReport).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, nil, ErrNotFound
|
|
}
|
|
return nil, nil, fmt.Errorf("failed to get report: %w", err)
|
|
}
|
|
|
|
return dbReport.ReportJSON, dbReport.RawEmail, nil
|
|
}
|
|
|
|
// UpdateReport updates the report JSON for an existing test ID
|
|
func (s *DBStorage) UpdateReport(testID uuid.UUID, reportJSON []byte) error {
|
|
result := s.db.Model(&Report{}).Where("test_id = ?", testID).Update("report_json", reportJSON)
|
|
if result.Error != nil {
|
|
return fmt.Errorf("failed to update report: %w", result.Error)
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteOldReports deletes reports older than the specified time
|
|
func (s *DBStorage) DeleteOldReports(olderThan time.Time) (int64, error) {
|
|
result := s.db.Where("created_at < ?", olderThan).Delete(&Report{})
|
|
if result.Error != nil {
|
|
return 0, fmt.Errorf("failed to delete old reports: %w", result.Error)
|
|
}
|
|
return result.RowsAffected, nil
|
|
}
|
|
|
|
// reportSummaryRow is used internally to scan SQL results before converting to model.TestSummary
|
|
type reportSummaryRow struct {
|
|
TestID uuid.UUID
|
|
Score int
|
|
Grade string
|
|
FromDomain string
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// ListReportSummaries returns a paginated list of lightweight report summaries
|
|
func (s *DBStorage) ListReportSummaries(offset, limit int) ([]model.TestSummary, int64, error) {
|
|
var total int64
|
|
if err := s.db.Model(&Report{}).Count(&total).Error; err != nil {
|
|
return nil, 0, fmt.Errorf("failed to count reports: %w", err)
|
|
}
|
|
|
|
if total == 0 {
|
|
return []model.TestSummary{}, 0, nil
|
|
}
|
|
|
|
var selectExpr string
|
|
switch s.db.Dialector.Name() {
|
|
case "postgres":
|
|
selectExpr = `test_id, ` +
|
|
`(convert_from(report_json, 'UTF8')::jsonb->>'score')::int as score, ` +
|
|
`convert_from(report_json, 'UTF8')::jsonb->>'grade' as grade, ` +
|
|
`convert_from(report_json, 'UTF8')::jsonb->'dns_results'->>'from_domain' as from_domain, ` +
|
|
`created_at`
|
|
case "sqlite":
|
|
selectExpr = `test_id, ` +
|
|
`json_extract(report_json, '$.score') as score, ` +
|
|
`json_extract(report_json, '$.grade') as grade, ` +
|
|
`json_extract(report_json, '$.dns_results.from_domain') as from_domain, ` +
|
|
`created_at`
|
|
default:
|
|
return nil, 0, fmt.Errorf("history tests list not implemented in this database dialect")
|
|
}
|
|
|
|
var rows []reportSummaryRow
|
|
err := s.db.Model(&Report{}).
|
|
Select(selectExpr).
|
|
Order("created_at DESC").
|
|
Offset(offset).
|
|
Limit(limit).
|
|
Scan(&rows).Error
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("failed to list report summaries: %w", err)
|
|
}
|
|
|
|
summaries := make([]model.TestSummary, 0, len(rows))
|
|
for _, r := range rows {
|
|
s := model.TestSummary{
|
|
TestId: utils.UUIDToBase32(r.TestID),
|
|
Score: r.Score,
|
|
Grade: model.TestSummaryGrade(r.Grade),
|
|
CreatedAt: r.CreatedAt,
|
|
}
|
|
if r.FromDomain != "" {
|
|
s.FromDomain = utils.PtrTo(r.FromDomain)
|
|
}
|
|
summaries = append(summaries, s)
|
|
}
|
|
|
|
return summaries, total, nil
|
|
}
|
|
|
|
// Close closes the database connection
|
|
func (s *DBStorage) Close() error {
|
|
sqlDB, err := s.db.DB()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return sqlDB.Close()
|
|
}
|
|
|
|
// GetAllReports retrieves all reports from the database
|
|
func GetAllReports(s Storage) ([]Report, error) {
|
|
dbStorage, ok := s.(*DBStorage)
|
|
if !ok {
|
|
return nil, fmt.Errorf("storage type does not support GetAllReports")
|
|
}
|
|
|
|
var reports []Report
|
|
if err := dbStorage.db.Find(&reports).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to retrieve reports: %w", err)
|
|
}
|
|
|
|
return reports, nil
|
|
}
|
|
|
|
// CreateReportFromBackup creates a report from backup data, preserving timestamps
|
|
func CreateReportFromBackup(s Storage, report *Report) (*Report, error) {
|
|
dbStorage, ok := s.(*DBStorage)
|
|
if !ok {
|
|
return nil, fmt.Errorf("storage type does not support CreateReportFromBackup")
|
|
}
|
|
|
|
// Use Create to insert the report with all fields including timestamps
|
|
if err := dbStorage.db.Create(report).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to create report from backup: %w", err)
|
|
}
|
|
|
|
return report, nil
|
|
}
|