diff --git a/.gitignore b/.gitignore index 2c32cca..ab428f0 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ client_secret*.json /docker.secret /samples/ /docs/reports/ +/prompts/ diff --git a/backend/cmd/community/main.go b/backend/cmd/community/main.go index fa95b1a..2e8a614 100644 --- a/backend/cmd/community/main.go +++ b/backend/cmd/community/main.go @@ -45,22 +45,18 @@ func main() { "build_date", BuildDate, "telemetry", cfg.Telemetry) - // Initialize DB db, err := database.InitDB(ctx, database.Config{DSN: cfg.Database.DSN}) if err != nil { log.Fatalf("failed to initialize database: %v", err) } - // Initialize tenant provider tenantProvider, err := tenant.NewSingleTenantProviderWithContext(ctx, db) if err != nil { log.Fatalf("failed to initialize tenant provider: %v", err) } - // Create OAuth session repository oauthSessionRepo := database.NewOAuthSessionRepository(db, tenantProvider) - // Create OAuth service (internal infrastructure) var oauthService *auth.OauthService if cfg.Auth.OAuthEnabled || cfg.Auth.MagicLinkEnabled { oauthService = auth.NewOAuthService(auth.Config{ @@ -79,10 +75,7 @@ func main() { }) } - // Create OAuth provider adapter oauthProvider := webauth.NewOAuthProvider(oauthService, cfg.Auth.OAuthEnabled) - - // Create Authorizer authorizer := webauth.NewSimpleAuthorizer(cfg.App.AdminEmails, cfg.App.OnlyAdminCanCreate) // === Build Server === @@ -97,7 +90,6 @@ func main() { log.Fatalf("Failed to create server: %v", err) } - // Start server go func() { log.Printf("Community Edition server starting on %s", server.GetAddr()) if err := server.Start(); err != nil && !errors.Is(err, http.ErrServerClosed) { @@ -105,7 +97,6 @@ func main() { } }() - // Wait for shutdown signal quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit diff --git a/backend/cmd/migrate/main.go b/backend/cmd/migrate/main.go index d1293c3..5e8fc03 100644 --- a/backend/cmd/migrate/main.go +++ b/backend/cmd/migrate/main.go @@ -157,7 +157,6 @@ func ensureAppRole(db *sql.DB) error { return nil } - // Check if role exists var exists bool err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM pg_roles WHERE rolname = 'ackify_app')").Scan(&exists) if err != nil { @@ -165,14 +164,12 @@ func ensureAppRole(db *sql.DB) error { } if exists { - // Update password to ensure it matches environment _, err = db.Exec(fmt.Sprintf("ALTER ROLE ackify_app WITH PASSWORD '%s'", escapePassword(password))) if err != nil { return fmt.Errorf("failed to update ackify_app password: %w", err) } log.Println("ackify_app role exists, password updated") } else { - // Create the role with all necessary attributes createSQL := fmt.Sprintf(` CREATE ROLE ackify_app WITH LOGIN diff --git a/backend/internal/application/services/document_service.go b/backend/internal/application/services/document_service.go index 7d82d1c..289e6d8 100644 --- a/backend/internal/application/services/document_service.go +++ b/backend/internal/application/services/document_service.go @@ -23,6 +23,9 @@ type documentRepository interface { List(ctx context.Context, limit, offset int) ([]*models.Document, error) Search(ctx context.Context, query string, limit, offset int) ([]*models.Document, error) Count(ctx context.Context, searchQuery string) (int, error) + ListByCreatedBy(ctx context.Context, createdBy string, limit, offset int) ([]*models.Document, error) + SearchByCreatedBy(ctx context.Context, createdBy, searchQuery string, limit, offset int) ([]*models.Document, error) + CountByCreatedBy(ctx context.Context, createdBy, searchQuery string) (int, error) } type docExpectedSignerRepository interface { @@ -497,3 +500,18 @@ func (s *DocumentService) ListExpectedSigners(ctx context.Context, docID string) } return s.expectedSignerRepo.ListByDocID(ctx, docID) } + +// ListByCreatedBy retrieves a paginated list of documents created by a specific user +func (s *DocumentService) ListByCreatedBy(ctx context.Context, createdBy string, limit, offset int) ([]*models.Document, error) { + return s.repo.ListByCreatedBy(ctx, createdBy, limit, offset) +} + +// SearchByCreatedBy performs a search query across documents created by a specific user +func (s *DocumentService) SearchByCreatedBy(ctx context.Context, createdBy, query string, limit, offset int) ([]*models.Document, error) { + return s.repo.SearchByCreatedBy(ctx, createdBy, query, limit, offset) +} + +// CountByCreatedBy returns the total number of documents created by a specific user +func (s *DocumentService) CountByCreatedBy(ctx context.Context, createdBy, searchQuery string) (int, error) { + return s.repo.CountByCreatedBy(ctx, createdBy, searchQuery) +} diff --git a/backend/internal/domain/models/document.go b/backend/internal/domain/models/document.go index 0fefe08..8a5417f 100644 --- a/backend/internal/domain/models/document.go +++ b/backend/internal/domain/models/document.go @@ -16,6 +16,10 @@ type Document struct { Checksum string `json:"checksum" db:"checksum"` ChecksumAlgorithm string `json:"checksum_algorithm" db:"checksum_algorithm"` Description string `json:"description" db:"description"` + ReadMode string `json:"read_mode" db:"read_mode"` + AllowDownload bool `json:"allow_download" db:"allow_download"` + RequireFullRead bool `json:"require_full_read" db:"require_full_read"` + VerifyChecksum bool `json:"verify_checksum" db:"verify_checksum"` CreatedAt time.Time `json:"created_at" db:"created_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"` CreatedBy string `json:"created_by" db:"created_by"` @@ -29,6 +33,10 @@ type DocumentInput struct { Checksum string `json:"checksum"` ChecksumAlgorithm string `json:"checksum_algorithm"` Description string `json:"description"` + ReadMode string `json:"read_mode"` + AllowDownload *bool `json:"allow_download"` + RequireFullRead *bool `json:"require_full_read"` + VerifyChecksum *bool `json:"verify_checksum"` } // HasChecksum returns true if the document has a checksum configured diff --git a/backend/internal/infrastructure/database/document_repository.go b/backend/internal/infrastructure/database/document_repository.go index 23f0ee3..53d480a 100644 --- a/backend/internal/infrastructure/database/document_repository.go +++ b/backend/internal/infrastructure/database/document_repository.go @@ -31,9 +31,9 @@ func (r *DocumentRepository) Create(ctx context.Context, docID string, input mod } query := ` - INSERT INTO documents (tenant_id, doc_id, title, url, checksum, checksum_algorithm, description, created_by) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at + INSERT INTO documents (tenant_id, doc_id, title, url, checksum, checksum_algorithm, description, read_mode, allow_download, require_full_read, verify_checksum, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + RETURNING doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, read_mode, allow_download, require_full_read, verify_checksum, created_at, updated_at, created_by, deleted_at ` // Use NULL for empty checksum fields to avoid constraint violation @@ -46,6 +46,26 @@ func (r *DocumentRepository) Create(ctx context.Context, docID string, input mod checksumAlgorithm = "SHA-256" } + // Handle read_mode with default + readMode := input.ReadMode + if readMode == "" { + readMode = "integrated" + } + + // Handle boolean defaults + allowDownload := true + if input.AllowDownload != nil { + allowDownload = *input.AllowDownload + } + requireFullRead := false + if input.RequireFullRead != nil { + requireFullRead = *input.RequireFullRead + } + verifyChecksum := true + if input.VerifyChecksum != nil { + verifyChecksum = *input.VerifyChecksum + } + doc := &models.Document{} err = dbctx.GetQuerier(ctx, r.db).QueryRowContext( ctx, @@ -57,6 +77,10 @@ func (r *DocumentRepository) Create(ctx context.Context, docID string, input mod checksum, checksumAlgorithm, input.Description, + readMode, + allowDownload, + requireFullRead, + verifyChecksum, createdBy, ).Scan( &doc.DocID, @@ -66,6 +90,10 @@ func (r *DocumentRepository) Create(ctx context.Context, docID string, input mod &doc.Checksum, &doc.ChecksumAlgorithm, &doc.Description, + &doc.ReadMode, + &doc.AllowDownload, + &doc.RequireFullRead, + &doc.VerifyChecksum, &doc.CreatedAt, &doc.UpdatedAt, &doc.CreatedBy, @@ -84,7 +112,7 @@ func (r *DocumentRepository) Create(ctx context.Context, docID string, input mod // RLS policy automatically filters by tenant_id func (r *DocumentRepository) GetByDocID(ctx context.Context, docID string) (*models.Document, error) { query := ` - SELECT doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at + SELECT doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, read_mode, allow_download, require_full_read, verify_checksum, created_at, updated_at, created_by, deleted_at FROM documents WHERE doc_id = $1 AND deleted_at IS NULL ` @@ -98,6 +126,10 @@ func (r *DocumentRepository) GetByDocID(ctx context.Context, docID string) (*mod &doc.Checksum, &doc.ChecksumAlgorithm, &doc.Description, + &doc.ReadMode, + &doc.AllowDownload, + &doc.RequireFullRead, + &doc.VerifyChecksum, &doc.CreatedAt, &doc.UpdatedAt, &doc.CreatedBy, @@ -126,7 +158,7 @@ func (r *DocumentRepository) FindByReference(ctx context.Context, ref string, re case "url": // Search by URL field (excluding soft-deleted) query = ` - SELECT doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at + SELECT doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, read_mode, allow_download, require_full_read, verify_checksum, created_at, updated_at, created_by, deleted_at FROM documents WHERE url = $1 AND deleted_at IS NULL LIMIT 1 @@ -136,7 +168,7 @@ func (r *DocumentRepository) FindByReference(ctx context.Context, ref string, re case "path": // Search by URL field (paths are also stored in url field, excluding soft-deleted) query = ` - SELECT doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at + SELECT doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, read_mode, allow_download, require_full_read, verify_checksum, created_at, updated_at, created_by, deleted_at FROM documents WHERE url = $1 AND deleted_at IS NULL LIMIT 1 @@ -146,7 +178,7 @@ func (r *DocumentRepository) FindByReference(ctx context.Context, ref string, re case "reference": // Search by doc_id (excluding soft-deleted) query = ` - SELECT doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at + SELECT doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, read_mode, allow_download, require_full_read, verify_checksum, created_at, updated_at, created_by, deleted_at FROM documents WHERE doc_id = $1 AND deleted_at IS NULL LIMIT 1 @@ -166,6 +198,10 @@ func (r *DocumentRepository) FindByReference(ctx context.Context, ref string, re &doc.Checksum, &doc.ChecksumAlgorithm, &doc.Description, + &doc.ReadMode, + &doc.AllowDownload, + &doc.RequireFullRead, + &doc.VerifyChecksum, &doc.CreatedAt, &doc.UpdatedAt, &doc.CreatedBy, @@ -200,9 +236,9 @@ func (r *DocumentRepository) FindByReference(ctx context.Context, ref string, re func (r *DocumentRepository) Update(ctx context.Context, docID string, input models.DocumentInput) (*models.Document, error) { query := ` UPDATE documents - SET title = $2, url = $3, checksum = $4, checksum_algorithm = $5, description = $6 + SET title = $2, url = $3, checksum = $4, checksum_algorithm = $5, description = $6, read_mode = $7, allow_download = $8, require_full_read = $9, verify_checksum = $10 WHERE doc_id = $1 AND deleted_at IS NULL - RETURNING doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at + RETURNING doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, read_mode, allow_download, require_full_read, verify_checksum, created_at, updated_at, created_by, deleted_at ` // Use empty string for empty checksum fields (table has NOT NULL DEFAULT '') @@ -212,6 +248,26 @@ func (r *DocumentRepository) Update(ctx context.Context, docID string, input mod checksumAlgorithm = "SHA-256" // Default algorithm } + // Handle read_mode with default + readMode := input.ReadMode + if readMode == "" { + readMode = "integrated" + } + + // Handle boolean defaults + allowDownload := true + if input.AllowDownload != nil { + allowDownload = *input.AllowDownload + } + requireFullRead := false + if input.RequireFullRead != nil { + requireFullRead = *input.RequireFullRead + } + verifyChecksum := true + if input.VerifyChecksum != nil { + verifyChecksum = *input.VerifyChecksum + } + doc := &models.Document{} err := dbctx.GetQuerier(ctx, r.db).QueryRowContext( ctx, @@ -222,6 +278,10 @@ func (r *DocumentRepository) Update(ctx context.Context, docID string, input mod checksum, checksumAlgorithm, input.Description, + readMode, + allowDownload, + requireFullRead, + verifyChecksum, ).Scan( &doc.DocID, &doc.TenantID, @@ -230,6 +290,10 @@ func (r *DocumentRepository) Update(ctx context.Context, docID string, input mod &doc.Checksum, &doc.ChecksumAlgorithm, &doc.Description, + &doc.ReadMode, + &doc.AllowDownload, + &doc.RequireFullRead, + &doc.VerifyChecksum, &doc.CreatedAt, &doc.UpdatedAt, &doc.CreatedBy, @@ -256,16 +320,20 @@ func (r *DocumentRepository) CreateOrUpdate(ctx context.Context, docID string, i } query := ` - INSERT INTO documents (tenant_id, doc_id, title, url, checksum, checksum_algorithm, description, created_by) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + INSERT INTO documents (tenant_id, doc_id, title, url, checksum, checksum_algorithm, description, read_mode, allow_download, require_full_read, verify_checksum, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (doc_id) DO UPDATE SET title = EXCLUDED.title, url = EXCLUDED.url, checksum = EXCLUDED.checksum, checksum_algorithm = EXCLUDED.checksum_algorithm, description = EXCLUDED.description, + read_mode = EXCLUDED.read_mode, + allow_download = EXCLUDED.allow_download, + require_full_read = EXCLUDED.require_full_read, + verify_checksum = EXCLUDED.verify_checksum, deleted_at = NULL - RETURNING doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at + RETURNING doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, read_mode, allow_download, require_full_read, verify_checksum, created_at, updated_at, created_by, deleted_at ` // Use empty string for empty checksum fields (table has NOT NULL DEFAULT '') @@ -275,6 +343,26 @@ func (r *DocumentRepository) CreateOrUpdate(ctx context.Context, docID string, i checksumAlgorithm = "SHA-256" // Default algorithm } + // Handle read_mode with default + readMode := input.ReadMode + if readMode == "" { + readMode = "integrated" + } + + // Handle boolean defaults + allowDownload := true + if input.AllowDownload != nil { + allowDownload = *input.AllowDownload + } + requireFullRead := false + if input.RequireFullRead != nil { + requireFullRead = *input.RequireFullRead + } + verifyChecksum := true + if input.VerifyChecksum != nil { + verifyChecksum = *input.VerifyChecksum + } + doc := &models.Document{} err = dbctx.GetQuerier(ctx, r.db).QueryRowContext( ctx, @@ -286,6 +374,10 @@ func (r *DocumentRepository) CreateOrUpdate(ctx context.Context, docID string, i checksum, checksumAlgorithm, input.Description, + readMode, + allowDownload, + requireFullRead, + verifyChecksum, createdBy, ).Scan( &doc.DocID, @@ -295,6 +387,10 @@ func (r *DocumentRepository) CreateOrUpdate(ctx context.Context, docID string, i &doc.Checksum, &doc.ChecksumAlgorithm, &doc.Description, + &doc.ReadMode, + &doc.AllowDownload, + &doc.RequireFullRead, + &doc.VerifyChecksum, &doc.CreatedAt, &doc.UpdatedAt, &doc.CreatedBy, @@ -336,7 +432,7 @@ func (r *DocumentRepository) Delete(ctx context.Context, docID string) error { // RLS policy automatically filters by tenant_id func (r *DocumentRepository) List(ctx context.Context, limit, offset int) ([]*models.Document, error) { query := ` - SELECT doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at + SELECT doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, read_mode, allow_download, require_full_read, verify_checksum, created_at, updated_at, created_by, deleted_at FROM documents WHERE deleted_at IS NULL ORDER BY created_at DESC @@ -361,6 +457,10 @@ func (r *DocumentRepository) List(ctx context.Context, limit, offset int) ([]*mo &doc.Checksum, &doc.ChecksumAlgorithm, &doc.Description, + &doc.ReadMode, + &doc.AllowDownload, + &doc.RequireFullRead, + &doc.VerifyChecksum, &doc.CreatedAt, &doc.UpdatedAt, &doc.CreatedBy, @@ -385,7 +485,7 @@ func (r *DocumentRepository) List(ctx context.Context, limit, offset int) ([]*mo // RLS policy automatically filters by tenant_id func (r *DocumentRepository) Search(ctx context.Context, query string, limit, offset int) ([]*models.Document, error) { searchQuery := ` - SELECT doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at + SELECT doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, read_mode, allow_download, require_full_read, verify_checksum, created_at, updated_at, created_by, deleted_at FROM documents WHERE deleted_at IS NULL AND ( @@ -417,6 +517,10 @@ func (r *DocumentRepository) Search(ctx context.Context, query string, limit, of &doc.Checksum, &doc.ChecksumAlgorithm, &doc.Description, + &doc.ReadMode, + &doc.AllowDownload, + &doc.RequireFullRead, + &doc.VerifyChecksum, &doc.CreatedAt, &doc.UpdatedAt, &doc.CreatedBy, @@ -482,3 +586,160 @@ func (r *DocumentRepository) Count(ctx context.Context, searchQuery string) (int logger.Logger.Debug("Document count completed", "count", count, "search", searchQuery) return count, nil } + +// ListByCreatedBy retrieves paginated documents created by a specific user (excluding soft-deleted) +// RLS policy automatically filters by tenant_id +func (r *DocumentRepository) ListByCreatedBy(ctx context.Context, createdBy string, limit, offset int) ([]*models.Document, error) { + query := ` + SELECT doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, read_mode, allow_download, require_full_read, verify_checksum, created_at, updated_at, created_by, deleted_at + FROM documents + WHERE deleted_at IS NULL AND created_by = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3 + ` + + rows, err := dbctx.GetQuerier(ctx, r.db).QueryContext(ctx, query, createdBy, limit, offset) + if err != nil { + logger.Logger.Error("Failed to list documents by creator", "error", err.Error(), "created_by", createdBy) + return nil, fmt.Errorf("failed to list documents: %w", err) + } + defer rows.Close() + + documents := []*models.Document{} + for rows.Next() { + doc := &models.Document{} + err := rows.Scan( + &doc.DocID, + &doc.TenantID, + &doc.Title, + &doc.URL, + &doc.Checksum, + &doc.ChecksumAlgorithm, + &doc.Description, + &doc.ReadMode, + &doc.AllowDownload, + &doc.RequireFullRead, + &doc.VerifyChecksum, + &doc.CreatedAt, + &doc.UpdatedAt, + &doc.CreatedBy, + &doc.DeletedAt, + ) + if err != nil { + logger.Logger.Error("Failed to scan document row", "error", err.Error()) + return nil, fmt.Errorf("failed to scan document: %w", err) + } + documents = append(documents, doc) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating documents: %w", err) + } + + return documents, nil +} + +// SearchByCreatedBy retrieves paginated documents matching search query created by a specific user (excluding soft-deleted) +// RLS policy automatically filters by tenant_id +func (r *DocumentRepository) SearchByCreatedBy(ctx context.Context, createdBy, searchQuery string, limit, offset int) ([]*models.Document, error) { + query := ` + SELECT doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, read_mode, allow_download, require_full_read, verify_checksum, created_at, updated_at, created_by, deleted_at + FROM documents + WHERE deleted_at IS NULL AND created_by = $1 + AND ( + doc_id ILIKE $2 + OR title ILIKE $2 + OR url ILIKE $2 + OR description ILIKE $2 + ) + ORDER BY created_at DESC + LIMIT $3 OFFSET $4 + ` + + searchPattern := "%" + searchQuery + "%" + rows, err := dbctx.GetQuerier(ctx, r.db).QueryContext(ctx, query, createdBy, searchPattern, limit, offset) + if err != nil { + logger.Logger.Error("Failed to search documents by creator", "error", err.Error(), "created_by", createdBy, "query", searchQuery) + return nil, fmt.Errorf("failed to search documents: %w", err) + } + defer rows.Close() + + documents := []*models.Document{} + for rows.Next() { + doc := &models.Document{} + err := rows.Scan( + &doc.DocID, + &doc.TenantID, + &doc.Title, + &doc.URL, + &doc.Checksum, + &doc.ChecksumAlgorithm, + &doc.Description, + &doc.ReadMode, + &doc.AllowDownload, + &doc.RequireFullRead, + &doc.VerifyChecksum, + &doc.CreatedAt, + &doc.UpdatedAt, + &doc.CreatedBy, + &doc.DeletedAt, + ) + if err != nil { + logger.Logger.Error("Failed to scan document row", "error", err.Error()) + return nil, fmt.Errorf("failed to scan document: %w", err) + } + documents = append(documents, doc) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating documents: %w", err) + } + + logger.Logger.Debug("Document search by creator completed", + "created_by", createdBy, + "query", searchQuery, + "results", len(documents), + "limit", limit, + "offset", offset) + + return documents, nil +} + +// CountByCreatedBy returns the total number of documents created by a specific user (excluding soft-deleted) +func (r *DocumentRepository) CountByCreatedBy(ctx context.Context, createdBy, searchQuery string) (int, error) { + var query string + var args []interface{} + + if searchQuery != "" { + query = ` + SELECT COUNT(*) + FROM documents + WHERE deleted_at IS NULL AND created_by = $1 + AND ( + doc_id ILIKE $2 + OR title ILIKE $2 + OR url ILIKE $2 + OR description ILIKE $2 + ) + ` + searchPattern := "%" + searchQuery + "%" + args = []interface{}{createdBy, searchPattern} + } else { + query = ` + SELECT COUNT(*) + FROM documents + WHERE deleted_at IS NULL AND created_by = $1 + ` + args = []interface{}{createdBy} + } + + var count int + err := dbctx.GetQuerier(ctx, r.db).QueryRowContext(ctx, query, args...).Scan(&count) + if err != nil { + logger.Logger.Error("Failed to count documents by creator", "error", err.Error(), "created_by", createdBy, "search", searchQuery) + return 0, fmt.Errorf("failed to count documents: %w", err) + } + + logger.Logger.Debug("Document count by creator completed", "count", count, "created_by", createdBy, "search", searchQuery) + return count, nil +} diff --git a/backend/internal/presentation/api/admin/handler.go b/backend/internal/presentation/api/admin/handler.go index c97f833..20a8935 100644 --- a/backend/internal/presentation/api/admin/handler.go +++ b/backend/internal/presentation/api/admin/handler.go @@ -73,6 +73,10 @@ type DocumentResponse struct { Checksum string `json:"checksum,omitempty"` ChecksumAlgorithm string `json:"checksumAlgorithm,omitempty"` Description string `json:"description"` + ReadMode string `json:"readMode"` + AllowDownload bool `json:"allowDownload"` + RequireFullRead bool `json:"requireFullRead"` + VerifyChecksum bool `json:"verifyChecksum"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` CreatedBy string `json:"createdBy"` @@ -328,6 +332,10 @@ func toDocumentResponse(doc *models.Document) *DocumentResponse { Checksum: doc.Checksum, ChecksumAlgorithm: doc.ChecksumAlgorithm, Description: doc.Description, + ReadMode: doc.ReadMode, + AllowDownload: doc.AllowDownload, + RequireFullRead: doc.RequireFullRead, + VerifyChecksum: doc.VerifyChecksum, CreatedAt: doc.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), UpdatedAt: doc.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), CreatedBy: doc.CreatedBy, @@ -488,6 +496,10 @@ type UpdateDocumentMetadataRequest struct { Checksum *string `json:"checksum,omitempty"` ChecksumAlgorithm *string `json:"checksumAlgorithm,omitempty"` Description *string `json:"description,omitempty"` + ReadMode *string `json:"readMode,omitempty"` + AllowDownload *bool `json:"allowDownload,omitempty"` + RequireFullRead *bool `json:"requireFullRead,omitempty"` + VerifyChecksum *bool `json:"verifyChecksum,omitempty"` } // HandleUpdateDocumentMetadata handles PUT /api/v1/admin/documents/{docId}/metadata @@ -540,6 +552,18 @@ func (h *Handler) HandleUpdateDocumentMetadata(w http.ResponseWriter, r *http.Re if req.Description != nil { doc.Description = *req.Description } + if req.ReadMode != nil { + doc.ReadMode = *req.ReadMode + } + if req.AllowDownload != nil { + doc.AllowDownload = *req.AllowDownload + } + if req.RequireFullRead != nil { + doc.RequireFullRead = *req.RequireFullRead + } + if req.VerifyChecksum != nil { + doc.VerifyChecksum = *req.VerifyChecksum + } // Save document using CreateOrUpdate input := models.DocumentInput{ @@ -548,6 +572,10 @@ func (h *Handler) HandleUpdateDocumentMetadata(w http.ResponseWriter, r *http.Re Checksum: doc.Checksum, ChecksumAlgorithm: doc.ChecksumAlgorithm, Description: doc.Description, + ReadMode: doc.ReadMode, + AllowDownload: &doc.AllowDownload, + RequireFullRead: &doc.RequireFullRead, + VerifyChecksum: &doc.VerifyChecksum, } doc, err = h.adminService.UpdateDocumentMetadata(ctx, docID, input, user.Email) if err != nil { diff --git a/backend/internal/presentation/api/admin/handler_unit_test.go b/backend/internal/presentation/api/admin/handler_unit_test.go index de84367..914dce5 100644 --- a/backend/internal/presentation/api/admin/handler_unit_test.go +++ b/backend/internal/presentation/api/admin/handler_unit_test.go @@ -178,6 +178,10 @@ func createTestDocument(docID string) *models.Document { Checksum: "abc123", ChecksumAlgorithm: "SHA-256", Description: "Test description", + ReadMode: "integrated", + AllowDownload: true, + RequireFullRead: false, + VerifyChecksum: true, CreatedAt: now, UpdatedAt: now, CreatedBy: "admin@example.com", diff --git a/backend/internal/presentation/api/documents/handler.go b/backend/internal/presentation/api/documents/handler.go index a598ee9..e82c68c 100644 --- a/backend/internal/presentation/api/documents/handler.go +++ b/backend/internal/presentation/api/documents/handler.go @@ -28,6 +28,9 @@ type documentService interface { GetByDocID(ctx context.Context, docID string) (*models.Document, error) GetExpectedSignerStats(ctx context.Context, docID string) (*models.DocCompletionStats, error) ListExpectedSigners(ctx context.Context, docID string) ([]*models.ExpectedSigner, error) + ListByCreatedBy(ctx context.Context, createdBy string, limit, offset int) ([]*models.Document, error) + SearchByCreatedBy(ctx context.Context, createdBy, query string, limit, offset int) ([]*models.Document, error) + CountByCreatedBy(ctx context.Context, createdBy, searchQuery string) (int, error) } // webhookPublisher defines minimal publish capability @@ -106,7 +109,6 @@ type CreateDocumentResponse struct { func (h *Handler) HandleCreateDocument(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - // Check if user can create documents user, authenticated := shared.GetUserFromContext(ctx) userEmail := "" if authenticated && user != nil { @@ -127,7 +129,6 @@ func (h *Handler) HandleCreateDocument(w http.ResponseWriter, r *http.Request) { return } - // Parse request body var req CreateDocumentRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { logger.Logger.Warn("Invalid document creation request body", @@ -137,7 +138,6 @@ func (h *Handler) HandleCreateDocument(w http.ResponseWriter, r *http.Request) { return } - // Validate reference field if req.Reference == "" { logger.Logger.Warn("Document creation request missing reference field", "remote_addr", r.RemoteAddr) @@ -150,13 +150,11 @@ func (h *Handler) HandleCreateDocument(w http.ResponseWriter, r *http.Request) { "has_title", req.Title != "", "remote_addr", r.RemoteAddr) - // Create document request docRequest := services.CreateDocumentRequest{ Reference: req.Reference, Title: req.Title, } - // Create document doc, err := h.documentService.CreateDocument(ctx, docRequest) if err != nil { logger.Logger.Error("Document creation failed in handler", @@ -197,11 +195,9 @@ func (h *Handler) HandleCreateDocument(w http.ResponseWriter, r *http.Request) { func (h *Handler) HandleListDocuments(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - // Parse pagination and search parameters pagination := shared.ParsePaginationParams(r, 20, 100) searchQuery := r.URL.Query().Get("search") - // Fetch documents from service var docs []*models.Document var err error @@ -228,7 +224,6 @@ func (h *Handler) HandleListDocuments(w http.ResponseWriter, r *http.Request) { return } - // Get total count of documents (with or without search filter) totalCount, err := h.documentService.Count(ctx, searchQuery) if err != nil { logger.Logger.Warn("Failed to count documents, using result count", @@ -237,7 +232,6 @@ func (h *Handler) HandleListDocuments(w http.ResponseWriter, r *http.Request) { totalCount = len(docs) } - // Convert to DTOs (enriched with counts) documents := make([]DocumentDTO, 0, len(docs)) for _, doc := range docs { dto := DocumentDTO{ @@ -248,12 +242,10 @@ func (h *Handler) HandleListDocuments(w http.ResponseWriter, r *http.Request) { UpdatedAt: doc.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), } - // Get signature count if sigs, err := h.signatureService.GetDocumentSignatures(ctx, doc.DocID); err == nil { dto.SignatureCount = len(sigs) } - // Get expected signer count if stats, err := h.documentService.GetExpectedSignerStats(ctx, doc.DocID); err == nil { dto.ExpectedSignerCount = stats.ExpectedCount } @@ -274,7 +266,6 @@ func (h *Handler) HandleGetDocument(w http.ResponseWriter, r *http.Request) { return } - // Get document from service doc, err := h.documentService.GetByDocID(ctx, docID) if err != nil { logger.Logger.Error("Failed to get document", "doc_id", docID, "error", err.Error()) @@ -286,7 +277,6 @@ func (h *Handler) HandleGetDocument(w http.ResponseWriter, r *http.Request) { return } - // Get signatures for the document signatures, err := h.signatureService.GetDocumentSignatures(ctx, docID) if err != nil { logger.Logger.Error("Failed to get signatures", "doc_id", docID, "error", err.Error()) @@ -404,6 +394,10 @@ type FindOrCreateDocumentResponse struct { Checksum string `json:"checksum,omitempty"` ChecksumAlgorithm string `json:"checksumAlgorithm,omitempty"` Description string `json:"description,omitempty"` + ReadMode string `json:"readMode"` + AllowDownload bool `json:"allowDownload"` + RequireFullRead bool `json:"requireFullRead"` + VerifyChecksum bool `json:"verifyChecksum"` CreatedAt string `json:"createdAt"` IsNew bool `json:"isNew"` } @@ -452,6 +446,10 @@ func (h *Handler) HandleFindOrCreateDocument(w http.ResponseWriter, r *http.Requ Checksum: existingDoc.Checksum, ChecksumAlgorithm: existingDoc.ChecksumAlgorithm, Description: existingDoc.Description, + ReadMode: existingDoc.ReadMode, + AllowDownload: existingDoc.AllowDownload, + RequireFullRead: existingDoc.RequireFullRead, + VerifyChecksum: existingDoc.VerifyChecksum, CreatedAt: existingDoc.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), IsNew: false, } @@ -502,6 +500,10 @@ func (h *Handler) HandleFindOrCreateDocument(w http.ResponseWriter, r *http.Requ Checksum: doc.Checksum, ChecksumAlgorithm: doc.ChecksumAlgorithm, Description: doc.Description, + ReadMode: doc.ReadMode, + AllowDownload: doc.AllowDownload, + RequireFullRead: doc.RequireFullRead, + VerifyChecksum: doc.VerifyChecksum, CreatedAt: doc.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), IsNew: isNew, } @@ -522,3 +524,90 @@ func detectReferenceType(ref string) ReferenceType { } type ReferenceType string + +// MyDocumentDTO represents a document with stats for the current user's documents list +type MyDocumentDTO struct { + ID string `json:"id"` + Title string `json:"title"` + URL string `json:"url,omitempty"` + Description string `json:"description"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + SignatureCount int `json:"signatureCount"` + ExpectedSignerCount int `json:"expectedSignerCount"` +} + +// HandleListMyDocuments handles GET /api/v1/users/me/documents +// Returns documents created by the current authenticated user +func (h *Handler) HandleListMyDocuments(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + user, authenticated := shared.GetUserFromContext(ctx) + if !authenticated || user == nil { + shared.WriteError(w, http.StatusUnauthorized, shared.ErrCodeUnauthorized, "Authentication required", nil) + return + } + + pagination := shared.ParsePaginationParams(r, 20, 100) + searchQuery := r.URL.Query().Get("search") + + var docs []*models.Document + var err error + + if searchQuery != "" { + docs, err = h.documentService.SearchByCreatedBy(ctx, user.Email, searchQuery, pagination.PageSize, pagination.Offset) + logger.Logger.Debug("User document search request", + "user_email", user.Email, + "query", searchQuery, + "limit", pagination.PageSize, + "offset", pagination.Offset) + } else { + docs, err = h.documentService.ListByCreatedBy(ctx, user.Email, pagination.PageSize, pagination.Offset) + logger.Logger.Debug("User document list request", + "user_email", user.Email, + "limit", pagination.PageSize, + "offset", pagination.Offset) + } + + if err != nil { + logger.Logger.Error("Failed to fetch user documents", + "user_email", user.Email, + "search", searchQuery, + "error", err.Error()) + shared.WriteError(w, http.StatusInternalServerError, shared.ErrCodeInternal, "Failed to fetch documents", nil) + return + } + + totalCount, err := h.documentService.CountByCreatedBy(ctx, user.Email, searchQuery) + if err != nil { + logger.Logger.Warn("Failed to count user documents, using result count", + "error", err.Error(), + "user_email", user.Email, + "search", searchQuery) + totalCount = len(docs) + } + + documents := make([]MyDocumentDTO, 0, len(docs)) + for _, doc := range docs { + dto := MyDocumentDTO{ + ID: doc.DocID, + Title: doc.Title, + URL: doc.URL, + Description: doc.Description, + CreatedAt: doc.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: doc.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + + if sigs, err := h.signatureService.GetDocumentSignatures(ctx, doc.DocID); err == nil { + dto.SignatureCount = len(sigs) + } + + if stats, err := h.documentService.GetExpectedSignerStats(ctx, doc.DocID); err == nil { + dto.ExpectedSignerCount = stats.ExpectedCount + } + + documents = append(documents, dto) + } + + shared.WritePaginatedJSON(w, documents, pagination.Page, pagination.PageSize, totalCount) +} diff --git a/backend/internal/presentation/api/documents/handler_test.go b/backend/internal/presentation/api/documents/handler_test.go index 57a92bb..d68b74b 100644 --- a/backend/internal/presentation/api/documents/handler_test.go +++ b/backend/internal/presentation/api/documents/handler_test.go @@ -33,6 +33,10 @@ var ( Description: "Test description", Checksum: "abc123", ChecksumAlgorithm: "SHA-256", + ReadMode: "integrated", + AllowDownload: true, + RequireFullRead: false, + VerifyChecksum: true, CreatedAt: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC), UpdatedAt: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC), CreatedBy: "user@example.com", diff --git a/backend/internal/presentation/api/router.go b/backend/internal/presentation/api/router.go index 3d02ca5..1ff4005 100644 --- a/backend/internal/presentation/api/router.go +++ b/backend/internal/presentation/api/router.go @@ -20,6 +20,7 @@ import ( apiAuth "github.com/btouchard/ackify-ce/backend/internal/presentation/api/auth" "github.com/btouchard/ackify-ce/backend/internal/presentation/api/documents" "github.com/btouchard/ackify-ce/backend/internal/presentation/api/health" + "github.com/btouchard/ackify-ce/backend/internal/presentation/api/proxy" "github.com/btouchard/ackify-ce/backend/internal/presentation/api/shared" "github.com/btouchard/ackify-ce/backend/internal/presentation/api/signatures" "github.com/btouchard/ackify-ce/backend/internal/presentation/api/users" @@ -53,6 +54,9 @@ type documentService interface { GetByDocID(ctx context.Context, docID string) (*models.Document, error) GetExpectedSignerStats(ctx context.Context, docID string) (*models.DocCompletionStats, error) ListExpectedSigners(ctx context.Context, docID string) ([]*models.ExpectedSigner, error) + ListByCreatedBy(ctx context.Context, createdBy string, limit, offset int) ([]*models.Document, error) + SearchByCreatedBy(ctx context.Context, createdBy, query string, limit, offset int) ([]*models.Document, error) + CountByCreatedBy(ctx context.Context, createdBy, searchQuery string) (int, error) } // reminderService defines reminder operations @@ -177,6 +181,7 @@ func NewRouter(cfg RouterConfig) *chi.Mux { cfg.Authorizer, ) signaturesHandler := signatures.NewHandler(cfg.SignatureService, cfg.AdminService, cfg.WebhookPublisher) + proxyHandler := proxy.NewHandler(cfg.DocumentService) // Public routes r.Group(func(r chi.Router) { @@ -186,6 +191,9 @@ func NewRouter(cfg RouterConfig) *chi.Mux { // CSRF token r.Get("/csrf", authHandler.HandleGetCSRFToken) + // Proxy for streaming external documents (has its own rate limiting) + r.Get("/proxy", proxyHandler.HandleProxy) + // Auth endpoints r.Route("/auth", func(r chi.Router) { // Public endpoint to expose available authentication methods @@ -251,6 +259,7 @@ func NewRouter(cfg RouterConfig) *chi.Mux { // User endpoints r.Route("/users", func(r chi.Router) { r.Get("/me", usersHandler.HandleGetCurrentUser) + r.Get("/me/documents", documentsHandler.HandleListMyDocuments) }) // Signature endpoints diff --git a/backend/internal/presentation/api/shared/errors.go b/backend/internal/presentation/api/shared/errors.go index 9b7b0fe..81949ee 100644 --- a/backend/internal/presentation/api/shared/errors.go +++ b/backend/internal/presentation/api/shared/errors.go @@ -37,7 +37,6 @@ type ErrorDetail struct { Details map[string]interface{} `json:"details,omitempty"` } -// WriteError writes a standardized error response func WriteError(w http.ResponseWriter, statusCode int, code ErrorCode, message string, details map[string]interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) @@ -53,7 +52,6 @@ func WriteError(w http.ResponseWriter, statusCode int, code ErrorCode, message s json.NewEncoder(w).Encode(response) } -// WriteValidationError writes a validation error response func WriteValidationError(w http.ResponseWriter, message string, fieldErrors map[string]string) { details := make(map[string]interface{}) if fieldErrors != nil { @@ -62,7 +60,6 @@ func WriteValidationError(w http.ResponseWriter, message string, fieldErrors map WriteError(w, http.StatusBadRequest, ErrCodeValidation, message, details) } -// WriteUnauthorized writes an unauthorized error response func WriteUnauthorized(w http.ResponseWriter, message string) { if message == "" { message = "Authentication required" @@ -70,7 +67,6 @@ func WriteUnauthorized(w http.ResponseWriter, message string) { WriteError(w, http.StatusUnauthorized, ErrCodeUnauthorized, message, nil) } -// WriteForbidden writes a forbidden error response func WriteForbidden(w http.ResponseWriter, message string) { if message == "" { message = "Access denied" @@ -78,7 +74,6 @@ func WriteForbidden(w http.ResponseWriter, message string) { WriteError(w, http.StatusForbidden, ErrCodeForbidden, message, nil) } -// WriteNotFound writes a not found error response func WriteNotFound(w http.ResponseWriter, resource string) { message := "Resource not found" if resource != "" { @@ -87,7 +82,6 @@ func WriteNotFound(w http.ResponseWriter, resource string) { WriteError(w, http.StatusNotFound, ErrCodeNotFound, message, nil) } -// WriteConflict writes a conflict error response func WriteConflict(w http.ResponseWriter, message string) { if message == "" { message = "Resource conflict" @@ -95,7 +89,6 @@ func WriteConflict(w http.ResponseWriter, message string) { WriteError(w, http.StatusConflict, ErrCodeConflict, message, nil) } -// WriteInternalError writes an internal server error response func WriteInternalError(w http.ResponseWriter) { WriteError(w, http.StatusInternalServerError, ErrCodeInternal, "An internal error occurred", nil) } diff --git a/backend/internal/presentation/api/shared/response.go b/backend/internal/presentation/api/shared/response.go index e4edc22..23ab494 100644 --- a/backend/internal/presentation/api/shared/response.go +++ b/backend/internal/presentation/api/shared/response.go @@ -28,7 +28,6 @@ type PaginationParams struct { Offset int `json:"-"` } -// NewPaginationParams creates pagination parameters with default values func NewPaginationParams(defaultPage, defaultPageSize, maxPageSize int) *PaginationParams { if defaultPage < 1 { defaultPage = 1 @@ -46,19 +45,15 @@ func NewPaginationParams(defaultPage, defaultPageSize, maxPageSize int) *Paginat } } -// ParsePaginationParams parses pagination parameters from HTTP request query string -// and validates them against min/max constraints func ParsePaginationParams(r *http.Request, defaultPageSize, maxPageSize int) *PaginationParams { params := NewPaginationParams(1, defaultPageSize, maxPageSize) - // Parse page parameter if pageStr := r.URL.Query().Get("page"); pageStr != "" { if page, err := strconv.Atoi(pageStr); err == nil && page > 0 { params.Page = page } } - // Parse limit/page_size parameter (support both names) pageSizeStr := r.URL.Query().Get("limit") if pageSizeStr == "" { pageSizeStr = r.URL.Query().Get("page_size") @@ -69,9 +64,7 @@ func ParsePaginationParams(r *http.Request, defaultPageSize, maxPageSize int) *P } } - // Validate and calculate params.Validate(maxPageSize) - return params } @@ -89,7 +82,6 @@ func (p *PaginationParams) Validate(maxPageSize int) { p.Offset = (p.Page - 1) * p.PageSize } -// WriteJSON writes a JSON response func WriteJSON(w http.ResponseWriter, statusCode int, data interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) @@ -101,7 +93,6 @@ func WriteJSON(w http.ResponseWriter, statusCode int, data interface{}) { json.NewEncoder(w).Encode(response) } -// WriteJSONWithMeta writes a JSON response with metadata func WriteJSONWithMeta(w http.ResponseWriter, statusCode int, data interface{}, meta map[string]interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) @@ -114,7 +105,6 @@ func WriteJSONWithMeta(w http.ResponseWriter, statusCode int, data interface{}, json.NewEncoder(w).Encode(response) } -// WritePaginatedJSON writes a paginated JSON response func WritePaginatedJSON(w http.ResponseWriter, data interface{}, page, limit, total int) { totalPages := (total + limit - 1) / limit if totalPages < 1 { diff --git a/backend/internal/presentation/api/signatures/handler.go b/backend/internal/presentation/api/signatures/handler.go index a16aa37..cd3ad6e 100644 --- a/backend/internal/presentation/api/signatures/handler.go +++ b/backend/internal/presentation/api/signatures/handler.go @@ -91,34 +91,29 @@ type SignatureStatusResponse struct { func (h *Handler) HandleCreateSignature(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - // Get user from context (set by RequireAuth middleware) user, ok := shared.GetUserFromContext(ctx) if !ok || user == nil { shared.WriteUnauthorized(w, "Authentication required") return } - // Parse request body var req CreateSignatureRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, "Invalid request body", map[string]interface{}{"error": err.Error()}) return } - // Validate document ID if req.DocID == "" { shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, "Document ID is required", nil) return } - // Create signature request sigRequest := &models.SignatureRequest{ DocID: req.DocID, User: user, Referer: req.Referer, } - // Create signature err := h.signatureService.CreateSignature(ctx, sigRequest) if err != nil { if err == models.ErrSignatureAlreadyExists { @@ -165,10 +160,8 @@ func (h *Handler) HandleCreateSignature(w http.ResponseWriter, r *http.Request) } } - // Get the created signature to return it signature, err := h.signatureService.GetSignatureByDocAndUser(ctx, req.DocID, user) if err != nil { - // Signature was created but we couldn't retrieve it shared.WriteJSON(w, http.StatusCreated, map[string]interface{}{ "message": "Signature created successfully", "docId": req.DocID, @@ -176,7 +169,6 @@ func (h *Handler) HandleCreateSignature(w http.ResponseWriter, r *http.Request) return } - // Return the created signature shared.WriteJSON(w, http.StatusCreated, h.toSignatureResponse(ctx, signature)) } @@ -184,21 +176,18 @@ func (h *Handler) HandleCreateSignature(w http.ResponseWriter, r *http.Request) func (h *Handler) HandleGetUserSignatures(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - // Get user from context user, ok := shared.GetUserFromContext(ctx) if !ok || user == nil { shared.WriteUnauthorized(w, "Authentication required") return } - // Get user's signatures signatures, err := h.signatureService.GetUserSignatures(ctx, user) if err != nil { shared.WriteError(w, http.StatusInternalServerError, shared.ErrCodeInternal, "Failed to fetch signatures", map[string]interface{}{"error": err.Error()}) return } - // Convert to response format response := make([]*SignatureResponse, 0, len(signatures)) for _, sig := range signatures { response = append(response, h.toSignatureResponse(ctx, sig)) @@ -211,21 +200,18 @@ func (h *Handler) HandleGetUserSignatures(w http.ResponseWriter, r *http.Request func (h *Handler) HandleGetDocumentSignatures(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - // Get document ID from URL docID := chi.URLParam(r, "docId") if docID == "" { shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, "Document ID is required", nil) return } - // Get document signatures signatures, err := h.signatureService.GetDocumentSignatures(ctx, docID) if err != nil { shared.WriteError(w, http.StatusInternalServerError, shared.ErrCodeInternal, "Failed to fetch signatures", map[string]interface{}{"error": err.Error()}) return } - // Convert to response format response := make([]*SignatureResponse, 0, len(signatures)) for _, sig := range signatures { response = append(response, h.toSignatureResponse(ctx, sig)) @@ -238,28 +224,24 @@ func (h *Handler) HandleGetDocumentSignatures(w http.ResponseWriter, r *http.Req func (h *Handler) HandleGetSignatureStatus(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - // Get user from context user, ok := shared.GetUserFromContext(ctx) if !ok || user == nil { shared.WriteUnauthorized(w, "Authentication required") return } - // Get document ID from URL docID := chi.URLParam(r, "docId") if docID == "" { shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, "Document ID is required", nil) return } - // Get signature status status, err := h.signatureService.GetSignatureStatus(ctx, docID, user) if err != nil { shared.WriteError(w, http.StatusInternalServerError, shared.ErrCodeInternal, "Failed to fetch signature status", map[string]interface{}{"error": err.Error()}) return } - // Convert to response format response := SignatureStatusResponse{ DocID: status.DocID, UserEmail: status.UserEmail, diff --git a/backend/internal/presentation/handlers/oembed.go b/backend/internal/presentation/handlers/oembed.go index 8d9c6c4..e4ff921 100644 --- a/backend/internal/presentation/handlers/oembed.go +++ b/backend/internal/presentation/handlers/oembed.go @@ -27,7 +27,6 @@ type OEmbedResponse struct { // Returns oEmbed JSON for embedding Ackify signature widgets in external platforms func HandleOEmbed(baseURL string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - // Get the URL parameter urlParam := r.URL.Query().Get("url") if urlParam == "" { logger.Logger.Warn("oEmbed request missing url parameter", @@ -36,7 +35,6 @@ func HandleOEmbed(baseURL string) http.HandlerFunc { return } - // Parse the URL to extract doc parameter parsedURL, err := url.Parse(urlParam) if err != nil { logger.Logger.Warn("oEmbed request with invalid url", @@ -57,19 +55,15 @@ func HandleOEmbed(baseURL string) http.HandlerFunc { return } - // Build embed URL (points to the SPA embed view) embedURL := baseURL + "/embed?doc=" + url.QueryEscape(docID) - // Check if referrer is provided (for tracking which platform is embedding) referrer := parsedURL.Query().Get("referrer") if referrer != "" { embedURL += "&referrer=" + url.QueryEscape(referrer) } - // Build iframe HTML iframeHTML := `` - // Create oEmbed response response := OEmbedResponse{ Type: "rich", Version: "1.0", @@ -80,11 +74,9 @@ func HandleOEmbed(baseURL string) http.HandlerFunc { Height: 200, } - // Set response headers w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.Header().Set("Access-Control-Allow-Origin", "*") // Allow cross-origin requests for oEmbed + w.Header().Set("Access-Control-Allow-Origin", "*") - // Encode and send response if err := json.NewEncoder(w).Encode(response); err != nil { logger.Logger.Error("Failed to encode oEmbed response", "doc_id", docID, @@ -107,7 +99,6 @@ func ValidateOEmbedURL(urlStr string, baseURL string) bool { return false } - // Check if the URL belongs to this Ackify instance baseURLParsed, err := url.Parse(baseURL) if err != nil { return false diff --git a/backend/pkg/checksum/remote_checksum.go b/backend/pkg/checksum/remote_checksum.go index 5417590..554eb11 100644 --- a/backend/pkg/checksum/remote_checksum.go +++ b/backend/pkg/checksum/remote_checksum.go @@ -61,30 +61,26 @@ func DefaultOptions() ComputeOptions { // Returns nil if the file cannot be processed (too large, wrong type, network error, SSRF blocked) // The context is used for request cancellation and timeout propagation. func ComputeRemoteChecksum(ctx context.Context, urlStr string, opts ComputeOptions) (*Result, error) { - // Check if context is already cancelled if err := ctx.Err(); err != nil { return nil, fmt.Errorf("context cancelled before checksum computation: %w", err) } - // Validate URL scheme (only HTTPS allowed) + if !isValidURL(urlStr) { logger.Logger.Info("Checksum: URL rejected - not HTTPS", "url", urlStr) return nil, nil } - // Parse URL parsedURL, err := url.Parse(urlStr) if err != nil { logger.Logger.Warn("Checksum: Failed to parse URL", "url", urlStr, "error", err.Error()) return nil, nil } - // SSRF Protection: Block internal/private IPs (unless disabled for testing) if !opts.SkipSSRFCheck && isBlockedHost(parsedURL.Hostname()) { logger.Logger.Warn("Checksum: SSRF protection - blocked internal/private host", "host", parsedURL.Hostname()) return nil, nil } - // Create HTTP client with timeout and redirect limits client := &http.Client{ Timeout: time.Duration(opts.TimeoutMs) * time.Millisecond, CheckRedirect: func(req *http.Request, via []*http.Request) error { @@ -99,7 +95,6 @@ func ComputeRemoteChecksum(ctx context.Context, urlStr string, opts ComputeOptio }, } - // For testing only: disable TLS verification if opts.InsecureSkipVerify { client.Transport = &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, @@ -122,14 +117,12 @@ func ComputeRemoteChecksum(ctx context.Context, urlStr string, opts ComputeOptio } defer headResp.Body.Close() - // Check Content-Type contentType := headResp.Header.Get("Content-Type") if contentType != "" && !isAllowedContentType(contentType, opts.AllowedContentType) { logger.Logger.Info("Checksum: Content-Type not allowed", "url", urlStr, "content_type", contentType) return nil, nil } - // Check Content-Length contentLength := headResp.ContentLength if contentLength > 0 && contentLength > opts.MaxBytes { logger.Logger.Info("Checksum: File too large", "url", urlStr, "size", contentLength, "max", opts.MaxBytes) @@ -187,7 +180,6 @@ func computeWithStreamedGET(ctx context.Context, client *http.Client, urlStr str return nil, nil } - // Check Content-Type again contentType := getResp.Header.Get("Content-Type") if contentType != "" && !isAllowedContentType(contentType, opts.AllowedContentType) { logger.Logger.Info("Checksum: Content-Type not allowed (fallback)", "url", urlStr, "content_type", contentType) @@ -208,7 +200,6 @@ func computeHashWithLimit(reader io.Reader, maxBytes int64, urlStr string) (*Res return nil, nil } - // Check if we exceeded the limit if written > maxBytes { logger.Logger.Info("Checksum: File exceeded size limit during streaming", "url", urlStr, "read", written, "max", maxBytes) return nil, nil @@ -260,7 +251,6 @@ func isAllowedContentType(contentType string, allowedTypes []string) bool { // isBlockedHost checks if the hostname is a private/internal IP or localhost func isBlockedHost(hostname string) bool { - // Check for localhost variations if hostname == "localhost" || hostname == "127.0.0.1" || hostname == "::1" { return true } @@ -273,7 +263,6 @@ func isBlockedHost(hostname string) bool { return true } - // Check if any resolved IP is private/internal for _, ip := range ips { if isPrivateIP(ip) { return true @@ -314,9 +303,7 @@ func isPrivateIP(ip net.IP) bool { } } - // Check for private IPv6 ranges if ip.To4() == nil { - // IPv6 if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { return true } diff --git a/backend/pkg/web/defaults.go b/backend/pkg/web/defaults.go index 2927941..c084efc 100644 --- a/backend/pkg/web/defaults.go +++ b/backend/pkg/web/defaults.go @@ -11,22 +11,18 @@ import ( // This is the default for Community Edition. type NoLimitQuotaEnforcer struct{} -// NewNoLimitQuotaEnforcer creates a new no-limit quota enforcer. func NewNoLimitQuotaEnforcer() *NoLimitQuotaEnforcer { return &NoLimitQuotaEnforcer{} } -// Check always returns nil (no quota limits). func (e *NoLimitQuotaEnforcer) Check(_ context.Context, _ string, _ QuotaAction) error { return nil } -// Record is a no-op (nothing to track). func (e *NoLimitQuotaEnforcer) Record(_ context.Context, _ string, _ QuotaAction) error { return nil } -// GetUsage returns unlimited usage metrics. func (e *NoLimitQuotaEnforcer) GetUsage(_ context.Context, tenantID string) (*QuotaUsage, error) { unlimited := UsageMetric{Used: 0, Limit: -1} return &QuotaUsage{ @@ -46,12 +42,10 @@ var _ QuotaEnforcer = (*NoLimitQuotaEnforcer)(nil) // This is the default for Community Edition. type LogOnlyAuditLogger struct{} -// NewLogOnlyAuditLogger creates a new log-only audit logger. func NewLogOnlyAuditLogger() *LogOnlyAuditLogger { return &LogOnlyAuditLogger{} } -// Log writes the audit event to the standard logger. func (l *LogOnlyAuditLogger) Log(_ context.Context, event AuditEvent) error { logger.Logger.Info("audit", "action", event.Action, diff --git a/backend/pkg/web/server.go b/backend/pkg/web/server.go index 0bff9d1..c7e4142 100644 --- a/backend/pkg/web/server.go +++ b/backend/pkg/web/server.go @@ -200,28 +200,22 @@ func (b *ServerBuilder) WithReminderService(service *services.ReminderAsyncServi // Build constructs the server with all dependencies. func (b *ServerBuilder) Build(ctx context.Context) (*Server, error) { - // Validate required capability providers if err := b.validateProviders(); err != nil { return nil, err } - // Set defaults for optional providers b.setDefaultProviders() - // Initialize infrastructure if err := b.initializeInfrastructure(); err != nil { return nil, err } - // Create repositories repos := b.createRepositories() - // Initialize Telemetry if is enabled if err := b.initializeTelemetry(ctx); err != nil { return nil, err } - // Initialize workers and services whPublisher, whWorker, err := b.initializeWebhookSystem(repos) if err != nil { return nil, err @@ -232,25 +226,17 @@ func (b *ServerBuilder) Build(ctx context.Context) (*Server, error) { return nil, err } - // Initialize core services b.initializeCoreServices(repos) - - // Initialize MagicLink service and worker magicLinkWorker := b.initializeMagicLinkService(ctx, repos) - - // Initialize reminder service b.initializeReminderService(repos) - // Initialize session worker sessionWorker, err := b.initializeSessionWorker(repos) if err != nil { return nil, err } - // Build router router := b.buildRouter(repos, whPublisher) - // Create HTTP server httpServer := &http.Server{ Addr: b.cfg.Server.ListenAddr, Handler: handlers.RequestLogger(handlers.SecureHeaders(router)), @@ -299,7 +285,6 @@ func (b *ServerBuilder) setDefaultProviders() { func (b *ServerBuilder) initializeInfrastructure() error { var err error - // Initialize Ed25519 signer if not provided if b.signer == nil { b.signer, err = crypto.NewEd25519Signer() if err != nil { @@ -307,7 +292,6 @@ func (b *ServerBuilder) initializeInfrastructure() error { } } - // Initialize i18n if not provided if b.i18nService == nil { localesDir := getLocalesDir() b.i18nService, err = i18n.NewI18n(localesDir) @@ -316,7 +300,6 @@ func (b *ServerBuilder) initializeInfrastructure() error { } } - // Initialize email sender if not provided if b.emailSender == nil && b.cfg.Mail.Host != "" { emailTemplatesDir := getTemplatesDir() renderer := email.NewRenderer(emailTemplatesDir, b.cfg.App.BaseURL, b.cfg.App.Organisation, diff --git a/backend/pkg/web/static.go b/backend/pkg/web/static.go index 8f95551..d85ee91 100644 --- a/backend/pkg/web/static.go +++ b/backend/pkg/web/static.go @@ -89,7 +89,6 @@ func serveIndexTemplate(w http.ResponseWriter, r *http.Request, file fs.File, ba processedContent := strings.ReplaceAll(string(content), "__ACKIFY_BASE_URL__", baseURL) processedContent = strings.ReplaceAll(processedContent, "__ACKIFY_VERSION__", version) - // Convert boolean to string for JavaScript oauthEnabledStr := "false" if oauthEnabled { oauthEnabledStr = "true" @@ -158,8 +157,6 @@ func generateBasicMetaTags(docID string, baseURL string, signatureCount int) str } var metaTags strings.Builder - - // Open Graph tags metaTags.WriteString(fmt.Sprintf(``, html.EscapeString(title))) metaTags.WriteString("\n ") metaTags.WriteString(fmt.Sprintf(``, html.EscapeString(description))) diff --git a/webapp/cypress/e2e/03-admin-signers-management.cy.ts b/webapp/cypress/e2e/03-admin-signers-management.cy.ts index 97dde05..adf1ea9 100644 --- a/webapp/cypress/e2e/03-admin-signers-management.cy.ts +++ b/webapp/cypress/e2e/03-admin-signers-management.cy.ts @@ -20,7 +20,7 @@ describe('Test 3: Admin - Expected Signers Management', () => { cy.contains('Administration', { timeout: 10000 }).should('be.visible') // Step 3: Create new document - cy.get('input#newDocId, input#newDocIdMobile').first().type(docId) + cy.get('[data-testid="new-doc-input"]').type(docId) cy.contains('button', 'Confirm').click() // Step 4: Should redirect to document detail page @@ -28,16 +28,16 @@ describe('Test 3: Admin - Expected Signers Management', () => { cy.contains('Document').should('be.visible') // Step 5: Add 3 expected signers - cy.contains('button', 'Add').click() + cy.get('[data-testid="open-add-signers-btn"]').click() // Modal should appear - cy.contains('Add expected readers').should('be.visible') + cy.get('[data-testid="add-signers-modal"]').should('be.visible') // Wait for modal to be fully rendered cy.wait(500) // Add signers (Name format and plain email) - cy.get('textarea[placeholder*="Jane"]').type( + cy.get('[data-testid="signers-textarea"]').type( 'Alice Smith {enter}bob@test.com{enter}Charlie Brown ', { delay: 50 } ) @@ -45,11 +45,11 @@ describe('Test 3: Admin - Expected Signers Management', () => { // Wait a bit for Vue reactivity cy.wait(300) - // Submit the form by clicking the submit button (find button of type submit) - cy.get('button[type="submit"]').contains('Add').click() + // Submit the form + cy.get('[data-testid="add-signers-btn"]').click() // Wait for modal to close - cy.contains('Add expected readers', { timeout: 15000 }).should('not.exist') + cy.get('[data-testid="add-signers-modal"]', { timeout: 15000 }).should('not.exist') // Step 6: Verify signers in table cy.contains('alice@test.com', { timeout: 10000 }).should('be.visible') @@ -74,27 +74,27 @@ describe('Test 3: Admin - Expected Signers Management', () => { // Create document const removeDocId = 'test-remove-signer-' + Date.now() - cy.get('input#newDocId, input#newDocIdMobile').first().type(removeDocId) - cy.contains('button', 'Confirm').click() + cy.get('[data-testid="new-doc-input"]').type(removeDocId) + cy.get('[data-testid="create-doc-btn"]').click() cy.url({ timeout: 10000 }).should('include', `/admin/docs/${removeDocId}`) // Add 2 signers - cy.contains('button', 'Add').first().click() + cy.get('[data-testid="open-add-signers-btn"]').click() // Wait for modal to be fully rendered cy.wait(500) - cy.get('textarea[placeholder*="Jane"]').type('alice@test.com{enter}bob@test.com', { delay: 50 }) + cy.get('[data-testid="signers-textarea"]').type('alice@test.com{enter}bob@test.com', { delay: 50 }) // Wait a bit for Vue reactivity cy.wait(300) - // Submit the form by clicking the submit button (find button of type submit) - cy.get('button[type="submit"]').contains('Add').click() + // Submit the form + cy.get('[data-testid="add-signers-btn"]').click() // Wait for modal to close - cy.contains('Add expected readers', { timeout: 15000 }).should('not.exist') + cy.get('[data-testid="add-signers-modal"]', { timeout: 15000 }).should('not.exist') // Verify 2 signers cy.contains('alice@test.com', { timeout: 10000 }).should('be.visible') diff --git a/webapp/cypress/e2e/04-admin-email-reminders.cy.ts b/webapp/cypress/e2e/04-admin-email-reminders.cy.ts index b4fd712..4ab9a15 100644 --- a/webapp/cypress/e2e/04-admin-email-reminders.cy.ts +++ b/webapp/cypress/e2e/04-admin-email-reminders.cy.ts @@ -17,23 +17,23 @@ describe('Test 4: Admin - Email Reminders', () => { cy.loginAsAdmin() cy.visit('/admin') - cy.get('input#newDocId, input#newDocIdMobile').first().type(docId) + cy.get('[data-testid="new-doc-input"]').type(docId) cy.contains('button', 'Confirm').click() cy.url({ timeout: 10000 }).should('include', `/admin/docs/${docId}`) // Step 2: Add 2 expected signers - cy.contains('button', 'Add').click() + cy.get('[data-testid="open-add-signers-btn"]').click() // Wait for modal cy.wait(500) - cy.get('textarea[placeholder*="Jane"]').type(`${alice}{enter}${bob}`, { delay: 50 }) + cy.get('[data-testid="signers-textarea"]').type(`${alice}{enter}${bob}`, { delay: 50 }) // Wait for Vue reactivity cy.wait(300) // Submit form - cy.get('button[type="submit"]').contains('Add').click() + cy.get('[data-testid="add-signers-btn"]').click() // Verify signers added cy.contains(alice, { timeout: 10000 }).should('be.visible') diff --git a/webapp/cypress/e2e/07-admin-document-deletion.cy.ts b/webapp/cypress/e2e/07-admin-document-deletion.cy.ts index 8d75c2c..2f1f3b5 100644 --- a/webapp/cypress/e2e/07-admin-document-deletion.cy.ts +++ b/webapp/cypress/e2e/07-admin-document-deletion.cy.ts @@ -16,16 +16,16 @@ describe('Test 7: Admin - Document Deletion', () => { cy.loginAsAdmin() cy.visit('/admin') - cy.get('input#newDocId, input#newDocIdMobile').first().type(docId) + cy.get('[data-testid="new-doc-input"]').type(docId) cy.contains('button', 'Confirm').click() cy.url({ timeout: 10000 }).should('include', `/admin/docs/${docId}`) // Step 2: Add 2 expected signers - cy.contains('button', 'Add').click() + cy.get('[data-testid="open-add-signers-btn"]').click() cy.wait(500) - cy.get('textarea[placeholder*="Jane"]').type(`alice@test.com\n${testUser}`, { delay: 50 }) + cy.get('[data-testid="signers-textarea"]').type(`alice@test.com\n${testUser}`, { delay: 50 }) cy.wait(300) - cy.get('button[type="submit"]').contains('Add').click() + cy.get('[data-testid="add-signers-btn"]').click() cy.contains('alice@test.com', { timeout: 10000 }).should('be.visible') @@ -84,7 +84,7 @@ describe('Test 7: Admin - Document Deletion', () => { cy.visit('/admin') const safeDocId = 'safe-doc-' + Date.now() - cy.get('input#newDocId, input#newDocIdMobile').first().type(safeDocId) + cy.get('[data-testid="new-doc-input"]').type(safeDocId) cy.contains('button', 'Confirm').click() cy.url({ timeout: 10000 }).should('include', `/admin/docs/${safeDocId}`) diff --git a/webapp/cypress/e2e/08-admin-route-protection.cy.ts b/webapp/cypress/e2e/08-admin-route-protection.cy.ts index c8d9c03..78599e9 100644 --- a/webapp/cypress/e2e/08-admin-route-protection.cy.ts +++ b/webapp/cypress/e2e/08-admin-route-protection.cy.ts @@ -72,7 +72,7 @@ describe('Test 8: Admin Route Protection', () => { // Create document first as admin cy.loginAsAdmin() cy.visit('/admin') - cy.get('input#newDocId, input#newDocIdMobile').first().type(targetDoc) + cy.get('[data-testid="new-doc-input"]').type(targetDoc) cy.contains('button', 'Confirm').click() cy.url({ timeout: 10000 }).should('include', `/admin/docs/${targetDoc}`) diff --git a/webapp/cypress/e2e/09-complete-workflow.cy.ts b/webapp/cypress/e2e/09-complete-workflow.cy.ts index c3cf5e4..8c2f27e 100644 --- a/webapp/cypress/e2e/09-complete-workflow.cy.ts +++ b/webapp/cypress/e2e/09-complete-workflow.cy.ts @@ -19,17 +19,17 @@ describe('Test 9: Complete End-to-End Workflow', () => { cy.loginAsAdmin() cy.visit('/admin') - cy.get('input#newDocId, input#newDocIdMobile').first().type(docId) + cy.get('[data-testid="new-doc-input"]').type(docId) cy.contains('button', 'Confirm').click() cy.url({ timeout: 10000 }).should('include', `/admin/docs/${docId}`) // ===== STEP 2: Admin adds 3 expected signers ===== cy.log('STEP 2: Admin adds 3 expected signers') - cy.contains('button', 'Add').click() + cy.get('[data-testid="open-add-signers-btn"]').click() cy.wait(500) - cy.get('textarea[placeholder*="Jane"]').type(`${alice}\n${bob}\n${charlie}`, { delay: 50 }) + cy.get('[data-testid="signers-textarea"]').type(`${alice}\n${bob}\n${charlie}`, { delay: 50 }) cy.wait(300) - cy.get('button[type="submit"]').contains('Add').click() + cy.get('[data-testid="add-signers-btn"]').click() cy.contains(alice, { timeout: 10000 }).should('be.visible') cy.contains(bob).should('be.visible') diff --git a/webapp/cypress/e2e/10-unexpected-signatures.cy.ts b/webapp/cypress/e2e/10-unexpected-signatures.cy.ts index 25fd6d2..6c0328a 100644 --- a/webapp/cypress/e2e/10-unexpected-signatures.cy.ts +++ b/webapp/cypress/e2e/10-unexpected-signatures.cy.ts @@ -19,16 +19,16 @@ describe('Test 10: Unexpected Signatures Tracking', () => { cy.loginAsAdmin() cy.visit('/admin') - cy.get('input#newDocId, input#newDocIdMobile').first().type(docId) + cy.get('[data-testid="new-doc-input"]').type(docId) cy.contains('button', 'Confirm').click() cy.url({ timeout: 10000 }).should('include', `/admin/docs/${docId}`) // Add alice and bob as expected signers - cy.contains('button', 'Add').click() + cy.get('[data-testid="open-add-signers-btn"]').click() cy.wait(500) - cy.get('textarea[placeholder*="Jane"]').type(`${alice}\n${bob}`, { delay: 50 }) + cy.get('[data-testid="signers-textarea"]').type(`${alice}\n${bob}`, { delay: 50 }) cy.wait(300) - cy.get('button[type="submit"]').contains('Add').click() + cy.get('[data-testid="add-signers-btn"]').click() cy.contains(alice, { timeout: 10000 }).should('be.visible') cy.contains(bob).should('be.visible') @@ -115,16 +115,16 @@ describe('Test 10: Unexpected Signatures Tracking', () => { cy.loginAsAdmin() cy.visit('/admin') - cy.get('input#newDocId, input#newDocIdMobile').first().type(multiDocId) + cy.get('[data-testid="new-doc-input"]').type(multiDocId) cy.contains('button', 'Confirm').click() cy.url({ timeout: 10000 }).should('include', `/admin/docs/${multiDocId}`) - cy.contains('button', 'Add').click() + cy.get('[data-testid="open-add-signers-btn"]').click() cy.wait(500) - cy.get('textarea[placeholder*="Jane"]').type(expected1, { delay: 50 }) + cy.get('[data-testid="signers-textarea"]').type(expected1, { delay: 50 }) cy.wait(300) - cy.get('button[type="submit"]').contains('Add').click() + cy.get('[data-testid="add-signers-btn"]').click() cy.contains(expected1, { timeout: 10000 }).should('be.visible') diff --git a/webapp/cypress/e2e/13-embed-page.cy.ts b/webapp/cypress/e2e/13-embed-page.cy.ts index d903287..fe5efa3 100644 --- a/webapp/cypress/e2e/13-embed-page.cy.ts +++ b/webapp/cypress/e2e/13-embed-page.cy.ts @@ -47,7 +47,6 @@ describe('Test 13: Embed Page Functionality', () => { cy.visitWithLocale(`/embed?doc=${sharedDocId}`, 'en') // Step 3: Should show document header with signature count (i18n: "confirmation") - cy.contains('Document', { timeout: 10000 }).should('be.visible') cy.contains('confirmation', { timeout: 10000 }).should('be.visible') // Step 4: Should show signature in list @@ -58,7 +57,7 @@ describe('Test 13: Embed Page Functionality', () => { cy.contains('a', 'Sign').should('have.attr', 'target', '_blank') // Step 6: Verify signature date is displayed - cy.get('.text-xs.text-muted-foreground').should('exist') + cy.get('[data-testid="signature-date"]').should('exist') }) it('should display multiple signatures', () => { @@ -203,7 +202,7 @@ describe('Test 13: Embed Page Functionality', () => { cy.contains('confirmation', { timeout: 10000 }).should('be.visible') // Step 4: Verify signatures appear in the list - cy.get('.space-y-2 > div').should('have.length', 3) + cy.get('[data-testid="signature-item"]').should('have.length', 3) // Step 5: Verify each signature has email and date users.forEach((email) => { diff --git a/webapp/cypress/e2e/14-csv-preview.cy.ts b/webapp/cypress/e2e/14-csv-preview.cy.ts index 3a13e4b..aec3e5d 100644 --- a/webapp/cypress/e2e/14-csv-preview.cy.ts +++ b/webapp/cypress/e2e/14-csv-preview.cy.ts @@ -13,7 +13,7 @@ describe('Test 14: CSV Import Preview', () => { // Step 1: Login as admin and create document cy.loginAsAdmin() cy.visit('/admin') - cy.get('input#newDocId, input#newDocIdMobile').first().type(testDocId) + cy.get('[data-testid="new-doc-input"]').type(testDocId) cy.contains('button', 'Confirm').click() cy.url({ timeout: 10000 }).should('include', `/admin/docs/${testDocId}`) @@ -37,7 +37,7 @@ charlie@test.com,Charlie Brown,Design team` // Step 5: Should show preview with summary (check for Valid label and count) cy.contains('Valid', { timeout: 10000 }).should('be.visible') - cy.get('.text-green-600').contains('3').should('be.visible') + cy.get('.text-emerald-600').contains('3').should('be.visible') // Step 6: Should show preview table with emails cy.contains('alice@test.com').should('be.visible') @@ -80,9 +80,9 @@ david@test.com,David New` // Step 5: Should show preview with existing email detected cy.contains('Valid', { timeout: 10000 }).should('be.visible') - cy.get('.text-green-600').contains('1').should('be.visible') + cy.get('.text-emerald-600').contains('1').should('be.visible') cy.contains('Already exist').should('be.visible') - cy.get('.text-orange-600').contains('1').should('be.visible') + cy.get('.text-amber-600').contains('1').should('be.visible') // Step 6: Should show existing email in preview with "Existing" badge cy.contains('alice@test.com').should('be.visible') @@ -108,7 +108,7 @@ david@test.com,David New` // Step 1: Login and create new document cy.loginAsAdmin() cy.visit('/admin') - cy.get('input#newDocId, input#newDocIdMobile').first().type(invalidCsvDocId) + cy.get('[data-testid="new-doc-input"]').type(invalidCsvDocId) cy.contains('button', 'Confirm').click() cy.url({ timeout: 10000 }).should('include', `/admin/docs/${invalidCsvDocId}`) @@ -133,13 +133,11 @@ missing-domain@,Missing Domain` // Step 5: Should show preview with invalid emails detected cy.contains('Valid', { timeout: 10000 }).should('be.visible') - cy.get('.text-green-600').contains('1').should('be.visible') + cy.get('.text-emerald-600').contains('1').should('be.visible') cy.contains('Invalid').should('be.visible') cy.get('.text-red-600').contains('3').should('be.visible') - // Step 6: Should show invalid emails with error indicators - cy.contains('invalid-email').should('be.visible') - cy.contains('Parse errors').should('be.visible') + // Step 6: Invalid count is shown (invalid emails not displayed in table) // Step 7: Confirm import (should only import valid email) cy.contains('button', 'Import 1 reader').click() @@ -156,7 +154,7 @@ missing-domain@,Missing Domain` // Step 1: Login and create new document cy.loginAsAdmin() cy.visit('/admin') - cy.get('input#newDocId, input#newDocIdMobile').first().type(emptyDocId) + cy.get('[data-testid="new-doc-input"]').type(emptyDocId) cy.contains('button', 'Confirm').click() cy.url({ timeout: 10000 }).should('include', `/admin/docs/${emptyDocId}`) @@ -187,7 +185,7 @@ missing-domain@,Missing Domain` // Step 1: Login and create new document cy.loginAsAdmin() cy.visit('/admin') - cy.get('input#newDocId, input#newDocIdMobile').first().type(missingColDocId) + cy.get('[data-testid="new-doc-input"]').type(missingColDocId) cy.contains('button', 'Confirm').click() cy.url({ timeout: 10000 }).should('include', `/admin/docs/${missingColDocId}`) @@ -219,7 +217,7 @@ Bob Johnson,Development` // Step 1: Login and create new document cy.loginAsAdmin() cy.visit('/admin') - cy.get('input#newDocId, input#newDocIdMobile').first().type(largeCsvDocId) + cy.get('[data-testid="new-doc-input"]').type(largeCsvDocId) cy.contains('button', 'Confirm').click() cy.url({ timeout: 10000 }).should('include', `/admin/docs/${largeCsvDocId}`) @@ -244,7 +242,7 @@ Bob Johnson,Development` // Step 6: Should show preview with all 50 valid emails cy.contains('Valid', { timeout: 10000 }).should('be.visible') - cy.get('.text-green-600').contains('50').should('be.visible') + cy.get('.text-emerald-600').contains('50').should('be.visible') // Step 7: Preview table should show some emails cy.contains('user1@test.com').should('be.visible') @@ -288,7 +286,7 @@ cancel2@test.com,Cancel User 2` // Step 6: Wait for preview cy.contains('Valid', { timeout: 10000 }).should('be.visible') - cy.get('.text-green-600').contains('2').should('be.visible') + cy.get('.text-emerald-600').contains('2').should('be.visible') // Step 7: Click Cancel button cy.contains('button', 'Cancel').click() @@ -330,18 +328,17 @@ another-new@test.com,Another New,Should be imported` // Step 5: Should show preview with accurate counts cy.contains('Valid', { timeout: 10000 }).should('be.visible') - cy.get('.text-green-600').contains('2').should('be.visible') + cy.get('.text-emerald-600').contains('2').should('be.visible') cy.contains('Already exist').should('be.visible') - cy.get('.text-orange-600').contains('1').should('be.visible') + cy.get('.text-amber-600').contains('1').should('be.visible') cy.contains('Invalid').should('be.visible') cy.get('.text-red-600').contains('1').should('be.visible') - // Step 6: Verify each category is displayed correctly + // Step 6: Verify valid and existing entries are displayed (invalid not shown in table) cy.contains('new-user@test.com').should('be.visible') cy.contains('another-new@test.com').should('be.visible') cy.contains('alice@test.com').should('be.visible') cy.contains('Existing').should('be.visible') - cy.contains('invalid-email').should('be.visible') // Step 7: Confirm import cy.contains('button', 'Import 2 reader').click() diff --git a/webapp/package-lock.json b/webapp/package-lock.json index dbdeced..e5871bf 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -8,8 +8,11 @@ "name": "webapp", "version": "0.0.0", "dependencies": { + "@types/dompurify": "^3.0.5", "axios": "^1.12.2", + "dompurify": "^3.3.1", "lucide-vue-next": "^0.546.0", + "marked": "^17.0.1", "pinia": "^3.0.3", "radix-vue": "^1.9.17", "vue": "^3.5.22", @@ -3710,6 +3713,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -3780,6 +3792,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, "node_modules/@types/web-bluetooth": { "version": "0.0.20", "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", @@ -5876,6 +5894,15 @@ "node": ">=8" } }, + "node_modules/dompurify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -8231,6 +8258,18 @@ "node": ">=10" } }, + "node_modules/marked": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz", + "integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/webapp/package.json b/webapp/package.json index 3f0efb0..8040d1f 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -19,8 +19,11 @@ "test:e2e:open": "cypress open" }, "dependencies": { + "@types/dompurify": "^3.0.5", "axios": "^1.12.2", + "dompurify": "^3.3.1", "lucide-vue-next": "^0.546.0", + "marked": "^17.0.1", "pinia": "^3.0.3", "radix-vue": "^1.9.17", "vue": "^3.5.22", diff --git a/webapp/src/components/DocumentCreateForm.vue b/webapp/src/components/DocumentCreateForm.vue new file mode 100644 index 0000000..0a9a24e --- /dev/null +++ b/webapp/src/components/DocumentCreateForm.vue @@ -0,0 +1,323 @@ + + + + diff --git a/webapp/src/pages/EmbedPage.vue b/webapp/src/pages/EmbedPage.vue index 896f588..d7c037e 100644 --- a/webapp/src/pages/EmbedPage.vue +++ b/webapp/src/pages/EmbedPage.vue @@ -58,10 +58,11 @@ -
+
@@ -72,7 +73,7 @@
{{ signature.userEmail }}
- {{ formatDateCompact(signature.signedAt) }} + {{ formatDateCompact(signature.signedAt) }}
diff --git a/webapp/src/pages/HomePage.vue b/webapp/src/pages/HomePage.vue index 590776e..3256088 100644 --- a/webapp/src/pages/HomePage.vue +++ b/webapp/src/pages/HomePage.vue @@ -7,17 +7,33 @@ import { useSignatureStore } from '@/stores/signatures' import { useI18n } from 'vue-i18n' import { usePageTitle } from '@/composables/usePageTitle' -const { t } = useI18n() +const { t, locale } = useI18n() usePageTitle('sign.title') -import { AlertTriangle, CheckCircle2, FileText, Info, Users, Loader2, Shield, Zap, Clock } from 'lucide-vue-next' +import { + AlertTriangle, + CheckCircle2, + FileText, + Users, + Loader2, + Shield, + Zap, + Clock, + ExternalLink, + Download, + Check, + Eye, + ArrowRight, + Sparkles, + Lock +} from 'lucide-vue-next' import SignButton from '@/components/SignButton.vue' import SignatureList from '@/components/SignatureList.vue' +import DocumentViewer from '@/components/viewer/DocumentViewer.vue' import { documentService, type FindOrCreateDocumentResponse } from '@/services/documents' import { detectReference } from '@/services/referenceDetector' import { calculateFileChecksum } from '@/services/checksumCalculator' import { updateDocumentMetadata } from '@/services/admin' -import DocumentForm from "@/components/DocumentForm.vue" const route = useRoute() const router = useRouter() @@ -26,13 +42,15 @@ const signatureStore = useSignatureStore() const docId = ref(undefined) const user = computed(() => authStore.user) -const isAdmin = computed(() => authStore.isAdmin) - -// Check if document creation is restricted to admins -const onlyAdminCanCreate = (window as any).ACKIFY_ONLY_ADMIN_CAN_CREATE || false -const canCreateDocument = computed(() => !onlyAdminCanCreate || isAdmin.value) +const isAuthenticated = computed(() => authStore.isAuthenticated) +const canCreateDocuments = computed(() => authStore.canCreateDocuments) const currentDocument = ref(null) +// Quick create form +const quickCreateUrl = ref('') +const quickCreateLoading = ref(false) +const quickCreateError = ref(null) + const documentSignatures = ref([]) const loadingSignatures = ref(false) const loadingDocument = ref(false) @@ -41,6 +59,10 @@ const errorMessage = ref(null) const needsAuth = ref(false) const calculatingChecksum = ref(false) +// New state for integrated viewer +const readComplete = ref(false) +const certifyChecked = ref(false) + // Check if current user has signed this document const userHasSigned = computed(() => { if (!user.value?.email || documentSignatures.value.length === 0) { @@ -49,6 +71,26 @@ const userHasSigned = computed(() => { return documentSignatures.value.some(sig => sig.userEmail === user.value?.email) }) +// Get user's signature if exists +const userSignature = computed(() => { + if (!user.value?.email || documentSignatures.value.length === 0) { + return null + } + return documentSignatures.value.find(sig => sig.userEmail === user.value?.email) +}) + +// Document properties +const isIntegratedMode = computed(() => currentDocument.value?.readMode === 'integrated') +const requiresFullRead = computed(() => currentDocument.value?.requireFullRead ?? false) +const allowDownload = computed(() => currentDocument.value?.allowDownload ?? false) + +// Can confirm: checkbox checked AND (if requireFullRead: must have completed read) +const canConfirm = computed(() => { + if (!certifyChecked.value) return false + if (isIntegratedMode.value && requiresFullRead.value && !readComplete.value) return false + return true +}) + async function loadDocumentSignatures() { if (!docId.value) return @@ -67,6 +109,8 @@ async function handleDocumentReference(ref: string) { loadingDocument.value = true errorMessage.value = null needsAuth.value = false + readComplete.value = false + certifyChecked.value = false console.log('Loading document for reference:', ref) @@ -87,7 +131,6 @@ async function handleDocumentReference(ref: string) { name: route.name as string, query: { doc: doc.docId } }) - // Continue loading even after redirect } // If new document AND downloadable URL → calculate checksum @@ -100,7 +143,6 @@ async function handleDocumentReference(ref: string) { } catch (error: any) { console.error('Failed to load/create document:', error) - // Handle 401 Unauthorized - user needs to authenticate if (error.response?.status === 401) { errorMessage.value = t('sign.error.authRequired') needsAuth.value = true @@ -117,6 +159,32 @@ function handleLoginClick() { authStore.startOAuthLogin(route.fullPath) } +async function handleQuickCreate() { + if (!quickCreateUrl.value.trim()) return + + quickCreateError.value = null + quickCreateLoading.value = true + + try { + // If not authenticated, redirect to login with next parameter + if (!isAuthenticated.value) { + // Store the URL to create after login + const nextUrl = `/documents/new?ref=${encodeURIComponent(quickCreateUrl.value.trim())}` + router.push({ name: 'auth-choice', query: { next: nextUrl } }) + return + } + + // Create document and redirect to edit page + const doc = await documentService.findOrCreateDocument(quickCreateUrl.value.trim()) + router.push({ name: 'document-edit', params: { id: doc.docId } }) + } catch (error: any) { + console.error('Failed to create document:', error) + quickCreateError.value = error.message || t('home.hero.createError') + } finally { + quickCreateLoading.value = false + } +} + async function calculateAndUpdateChecksum(docId: string, url: string) { try { calculatingChecksum.value = true @@ -125,14 +193,12 @@ async function calculateAndUpdateChecksum(docId: string, url: string) { const checksumData = await calculateFileChecksum(url) console.log('Checksum calculated:', checksumData.checksum) - // Update document metadata with checksum (if user is admin) if (authStore.isAdmin) { await updateDocumentMetadata(docId, { checksum: checksumData.checksum, checksumAlgorithm: checksumData.algorithm }) - // Update local document reference if (currentDocument.value) { currentDocument.value.checksum = checksumData.checksum currentDocument.value.checksumAlgorithm = checksumData.algorithm @@ -144,20 +210,22 @@ async function calculateAndUpdateChecksum(docId: string, url: string) { } } catch (error) { console.warn('Checksum calculation failed:', error) - // Don't fail the whole operation if checksum fails } finally { calculatingChecksum.value = false } } +function handleReadComplete() { + readComplete.value = true +} + async function handleSigned() { showSuccessMessage.value = true errorMessage.value = null + certifyChecked.value = false - // Reload signatures to show the new one await loadDocumentSignatures() - // Hide success message after 5 seconds setTimeout(() => { showSuccessMessage.value = false }, 5000) @@ -168,12 +236,52 @@ function handleError(error: string) { showSuccessMessage.value = false } +function formatDate(dateString: string): string { + const date = new Date(dateString) + return date.toLocaleDateString(locale.value, { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) +} + +function downloadProof() { + if (!userSignature.value || !currentDocument.value) return + + const proof = { + document: { + id: currentDocument.value.docId, + title: currentDocument.value.title, + url: currentDocument.value.url, + checksum: currentDocument.value.checksum, + algorithm: currentDocument.value.checksumAlgorithm + }, + signature: { + email: userSignature.value.userEmail, + name: userSignature.value.userName, + signedAt: userSignature.value.signedAt, + signature: userSignature.value.signature, + payloadHash: userSignature.value.payloadHash, + nonce: userSignature.value.nonce + }, + generatedAt: new Date().toISOString() + } + + const blob = new Blob([JSON.stringify(proof, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `proof-${currentDocument.value.docId}-${Date.now()}.json` + a.click() + URL.revokeObjectURL(url) +} + // Helper to wait for auth to be initialized by App.vue async function waitForAuth() { - // If already initialized, return immediately if (authStore.initialized) return - // Otherwise wait for initialized to become true return new Promise((resolve) => { const stopWatch = watch( () => authStore.initialized, @@ -188,33 +296,28 @@ async function waitForAuth() { }) } -// Watch for route query changes (only for changes, not initial mount) +// Watch for route query changes watch(() => route.query.doc, async (newRef, oldRef) => { - // Only process if the doc query parameter actually changed if (newRef === oldRef) return - // Reset state showSuccessMessage.value = false errorMessage.value = null needsAuth.value = false docId.value = undefined currentDocument.value = null documentSignatures.value = [] + readComplete.value = false + certifyChecked.value = false - // If we have a reference, load/create the document if (newRef && typeof newRef === 'string') { - // Wait for App.vue to finish checking auth await waitForAuth() await handleDocumentReference(newRef) } }) onMounted(async () => { - // CRITICAL: Wait for App.vue to finish auth check before doing anything - // App.vue calls checkAuth() which will set initialized=true when done await waitForAuth() - // Now handle the document reference if present in URL const ref = route.query.doc as string | undefined if (ref) { await handleDocumentReference(ref) @@ -224,17 +327,7 @@ onMounted(async () => {