mirror of
https://github.com/btouchard/ackify.git
synced 2026-02-06 13:58:41 -06:00
feat: add PKCE support to OAuth2 flow for enhanced security
- Implement PKCE (Proof Key for Code Exchange) with S256 method - Add crypto/pkce module with code verifier and challenge generation - Modify OAuth flow to include code_challenge in authorization requests - Update HandleCallback to validate code_verifier during token exchange - Extend session lifetime from 7 to 30 days - Add comprehensive unit tests for PKCE functions - Maintain backward compatibility with fallback for non-PKCE sessions - Add detailed logging for OAuth flow with PKCE tracking PKCE enhances security by preventing authorization code interception attacks, as recommended by OAuth 2.1 and OIDC standards. feat: add encrypted refresh token storage with automatic cleanup - Add oauth_sessions table for storing encrypted refresh tokens - Implement AES-256-GCM encryption for refresh tokens using cookie secret - Create OAuth session repository with full CRUD operations - Add SessionWorker for automatic cleanup of expired sessions - Configure cleanup to run every 24h for sessions older than 37 days - Modify OAuth flow to store refresh tokens after successful authentication - Track client IP and user agent for session security validation - Link OAuth sessions to user sessions via session ID - Add comprehensive encryption tests with security validations - Integrate SessionWorker into server lifecycle with graceful shutdown This enables persistent OAuth sessions with secure token storage, reducing the need for frequent re-authentication from 7 to 30 days.
This commit is contained in:
@@ -53,6 +53,7 @@ COPY --from=builder /app/migrate /app/migrate
|
||||
COPY --from=builder /app/backend/migrations /app/migrations
|
||||
COPY --from=builder /app/backend/locales /app/locales
|
||||
COPY --from=builder /app/backend/templates /app/templates
|
||||
COPY --from=builder /app/backend/openapi.yaml /app/openapi.yaml
|
||||
|
||||
ENV ACKIFY_TEMPLATES_DIR=/app/templates
|
||||
ENV ACKIFY_LOCALES_DIR=/app/locales
|
||||
|
||||
1203
README_FR.md
1203
README_FR.md
File diff suppressed because it is too large
Load Diff
1672
api/openapi.yaml
1672
api/openapi.yaml
File diff suppressed because it is too large
Load Diff
18
backend/internal/domain/models/oauth_session.go
Normal file
18
backend/internal/domain/models/oauth_session.go
Normal file
@@ -0,0 +1,18 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// OAuthSession represents an OAuth session with encrypted refresh token
|
||||
type OAuthSession struct {
|
||||
ID int64
|
||||
SessionID string
|
||||
UserSub string
|
||||
RefreshTokenEncrypted []byte
|
||||
AccessTokenExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
LastRefreshedAt *time.Time
|
||||
UserAgent string
|
||||
IPAddress string
|
||||
}
|
||||
@@ -9,17 +9,28 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/securecookie"
|
||||
"github.com/gorilla/sessions"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/btouchard/ackify-ce/backend/internal/domain/models"
|
||||
"github.com/btouchard/ackify-ce/backend/pkg/crypto"
|
||||
"github.com/btouchard/ackify-ce/backend/pkg/logger"
|
||||
)
|
||||
|
||||
const sessionName = "ackapp_session"
|
||||
|
||||
// SessionRepository defines the interface for OAuth session storage
|
||||
type SessionRepository interface {
|
||||
Create(ctx context.Context, session *models.OAuthSession) error
|
||||
GetBySessionID(ctx context.Context, sessionID string) (*models.OAuthSession, error)
|
||||
UpdateRefreshToken(ctx context.Context, sessionID string, encryptedToken []byte, expiresAt time.Time) error
|
||||
DeleteBySessionID(ctx context.Context, sessionID string) error
|
||||
DeleteExpired(ctx context.Context, olderThan time.Duration) (int64, error)
|
||||
}
|
||||
|
||||
type OauthService struct {
|
||||
oauthConfig *oauth2.Config
|
||||
sessionStore *sessions.CookieStore
|
||||
@@ -28,6 +39,8 @@ type OauthService struct {
|
||||
allowedDomain string
|
||||
secureCookies bool
|
||||
baseURL string
|
||||
sessionRepo SessionRepository
|
||||
encryptionKey []byte
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
@@ -42,6 +55,7 @@ type Config struct {
|
||||
AllowedDomain string
|
||||
CookieSecret []byte
|
||||
SecureCookies bool
|
||||
SessionRepo SessionRepository
|
||||
}
|
||||
|
||||
func NewOAuthService(config Config) *OauthService {
|
||||
@@ -64,12 +78,26 @@ func NewOAuthService(config Config) *OauthService {
|
||||
HttpOnly: true,
|
||||
Secure: config.SecureCookies,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 86400 * 7, // 7 days
|
||||
MaxAge: 86400 * 30, // 30 days
|
||||
}
|
||||
|
||||
logger.Logger.Info("OAuth session store configured",
|
||||
"secure_cookies", config.SecureCookies,
|
||||
"max_age_days", 7)
|
||||
"max_age_days", 30)
|
||||
|
||||
// Use CookieSecret as encryption key (must be 32 bytes for AES-256)
|
||||
encryptionKey := config.CookieSecret
|
||||
if len(encryptionKey) < 32 {
|
||||
logger.Logger.Warn("Encryption key too short, padding to 32 bytes",
|
||||
"original_length", len(encryptionKey))
|
||||
// Pad with zeros (not ideal, but prevents crashes)
|
||||
padded := make([]byte, 32)
|
||||
copy(padded, encryptionKey)
|
||||
encryptionKey = padded
|
||||
} else if len(encryptionKey) > 32 {
|
||||
// Truncate to 32 bytes for AES-256
|
||||
encryptionKey = encryptionKey[:32]
|
||||
}
|
||||
|
||||
return &OauthService{
|
||||
oauthConfig: oauthConfig,
|
||||
@@ -79,6 +107,8 @@ func NewOAuthService(config Config) *OauthService {
|
||||
allowedDomain: config.AllowedDomain,
|
||||
secureCookies: config.SecureCookies,
|
||||
baseURL: config.BaseURL,
|
||||
sessionRepo: config.SessionRepo,
|
||||
encryptionKey: encryptionKey,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +208,18 @@ func (s *OauthService) GetAuthURL(nextURL string) string {
|
||||
}
|
||||
|
||||
func (s *OauthService) CreateAuthURL(w http.ResponseWriter, r *http.Request, nextURL string) string {
|
||||
// Generate PKCE code verifier and challenge
|
||||
codeVerifier, err := crypto.GenerateCodeVerifier()
|
||||
if err != nil {
|
||||
logger.Logger.Error("Failed to generate PKCE code verifier", "error", err.Error())
|
||||
// Fallback to OAuth flow without PKCE for backward compatibility
|
||||
return s.createAuthURLWithoutPKCE(w, r, nextURL)
|
||||
}
|
||||
|
||||
codeChallenge := crypto.GenerateCodeChallenge(codeVerifier)
|
||||
logger.Logger.Debug("Generated PKCE parameters for OAuth flow")
|
||||
|
||||
// Generate state token
|
||||
randPart := securecookie.GenerateRandomKey(20)
|
||||
token := base64.RawURLEncoding.EncodeToString(randPart)
|
||||
state := token + ":" + base64.RawURLEncoding.EncodeToString([]byte(nextURL))
|
||||
@@ -188,7 +230,7 @@ func (s *OauthService) CreateAuthURL(w http.ResponseWriter, r *http.Request, nex
|
||||
promptParam = "none"
|
||||
}
|
||||
|
||||
logger.Logger.Info("Starting OAuth flow",
|
||||
logger.Logger.Info("Starting OAuth flow with PKCE",
|
||||
"next_url", nextURL,
|
||||
"silent", isSilent,
|
||||
"state_token_length", len(token))
|
||||
@@ -200,10 +242,51 @@ func (s *OauthService) CreateAuthURL(w http.ResponseWriter, r *http.Request, nex
|
||||
session, _ = s.sessionStore.New(r, sessionName)
|
||||
}
|
||||
|
||||
// Store state and code_verifier in session
|
||||
session.Values["oauth_state"] = token
|
||||
session.Values["code_verifier"] = codeVerifier
|
||||
|
||||
// Session options are already configured globally on the store
|
||||
// No need to set them again here
|
||||
err = session.Save(r, w)
|
||||
if err != nil {
|
||||
logger.Logger.Error("CreateAuthURL: failed to save session", "error", err.Error())
|
||||
}
|
||||
|
||||
// Generate OAuth URL with PKCE parameters
|
||||
authURL := s.oauthConfig.AuthCodeURL(state,
|
||||
oauth2.SetAuthURLParam("prompt", promptParam),
|
||||
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
|
||||
oauth2.SetAuthURLParam("code_challenge_method", "S256"))
|
||||
|
||||
logger.Logger.Debug("CreateAuthURL: generated auth URL with PKCE",
|
||||
"prompt", promptParam,
|
||||
"url_length", len(authURL))
|
||||
|
||||
return authURL
|
||||
}
|
||||
|
||||
// createAuthURLWithoutPKCE is a fallback method for OAuth without PKCE
|
||||
// Used for backward compatibility if PKCE generation fails
|
||||
func (s *OauthService) createAuthURLWithoutPKCE(w http.ResponseWriter, r *http.Request, nextURL string) string {
|
||||
randPart := securecookie.GenerateRandomKey(20)
|
||||
token := base64.RawURLEncoding.EncodeToString(randPart)
|
||||
state := token + ":" + base64.RawURLEncoding.EncodeToString([]byte(nextURL))
|
||||
|
||||
promptParam := "select_account"
|
||||
isSilent := r.URL.Query().Get("silent") == "true"
|
||||
if isSilent {
|
||||
promptParam = "none"
|
||||
}
|
||||
|
||||
logger.Logger.Warn("Starting OAuth flow WITHOUT PKCE (fallback mode)",
|
||||
"next_url", nextURL,
|
||||
"silent", isSilent)
|
||||
|
||||
session, err := s.sessionStore.Get(r, sessionName)
|
||||
if err != nil {
|
||||
session, _ = s.sessionStore.New(r, sessionName)
|
||||
}
|
||||
|
||||
session.Values["oauth_state"] = token
|
||||
|
||||
err = session.Save(r, w)
|
||||
if err != nil {
|
||||
@@ -211,9 +294,6 @@ func (s *OauthService) CreateAuthURL(w http.ResponseWriter, r *http.Request, nex
|
||||
}
|
||||
|
||||
authURL := s.oauthConfig.AuthCodeURL(state, oauth2.SetAuthURLParam("prompt", promptParam))
|
||||
logger.Logger.Debug("CreateAuthURL: generated auth URL",
|
||||
"prompt", promptParam,
|
||||
"url_length", len(authURL))
|
||||
|
||||
return authURL
|
||||
}
|
||||
@@ -255,7 +335,7 @@ func subtleConstantTimeCompare(a, b string) bool {
|
||||
return v == 0
|
||||
}
|
||||
|
||||
func (s *OauthService) HandleCallback(ctx context.Context, code, state string) (*models.User, string, error) {
|
||||
func (s *OauthService) HandleCallback(ctx context.Context, w http.ResponseWriter, r *http.Request, code, state string) (*models.User, string, error) {
|
||||
parts := strings.SplitN(state, ":", 2)
|
||||
nextURL := "/"
|
||||
if len(parts) == 2 {
|
||||
@@ -268,14 +348,37 @@ func (s *OauthService) HandleCallback(ctx context.Context, code, state string) (
|
||||
"has_code", code != "",
|
||||
"next_url", nextURL)
|
||||
|
||||
token, err := s.oauthConfig.Exchange(ctx, code)
|
||||
// Retrieve code_verifier from session for PKCE
|
||||
session, _ := s.sessionStore.Get(r, sessionName)
|
||||
codeVerifier, hasPKCE := session.Values["code_verifier"].(string)
|
||||
|
||||
// Clean up code_verifier immediately after retrieval
|
||||
if hasPKCE {
|
||||
delete(session.Values, "code_verifier")
|
||||
_ = session.Save(r, w)
|
||||
}
|
||||
|
||||
// Exchange authorization code for token (with or without PKCE)
|
||||
var token *oauth2.Token
|
||||
var err error
|
||||
|
||||
if hasPKCE && codeVerifier != "" {
|
||||
logger.Logger.Info("OAuth token exchange with PKCE")
|
||||
token, err = s.oauthConfig.Exchange(ctx, code,
|
||||
oauth2.SetAuthURLParam("code_verifier", codeVerifier))
|
||||
} else {
|
||||
logger.Logger.Warn("OAuth token exchange without PKCE (legacy session or fallback)")
|
||||
token, err = s.oauthConfig.Exchange(ctx, code)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.Logger.Error("OAuth token exchange failed",
|
||||
"error", err.Error())
|
||||
"error", err.Error(),
|
||||
"with_pkce", hasPKCE)
|
||||
return nil, nextURL, fmt.Errorf("oauth exchange failed: %w", err)
|
||||
}
|
||||
|
||||
logger.Logger.Debug("OAuth token exchange successful")
|
||||
logger.Logger.Info("OAuth token exchange successful", "with_pkce", hasPKCE)
|
||||
|
||||
client := s.oauthConfig.Client(ctx, token)
|
||||
resp, err := client.Get(s.userInfoURL)
|
||||
@@ -314,9 +417,95 @@ func (s *OauthService) HandleCallback(ctx context.Context, code, state string) (
|
||||
"user_email", user.Email,
|
||||
"user_name", user.Name)
|
||||
|
||||
// Store refresh token if available and repository is configured
|
||||
if token.RefreshToken != "" && s.sessionRepo != nil && s.encryptionKey != nil {
|
||||
if err := s.storeRefreshToken(ctx, w, r, token, user); err != nil {
|
||||
// Log error but don't fail the authentication
|
||||
logger.Logger.Error("Failed to store refresh token (non-fatal)",
|
||||
"user_sub", user.Sub,
|
||||
"error", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
return user, nextURL, nil
|
||||
}
|
||||
|
||||
// storeRefreshToken encrypts and stores the OAuth refresh token
|
||||
func (s *OauthService) storeRefreshToken(ctx context.Context, w http.ResponseWriter, r *http.Request, token *oauth2.Token, user *models.User) error {
|
||||
// Encrypt refresh token
|
||||
encryptedToken, err := crypto.EncryptToken(token.RefreshToken, s.encryptionKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt refresh token: %w", err)
|
||||
}
|
||||
|
||||
// Generate unique session ID for OAuth session tracking
|
||||
sessionID := generateSessionID()
|
||||
|
||||
// Get client IP and user agent for security tracking
|
||||
ipAddress := getClientIP(r)
|
||||
userAgent := r.UserAgent()
|
||||
|
||||
// Create OAuth session
|
||||
oauthSession := &models.OAuthSession{
|
||||
SessionID: sessionID,
|
||||
UserSub: user.Sub,
|
||||
RefreshTokenEncrypted: encryptedToken,
|
||||
AccessTokenExpiresAt: token.Expiry,
|
||||
UserAgent: userAgent,
|
||||
IPAddress: ipAddress,
|
||||
}
|
||||
|
||||
// Save to database
|
||||
if err := s.sessionRepo.Create(ctx, oauthSession); err != nil {
|
||||
return fmt.Errorf("failed to create OAuth session: %w", err)
|
||||
}
|
||||
|
||||
// Link OAuth session ID to user session
|
||||
userSession, _ := s.sessionStore.Get(r, sessionName)
|
||||
userSession.Values["oauth_session_id"] = sessionID
|
||||
if err := userSession.Save(r, w); err != nil {
|
||||
logger.Logger.Error("Failed to link OAuth session to user session",
|
||||
"session_id", sessionID,
|
||||
"error", err.Error())
|
||||
// Don't return error, session is already created in DB
|
||||
}
|
||||
|
||||
logger.Logger.Info("Stored encrypted refresh token",
|
||||
"user_sub", user.Sub,
|
||||
"session_id", sessionID,
|
||||
"expires_at", token.Expiry)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateSessionID generates a unique session ID for OAuth sessions
|
||||
func generateSessionID() string {
|
||||
nonce, _ := crypto.GenerateNonce()
|
||||
return nonce
|
||||
}
|
||||
|
||||
// getClientIP extracts the client IP address from the request
|
||||
func getClientIP(r *http.Request) string {
|
||||
// Check X-Forwarded-For header (if behind proxy)
|
||||
forwarded := r.Header.Get("X-Forwarded-For")
|
||||
if forwarded != "" {
|
||||
// Take the first IP in the list
|
||||
parts := strings.Split(forwarded, ",")
|
||||
if len(parts) > 0 {
|
||||
return strings.TrimSpace(parts[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Check X-Real-IP header
|
||||
realIP := r.Header.Get("X-Real-IP")
|
||||
if realIP != "" {
|
||||
return realIP
|
||||
}
|
||||
|
||||
// Fallback to RemoteAddr
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
func (s *OauthService) IsAllowedDomain(email string) bool {
|
||||
if s.allowedDomain == "" {
|
||||
return true
|
||||
|
||||
@@ -754,7 +754,9 @@ func TestOauthService_HandleCallback_StateDecoding(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// We can't easily test the full HandleCallback without mocking OAuth2 exchange
|
||||
// So we test the state parsing logic by calling with invalid code
|
||||
_, nextURL, _ := service.HandleCallback(context.Background(), "invalid-code", tt.state)
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
_, nextURL, _ := service.HandleCallback(context.Background(), w, r, "invalid-code", tt.state)
|
||||
|
||||
if nextURL != tt.expectedURL {
|
||||
t.Errorf("NextURL = %v, expected %v", nextURL, tt.expectedURL)
|
||||
|
||||
165
backend/internal/infrastructure/auth/session_worker.go
Normal file
165
backend/internal/infrastructure/auth/session_worker.go
Normal file
@@ -0,0 +1,165 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/btouchard/ackify-ce/backend/pkg/logger"
|
||||
)
|
||||
|
||||
// SessionWorker handles background cleanup of expired OAuth sessions
|
||||
type SessionWorker struct {
|
||||
sessionRepo SessionRepository
|
||||
cleanupInterval time.Duration
|
||||
cleanupAge time.Duration
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
stopChan chan struct{}
|
||||
started bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// SessionWorkerConfig contains configuration for the session worker
|
||||
type SessionWorkerConfig struct {
|
||||
CleanupInterval time.Duration // How often to run cleanup (default: 24 hours)
|
||||
CleanupAge time.Duration // Age of sessions to delete (default: 37 days = 30 + 7 grace period)
|
||||
}
|
||||
|
||||
// DefaultSessionWorkerConfig returns default session worker configuration
|
||||
func DefaultSessionWorkerConfig() SessionWorkerConfig {
|
||||
return SessionWorkerConfig{
|
||||
CleanupInterval: 24 * time.Hour, // Run cleanup once per day
|
||||
CleanupAge: (30 + 7) * 24 * time.Hour, // Delete sessions older than 37 days
|
||||
}
|
||||
}
|
||||
|
||||
// NewSessionWorker creates a new OAuth session cleanup worker
|
||||
func NewSessionWorker(sessionRepo SessionRepository, config SessionWorkerConfig) *SessionWorker {
|
||||
// Apply defaults
|
||||
if config.CleanupInterval <= 0 {
|
||||
config.CleanupInterval = 24 * time.Hour
|
||||
}
|
||||
if config.CleanupAge <= 0 {
|
||||
config.CleanupAge = 37 * 24 * time.Hour
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
return &SessionWorker{
|
||||
sessionRepo: sessionRepo,
|
||||
cleanupInterval: config.CleanupInterval,
|
||||
cleanupAge: config.CleanupAge,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the cleanup worker
|
||||
func (w *SessionWorker) Start() error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
if w.started {
|
||||
return fmt.Errorf("session worker already started")
|
||||
}
|
||||
|
||||
logger.Logger.Info("Starting OAuth session cleanup worker",
|
||||
"cleanup_interval", w.cleanupInterval,
|
||||
"cleanup_age", w.cleanupAge)
|
||||
|
||||
w.started = true
|
||||
|
||||
// Start the cleanup loop
|
||||
w.wg.Add(1)
|
||||
go w.cleanupLoop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully stops the worker
|
||||
func (w *SessionWorker) Stop() error {
|
||||
w.mu.Lock()
|
||||
if !w.started {
|
||||
w.mu.Unlock()
|
||||
return fmt.Errorf("session worker not started")
|
||||
}
|
||||
w.mu.Unlock()
|
||||
|
||||
logger.Logger.Info("Stopping OAuth session cleanup worker...")
|
||||
|
||||
// Signal shutdown
|
||||
w.cancel()
|
||||
close(w.stopChan)
|
||||
|
||||
// Wait for goroutines to finish with timeout
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
w.wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
logger.Logger.Info("OAuth session cleanup worker stopped gracefully")
|
||||
case <-time.After(30 * time.Second):
|
||||
logger.Logger.Warn("OAuth session cleanup worker stop timeout")
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
w.started = false
|
||||
w.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupLoop periodically cleans up expired sessions
|
||||
func (w *SessionWorker) cleanupLoop() {
|
||||
defer w.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(w.cleanupInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Run cleanup immediately on start
|
||||
w.performCleanup()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-w.ctx.Done():
|
||||
return
|
||||
case <-w.stopChan:
|
||||
return
|
||||
case <-ticker.C:
|
||||
w.performCleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// performCleanup removes expired OAuth sessions
|
||||
func (w *SessionWorker) performCleanup() {
|
||||
ctx, cancel := context.WithTimeout(w.ctx, 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
logger.Logger.Debug("Starting OAuth session cleanup",
|
||||
"older_than", w.cleanupAge)
|
||||
|
||||
deleted, err := w.sessionRepo.DeleteExpired(ctx, w.cleanupAge)
|
||||
if err != nil {
|
||||
logger.Logger.Error("Failed to cleanup expired OAuth sessions",
|
||||
"error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if deleted > 0 {
|
||||
logger.Logger.Info("Cleaned up expired OAuth sessions",
|
||||
"count", deleted,
|
||||
"older_than", w.cleanupAge)
|
||||
} else {
|
||||
logger.Logger.Debug("No expired OAuth sessions to clean up")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/btouchard/ackify-ce/backend/internal/domain/models"
|
||||
"github.com/btouchard/ackify-ce/backend/pkg/logger"
|
||||
)
|
||||
|
||||
// oauthSessionRepository defines the interface for OAuth session operations
|
||||
type oauthSessionRepository interface {
|
||||
Create(ctx context.Context, session *models.OAuthSession) error
|
||||
GetBySessionID(ctx context.Context, sessionID string) (*models.OAuthSession, error)
|
||||
UpdateRefreshToken(ctx context.Context, sessionID string, encryptedToken []byte, expiresAt time.Time) error
|
||||
DeleteBySessionID(ctx context.Context, sessionID string) error
|
||||
DeleteExpired(ctx context.Context, olderThan time.Duration) (int64, error)
|
||||
}
|
||||
|
||||
// OAuthSessionRepository implements the OAuth session repository
|
||||
type OAuthSessionRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewOAuthSessionRepository creates a new OAuth session repository
|
||||
func NewOAuthSessionRepository(db *sql.DB) *OAuthSessionRepository {
|
||||
return &OAuthSessionRepository{db: db}
|
||||
}
|
||||
|
||||
// Create creates a new OAuth session
|
||||
func (r *OAuthSessionRepository) Create(ctx context.Context, session *models.OAuthSession) error {
|
||||
query := `
|
||||
INSERT INTO oauth_sessions (
|
||||
session_id,
|
||||
user_sub,
|
||||
refresh_token_encrypted,
|
||||
access_token_expires_at,
|
||||
user_agent,
|
||||
ip_address
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, created_at, updated_at
|
||||
`
|
||||
|
||||
err := r.db.QueryRowContext(
|
||||
ctx,
|
||||
query,
|
||||
session.SessionID,
|
||||
session.UserSub,
|
||||
session.RefreshTokenEncrypted,
|
||||
session.AccessTokenExpiresAt,
|
||||
session.UserAgent,
|
||||
session.IPAddress,
|
||||
).Scan(&session.ID, &session.CreatedAt, &session.UpdatedAt)
|
||||
|
||||
if err != nil {
|
||||
logger.Logger.Error("Failed to create OAuth session",
|
||||
"session_id", session.SessionID,
|
||||
"user_sub", session.UserSub,
|
||||
"error", err.Error())
|
||||
return fmt.Errorf("failed to create OAuth session: %w", err)
|
||||
}
|
||||
|
||||
logger.Logger.Info("Created OAuth session",
|
||||
"session_id", session.SessionID,
|
||||
"user_sub", session.UserSub)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetBySessionID retrieves an OAuth session by session ID
|
||||
func (r *OAuthSessionRepository) GetBySessionID(ctx context.Context, sessionID string) (*models.OAuthSession, error) {
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
session_id,
|
||||
user_sub,
|
||||
refresh_token_encrypted,
|
||||
access_token_expires_at,
|
||||
created_at,
|
||||
updated_at,
|
||||
last_refreshed_at,
|
||||
user_agent,
|
||||
ip_address
|
||||
FROM oauth_sessions
|
||||
WHERE session_id = $1
|
||||
`
|
||||
|
||||
session := &models.OAuthSession{}
|
||||
var lastRefreshedAt sql.NullTime
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query, sessionID).Scan(
|
||||
&session.ID,
|
||||
&session.SessionID,
|
||||
&session.UserSub,
|
||||
&session.RefreshTokenEncrypted,
|
||||
&session.AccessTokenExpiresAt,
|
||||
&session.CreatedAt,
|
||||
&session.UpdatedAt,
|
||||
&lastRefreshedAt,
|
||||
&session.UserAgent,
|
||||
&session.IPAddress,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("OAuth session not found")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.Logger.Error("Failed to get OAuth session",
|
||||
"session_id", sessionID,
|
||||
"error", err.Error())
|
||||
return nil, fmt.Errorf("failed to get OAuth session: %w", err)
|
||||
}
|
||||
|
||||
if lastRefreshedAt.Valid {
|
||||
session.LastRefreshedAt = &lastRefreshedAt.Time
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// UpdateRefreshToken updates the refresh token and expiration time
|
||||
func (r *OAuthSessionRepository) UpdateRefreshToken(ctx context.Context, sessionID string, encryptedToken []byte, expiresAt time.Time) error {
|
||||
query := `
|
||||
UPDATE oauth_sessions
|
||||
SET
|
||||
refresh_token_encrypted = $1,
|
||||
access_token_expires_at = $2,
|
||||
last_refreshed_at = now(),
|
||||
updated_at = now()
|
||||
WHERE session_id = $3
|
||||
`
|
||||
|
||||
result, err := r.db.ExecContext(ctx, query, encryptedToken, expiresAt, sessionID)
|
||||
if err != nil {
|
||||
logger.Logger.Error("Failed to update OAuth session refresh token",
|
||||
"session_id", sessionID,
|
||||
"error", err.Error())
|
||||
return fmt.Errorf("failed to update refresh token: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get rows affected: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("OAuth session not found")
|
||||
}
|
||||
|
||||
logger.Logger.Info("Updated OAuth session refresh token",
|
||||
"session_id", sessionID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteBySessionID deletes an OAuth session by session ID
|
||||
func (r *OAuthSessionRepository) DeleteBySessionID(ctx context.Context, sessionID string) error {
|
||||
query := `DELETE FROM oauth_sessions WHERE session_id = $1`
|
||||
|
||||
result, err := r.db.ExecContext(ctx, query, sessionID)
|
||||
if err != nil {
|
||||
logger.Logger.Error("Failed to delete OAuth session",
|
||||
"session_id", sessionID,
|
||||
"error", err.Error())
|
||||
return fmt.Errorf("failed to delete OAuth session: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get rows affected: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected > 0 {
|
||||
logger.Logger.Info("Deleted OAuth session", "session_id", sessionID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteExpired deletes OAuth sessions older than the specified duration
|
||||
func (r *OAuthSessionRepository) DeleteExpired(ctx context.Context, olderThan time.Duration) (int64, error) {
|
||||
query := `
|
||||
DELETE FROM oauth_sessions
|
||||
WHERE updated_at < $1
|
||||
`
|
||||
|
||||
cutoffTime := time.Now().Add(-olderThan)
|
||||
result, err := r.db.ExecContext(ctx, query, cutoffTime)
|
||||
if err != nil {
|
||||
logger.Logger.Error("Failed to delete expired OAuth sessions",
|
||||
"cutoff_time", cutoffTime,
|
||||
"error", err.Error())
|
||||
return 0, fmt.Errorf("failed to delete expired sessions: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get rows affected: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected > 0 {
|
||||
logger.Logger.Info("Deleted expired OAuth sessions",
|
||||
"count", rowsAffected,
|
||||
"older_than", olderThan)
|
||||
}
|
||||
|
||||
return rowsAffected, nil
|
||||
}
|
||||
@@ -41,7 +41,7 @@ func TestNewI18n_InvalidDirectory(t *testing.T) {
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, i18n)
|
||||
assert.Contains(t, err.Error(), "failed to load English translations")
|
||||
assert.Contains(t, err.Error(), "failed to load en translations")
|
||||
}
|
||||
|
||||
func TestNewI18n_MissingEnglishFile(t *testing.T) {
|
||||
@@ -81,10 +81,10 @@ func TestI18n_T_EnglishTranslation(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test a known key from en.json
|
||||
result := i18n.T("en", "site.title")
|
||||
result := i18n.T("en", "email.reminder.subject")
|
||||
assert.NotEmpty(t, result)
|
||||
assert.NotEqual(t, "site.title", result, "Should return translation, not key")
|
||||
assert.Contains(t, result, "Ackify", "Should contain 'Ackify'")
|
||||
assert.NotEqual(t, "email.reminder.subject", result, "Should return translation, not key")
|
||||
assert.Contains(t, result, "Document Reading", "Should contain expected text")
|
||||
}
|
||||
|
||||
func TestI18n_T_FrenchTranslation(t *testing.T) {
|
||||
@@ -94,10 +94,10 @@ func TestI18n_T_FrenchTranslation(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test a known key from fr.json
|
||||
result := i18n.T("fr", "site.title")
|
||||
result := i18n.T("fr", "email.reminder.subject")
|
||||
assert.NotEmpty(t, result)
|
||||
assert.NotEqual(t, "site.title", result, "Should return translation, not key")
|
||||
assert.Contains(t, result, "Ackify", "Should contain 'Ackify'")
|
||||
assert.NotEqual(t, "email.reminder.subject", result, "Should return translation, not key")
|
||||
assert.Contains(t, result, "lecture", "Should contain expected French text")
|
||||
}
|
||||
|
||||
func TestI18n_T_FallbackToEnglish(t *testing.T) {
|
||||
@@ -107,7 +107,7 @@ func TestI18n_T_FallbackToEnglish(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Request French translation for a key - should work for existing keys
|
||||
result := i18n.T("fr", "site.title")
|
||||
result := i18n.T("fr", "email.reminder.subject")
|
||||
assert.NotEmpty(t, result)
|
||||
}
|
||||
|
||||
@@ -129,10 +129,10 @@ func TestI18n_T_UnknownLanguage(t *testing.T) {
|
||||
i18n, err := NewI18n(testLocalesDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test with unsupported language, should fallback to English
|
||||
result := i18n.T("de", "site.title")
|
||||
// Test with unsupported language (Chinese), should fallback to English
|
||||
result := i18n.T("zh", "email.reminder.subject")
|
||||
assert.NotEmpty(t, result)
|
||||
assert.Contains(t, result, "Ackify", "Should fallback to English translation")
|
||||
assert.Contains(t, result, "Document Reading", "Should fallback to English translation")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -215,7 +215,7 @@ func TestGetLangFromRequest_FromAcceptLanguageHeader(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "Unsupported language defaults to English",
|
||||
acceptLang: "de,es",
|
||||
acceptLang: "zh,ja",
|
||||
expectedLang: "en",
|
||||
},
|
||||
}
|
||||
@@ -327,7 +327,7 @@ func TestSetLangCookie_UnsupportedLanguage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
SetLangCookie(rec, "de", false)
|
||||
SetLangCookie(rec, "zh", false)
|
||||
|
||||
cookies := rec.Result().Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
@@ -379,14 +379,14 @@ func Test_normalizeLang(t *testing.T) {
|
||||
expected: "en",
|
||||
},
|
||||
{
|
||||
name: "Other language",
|
||||
name: "German",
|
||||
input: "de",
|
||||
expected: "de",
|
||||
},
|
||||
{
|
||||
name: "Other language with region",
|
||||
name: "German with region",
|
||||
input: "de-DE",
|
||||
expected: "de-de",
|
||||
expected: "de",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -435,11 +435,21 @@ func Test_isSupported(t *testing.T) {
|
||||
{
|
||||
name: "German",
|
||||
lang: "de",
|
||||
expected: false,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Spanish",
|
||||
lang: "es",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Italian",
|
||||
lang: "it",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Unsupported language (Chinese)",
|
||||
lang: "zh",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
@@ -484,8 +494,8 @@ func TestI18n_GetTranslations_UnsupportedLanguage(t *testing.T) {
|
||||
i18n, err := NewI18n(testLocalesDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should fallback to default language (English)
|
||||
translations := i18n.GetTranslations("de")
|
||||
// Should fallback to default language (English) for truly unsupported languages
|
||||
translations := i18n.GetTranslations("zh")
|
||||
assert.NotEmpty(t, translations)
|
||||
assert.Equal(t, i18n.translations[DefaultLang], translations)
|
||||
}
|
||||
@@ -512,7 +522,7 @@ func TestI18n_T_Concurrent(t *testing.T) {
|
||||
lang = "fr"
|
||||
}
|
||||
|
||||
result := i18n.T(lang, "site.title")
|
||||
result := i18n.T(lang, "email.reminder.subject")
|
||||
assert.NotEmpty(t, result)
|
||||
}(i)
|
||||
}
|
||||
@@ -533,7 +543,7 @@ func BenchmarkI18n_T(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
i18n.T("en", "site.title")
|
||||
i18n.T("en", "email.reminder.subject")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,7 +553,7 @@ func BenchmarkI18n_T_Parallel(b *testing.B) {
|
||||
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
i18n.T("en", "site.title")
|
||||
i18n.T("en", "email.reminder.subject")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -656,7 +656,7 @@ func TestHandleSendReminders_Success(t *testing.T) {
|
||||
sendRemindersFunc: func(ctx context.Context, docID, sentBy string, specificEmails []string, docURL string, locale string) (*models.ReminderSendResult, error) {
|
||||
assert.Equal(t, "doc1", docID)
|
||||
assert.Equal(t, "admin@example.com", sentBy)
|
||||
assert.Equal(t, "fr", locale)
|
||||
assert.Equal(t, "en", locale) // Default locale when no language preference is set
|
||||
return &models.ReminderSendResult{
|
||||
TotalAttempted: 2,
|
||||
SuccessfullySent: 2,
|
||||
|
||||
@@ -146,7 +146,7 @@ func (h *Handler) HandleOAuthCallback(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
user, nextURL, err := h.authService.HandleCallback(ctx, code, state)
|
||||
user, nextURL, err := h.authService.HandleCallback(ctx, w, r, code, state)
|
||||
if err != nil {
|
||||
logger.Logger.Error("OAuth callback failed", "error", err.Error())
|
||||
handlers.HandleError(w, err)
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/btouchard/ackify-ce/backend/internal/application/services"
|
||||
"github.com/btouchard/ackify-ce/backend/internal/infrastructure/auth"
|
||||
@@ -167,9 +170,34 @@ func NewRouter(cfg RouterConfig) *chi.Mux {
|
||||
|
||||
// serveOpenAPISpec serves the OpenAPI specification
|
||||
func serveOpenAPISpec(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: Read and serve the OpenAPI YAML file as JSON
|
||||
// For now, return a simple response
|
||||
// Read the OpenAPI YAML file and convert to JSON
|
||||
yamlData, err := os.ReadFile("openapi.yaml")
|
||||
if err != nil {
|
||||
// Fallback to basic response if file not found
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"info":{"title":"Ackify API","version":"1.0.0"},"message":"OpenAPI spec file not found - see /backend/openapi.yaml"}`))
|
||||
return
|
||||
}
|
||||
|
||||
// Parse YAML and convert to JSON
|
||||
var spec map[string]interface{}
|
||||
if err := yaml.Unmarshal(yamlData, &spec); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"error":"Failed to parse OpenAPI spec"}`))
|
||||
return
|
||||
}
|
||||
|
||||
jsonData, err := json.MarshalIndent(spec, "", " ")
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"error":"Failed to convert OpenAPI spec to JSON"}`))
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"info":{"title":"Ackify API","version":"1.0.0"}}`))
|
||||
w.Write(jsonData)
|
||||
}
|
||||
|
||||
4
backend/migrations/0009_add_oauth_sessions.down.sql
Normal file
4
backend/migrations/0009_add_oauth_sessions.down.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-- Drop oauth_sessions table
|
||||
DROP TABLE IF EXISTS oauth_sessions CASCADE;
|
||||
29
backend/migrations/0009_add_oauth_sessions.up.sql
Normal file
29
backend/migrations/0009_add_oauth_sessions.up.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
-- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-- Table for storing OAuth refresh tokens securely
|
||||
CREATE TABLE IF NOT EXISTS oauth_sessions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
session_id TEXT NOT NULL UNIQUE, -- Gorilla session ID
|
||||
user_sub TEXT NOT NULL, -- OAuth user ID (sub claim)
|
||||
refresh_token_encrypted BYTEA NOT NULL, -- AES-256-GCM encrypted refresh token
|
||||
access_token_expires_at TIMESTAMPTZ, -- When the access token expires
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_refreshed_at TIMESTAMPTZ, -- Last time token was refreshed
|
||||
|
||||
-- Security metadata for session validation
|
||||
user_agent TEXT, -- User agent for session binding
|
||||
ip_address INET -- IP address for session binding
|
||||
);
|
||||
|
||||
-- Indexes for fast lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_sessions_session_id ON oauth_sessions(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_sessions_user_sub ON oauth_sessions(user_sub);
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_sessions_expires_at ON oauth_sessions(access_token_expires_at);
|
||||
|
||||
-- Comment for documentation
|
||||
COMMENT ON TABLE oauth_sessions IS 'Stores encrypted OAuth refresh tokens for session management';
|
||||
COMMENT ON COLUMN oauth_sessions.refresh_token_encrypted IS 'Refresh token encrypted with AES-256-GCM';
|
||||
COMMENT ON COLUMN oauth_sessions.session_id IS 'Links to the gorilla session cookie';
|
||||
COMMENT ON COLUMN oauth_sessions.user_agent IS 'Used to detect session hijacking';
|
||||
COMMENT ON COLUMN oauth_sessions.ip_address IS 'Used to detect session hijacking';
|
||||
936
backend/openapi.yaml
Normal file
936
backend/openapi.yaml
Normal file
@@ -0,0 +1,936 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Ackify API
|
||||
description: |
|
||||
REST API for Ackify - Document signature tracking with cryptographic Ed25519 signatures.
|
||||
|
||||
## Authentication
|
||||
Most endpoints require OAuth2 authentication via session cookies.
|
||||
Admin endpoints additionally require the user's email to be in the ACKIFY_ADMIN_EMAILS list.
|
||||
|
||||
## CSRF Protection
|
||||
Write operations (POST, PUT, DELETE) require a CSRF token obtained from `GET /api/v1/csrf`.
|
||||
Include the token in the `X-CSRF-Token` header.
|
||||
version: 1.0.0
|
||||
contact:
|
||||
name: Ackify Support
|
||||
url: https://github.com/btouchard/ackify-ce
|
||||
license:
|
||||
name: AGPL-3.0-or-later
|
||||
url: https://www.gnu.org/licenses/agpl-3.0.html
|
||||
|
||||
servers:
|
||||
- url: /api/v1
|
||||
description: API v1
|
||||
|
||||
tags:
|
||||
- name: Health
|
||||
description: System health checks
|
||||
- name: Auth
|
||||
description: OAuth2 authentication endpoints
|
||||
- name: Users
|
||||
description: User information
|
||||
- name: Documents
|
||||
description: Document management (public)
|
||||
- name: Signatures
|
||||
description: Signature creation and retrieval
|
||||
- name: Admin - Documents
|
||||
description: Admin document management
|
||||
- name: Admin - Signers
|
||||
description: Admin expected signers management
|
||||
- name: Admin - Reminders
|
||||
description: Admin email reminder management
|
||||
|
||||
paths:
|
||||
/health:
|
||||
get:
|
||||
summary: Health check
|
||||
description: Returns the health status of the API
|
||||
tags:
|
||||
- Health
|
||||
responses:
|
||||
'200':
|
||||
description: Service is healthy
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
example: ok
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
/csrf:
|
||||
get:
|
||||
summary: Get CSRF token
|
||||
description: Returns a CSRF token required for write operations
|
||||
tags:
|
||||
- Auth
|
||||
responses:
|
||||
'200':
|
||||
description: CSRF token generated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
csrfToken:
|
||||
type: string
|
||||
example: abc123def456
|
||||
|
||||
/auth/start:
|
||||
post:
|
||||
summary: Start OAuth2 flow
|
||||
description: Initiates OAuth2 authentication with the configured provider
|
||||
tags:
|
||||
- Auth
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
redirectTo:
|
||||
type: string
|
||||
description: URL to redirect to after successful authentication
|
||||
example: /signatures
|
||||
responses:
|
||||
'302':
|
||||
description: Redirect to OAuth provider
|
||||
'400':
|
||||
description: Invalid request
|
||||
|
||||
/auth/callback:
|
||||
get:
|
||||
summary: OAuth2 callback
|
||||
description: Handles OAuth2 provider callback after user authentication
|
||||
tags:
|
||||
- Auth
|
||||
parameters:
|
||||
- name: code
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: state
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'302':
|
||||
description: Redirect to application
|
||||
'400':
|
||||
description: Invalid callback parameters
|
||||
'401':
|
||||
description: Authentication failed
|
||||
|
||||
/auth/logout:
|
||||
get:
|
||||
summary: Logout
|
||||
description: Logs out the current user and clears the session
|
||||
tags:
|
||||
- Auth
|
||||
responses:
|
||||
'302':
|
||||
description: Redirect to home page
|
||||
|
||||
/auth/check:
|
||||
get:
|
||||
summary: Check authentication status
|
||||
description: Checks if user has an active OAuth session (only available if ACKIFY_OAUTH_AUTO_LOGIN=true)
|
||||
tags:
|
||||
- Auth
|
||||
responses:
|
||||
'200':
|
||||
description: Authentication status
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
authenticated:
|
||||
type: boolean
|
||||
|
||||
/users/me:
|
||||
get:
|
||||
summary: Get current user
|
||||
description: Returns information about the currently authenticated user
|
||||
tags:
|
||||
- Users
|
||||
security:
|
||||
- sessionAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Current user information
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/User'
|
||||
'401':
|
||||
description: Not authenticated
|
||||
|
||||
/documents:
|
||||
get:
|
||||
summary: List documents
|
||||
description: Returns a paginated list of all documents
|
||||
tags:
|
||||
- Documents
|
||||
parameters:
|
||||
- name: page
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 1
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 20
|
||||
maximum: 100
|
||||
responses:
|
||||
'200':
|
||||
description: List of documents
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
documents:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Document'
|
||||
total:
|
||||
type: integer
|
||||
page:
|
||||
type: integer
|
||||
limit:
|
||||
type: integer
|
||||
|
||||
post:
|
||||
summary: Create document
|
||||
description: Creates a new document with metadata
|
||||
tags:
|
||||
- Documents
|
||||
security:
|
||||
- csrfToken: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CreateDocumentRequest'
|
||||
responses:
|
||||
'201':
|
||||
description: Document created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Document'
|
||||
'400':
|
||||
description: Invalid request
|
||||
'403':
|
||||
description: CSRF token missing or invalid
|
||||
|
||||
/documents/{docId}:
|
||||
get:
|
||||
summary: Get document
|
||||
description: Returns document metadata and signature count
|
||||
tags:
|
||||
- Documents
|
||||
parameters:
|
||||
- name: docId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Document details
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DocumentWithCount'
|
||||
'404':
|
||||
description: Document not found
|
||||
|
||||
/documents/{docId}/signatures:
|
||||
get:
|
||||
summary: Get document signatures
|
||||
description: Returns all signatures for a document
|
||||
tags:
|
||||
- Documents
|
||||
parameters:
|
||||
- name: docId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: List of signatures
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
signatures:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Signature'
|
||||
|
||||
/documents/{docId}/signatures/status:
|
||||
get:
|
||||
summary: Get user signature status
|
||||
description: Checks if the current user has signed this document
|
||||
tags:
|
||||
- Signatures
|
||||
security:
|
||||
- sessionAuth: []
|
||||
parameters:
|
||||
- name: docId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Signature status
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
hasSigned:
|
||||
type: boolean
|
||||
signature:
|
||||
$ref: '#/components/schemas/Signature'
|
||||
|
||||
/documents/{docId}/expected-signers:
|
||||
get:
|
||||
summary: Get expected signers
|
||||
description: Returns the list of expected signers for a document
|
||||
tags:
|
||||
- Documents
|
||||
parameters:
|
||||
- name: docId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: List of expected signers
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
expectedSigners:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ExpectedSigner'
|
||||
|
||||
/documents/find-or-create:
|
||||
get:
|
||||
summary: Find or create document
|
||||
description: Finds a document by reference, or creates it if it doesn't exist
|
||||
tags:
|
||||
- Documents
|
||||
parameters:
|
||||
- name: ref
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Document reference (URL, path, or custom ID)
|
||||
responses:
|
||||
'200':
|
||||
description: Document found or created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Document'
|
||||
|
||||
/signatures:
|
||||
get:
|
||||
summary: Get user signatures
|
||||
description: Returns all signatures created by the current user
|
||||
tags:
|
||||
- Signatures
|
||||
security:
|
||||
- sessionAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: List of user signatures
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
signatures:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Signature'
|
||||
|
||||
post:
|
||||
summary: Create signature
|
||||
description: Creates a cryptographic Ed25519 signature for a document
|
||||
tags:
|
||||
- Signatures
|
||||
security:
|
||||
- sessionAuth: []
|
||||
- csrfToken: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CreateSignatureRequest'
|
||||
responses:
|
||||
'201':
|
||||
description: Signature created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Signature'
|
||||
'400':
|
||||
description: Invalid request
|
||||
'409':
|
||||
description: User has already signed this document
|
||||
|
||||
/admin/documents:
|
||||
get:
|
||||
summary: List all documents (admin)
|
||||
description: Returns paginated list of all documents with admin metadata
|
||||
tags:
|
||||
- Admin - Documents
|
||||
security:
|
||||
- sessionAuth: []
|
||||
- adminRole: []
|
||||
parameters:
|
||||
- name: page
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 1
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 20
|
||||
responses:
|
||||
'200':
|
||||
description: List of documents
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
documents:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Document'
|
||||
total:
|
||||
type: integer
|
||||
|
||||
/admin/documents/{docId}:
|
||||
get:
|
||||
summary: Get document details (admin)
|
||||
description: Returns detailed document information
|
||||
tags:
|
||||
- Admin - Documents
|
||||
security:
|
||||
- sessionAuth: []
|
||||
- adminRole: []
|
||||
parameters:
|
||||
- name: docId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Document details
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Document'
|
||||
|
||||
delete:
|
||||
summary: Delete document (admin)
|
||||
description: Soft deletes a document
|
||||
tags:
|
||||
- Admin - Documents
|
||||
security:
|
||||
- sessionAuth: []
|
||||
- adminRole: []
|
||||
- csrfToken: []
|
||||
parameters:
|
||||
- name: docId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'204':
|
||||
description: Document deleted
|
||||
'404':
|
||||
description: Document not found
|
||||
|
||||
/admin/documents/{docId}/metadata:
|
||||
put:
|
||||
summary: Update document metadata (admin)
|
||||
description: Updates document title, URL, checksum, and description
|
||||
tags:
|
||||
- Admin - Documents
|
||||
security:
|
||||
- sessionAuth: []
|
||||
- adminRole: []
|
||||
- csrfToken: []
|
||||
parameters:
|
||||
- name: docId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UpdateDocumentMetadataRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Metadata updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Document'
|
||||
|
||||
/admin/documents/{docId}/signers:
|
||||
get:
|
||||
summary: Get document with signers (admin)
|
||||
description: Returns document with both expected and actual signers
|
||||
tags:
|
||||
- Admin - Signers
|
||||
security:
|
||||
- sessionAuth: []
|
||||
- adminRole: []
|
||||
parameters:
|
||||
- name: docId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Document with signers
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DocumentWithSigners'
|
||||
|
||||
post:
|
||||
summary: Add expected signer (admin)
|
||||
description: Adds one or more expected signers to a document
|
||||
tags:
|
||||
- Admin - Signers
|
||||
security:
|
||||
- sessionAuth: []
|
||||
- adminRole: []
|
||||
- csrfToken: []
|
||||
parameters:
|
||||
- name: docId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AddExpectedSignerRequest'
|
||||
responses:
|
||||
'201':
|
||||
description: Signer(s) added
|
||||
'400':
|
||||
description: Invalid request
|
||||
|
||||
/admin/documents/{docId}/signers/{email}:
|
||||
delete:
|
||||
summary: Remove expected signer (admin)
|
||||
description: Removes an expected signer from a document
|
||||
tags:
|
||||
- Admin - Signers
|
||||
security:
|
||||
- sessionAuth: []
|
||||
- adminRole: []
|
||||
- csrfToken: []
|
||||
parameters:
|
||||
- name: docId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: email
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'204':
|
||||
description: Signer removed
|
||||
|
||||
/admin/documents/{docId}/status:
|
||||
get:
|
||||
summary: Get document status (admin)
|
||||
description: Returns completion statistics for a document
|
||||
tags:
|
||||
- Admin - Documents
|
||||
security:
|
||||
- sessionAuth: []
|
||||
- adminRole: []
|
||||
parameters:
|
||||
- name: docId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Document status
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DocumentStatus'
|
||||
|
||||
/admin/documents/{docId}/reminders:
|
||||
get:
|
||||
summary: Get reminder history (admin)
|
||||
description: Returns email reminder send history for a document
|
||||
tags:
|
||||
- Admin - Reminders
|
||||
security:
|
||||
- sessionAuth: []
|
||||
- adminRole: []
|
||||
parameters:
|
||||
- name: docId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Reminder history
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
reminders:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ReminderLog'
|
||||
|
||||
post:
|
||||
summary: Send reminders (admin)
|
||||
description: Sends email reminders to pending signers
|
||||
tags:
|
||||
- Admin - Reminders
|
||||
security:
|
||||
- sessionAuth: []
|
||||
- adminRole: []
|
||||
- csrfToken: []
|
||||
parameters:
|
||||
- name: docId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SendRemindersRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Reminders queued
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
queued:
|
||||
type: integer
|
||||
description: Number of emails queued for sending
|
||||
|
||||
/openapi.json:
|
||||
get:
|
||||
summary: Get OpenAPI specification
|
||||
description: Returns this OpenAPI specification in JSON format
|
||||
tags:
|
||||
- Health
|
||||
responses:
|
||||
'200':
|
||||
description: OpenAPI spec
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
sessionAuth:
|
||||
type: apiKey
|
||||
in: cookie
|
||||
name: session
|
||||
description: OAuth2 session cookie
|
||||
|
||||
csrfToken:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: X-CSRF-Token
|
||||
description: CSRF protection token
|
||||
|
||||
adminRole:
|
||||
type: http
|
||||
scheme: bearer
|
||||
description: Admin email must be in ACKIFY_ADMIN_EMAILS
|
||||
|
||||
schemas:
|
||||
User:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: User unique identifier (OAuth sub claim)
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
name:
|
||||
type: string
|
||||
isAdmin:
|
||||
type: boolean
|
||||
|
||||
Document:
|
||||
type: object
|
||||
properties:
|
||||
docId:
|
||||
type: string
|
||||
example: abc123
|
||||
title:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
checksum:
|
||||
type: string
|
||||
checksumAlgorithm:
|
||||
type: string
|
||||
enum: [SHA-256, SHA-512, MD5]
|
||||
description:
|
||||
type: string
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
createdBy:
|
||||
type: string
|
||||
|
||||
DocumentWithCount:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Document'
|
||||
- type: object
|
||||
properties:
|
||||
signatureCount:
|
||||
type: integer
|
||||
|
||||
Signature:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
format: int64
|
||||
docId:
|
||||
type: string
|
||||
userSub:
|
||||
type: string
|
||||
userEmail:
|
||||
type: string
|
||||
format: email
|
||||
userName:
|
||||
type: string
|
||||
signedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
payloadHash:
|
||||
type: string
|
||||
signature:
|
||||
type: string
|
||||
description: Ed25519 signature (hex-encoded)
|
||||
nonce:
|
||||
type: string
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
referer:
|
||||
type: string
|
||||
prevHash:
|
||||
type: string
|
||||
docChecksum:
|
||||
type: string
|
||||
|
||||
ExpectedSigner:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
format: int64
|
||||
docId:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
name:
|
||||
type: string
|
||||
addedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
addedBy:
|
||||
type: string
|
||||
notes:
|
||||
type: string
|
||||
|
||||
ReminderLog:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
format: int64
|
||||
docId:
|
||||
type: string
|
||||
recipientEmail:
|
||||
type: string
|
||||
format: email
|
||||
sentAt:
|
||||
type: string
|
||||
format: date-time
|
||||
sentBy:
|
||||
type: string
|
||||
templateUsed:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
enum: [sent, failed, bounced, queued]
|
||||
errorMessage:
|
||||
type: string
|
||||
|
||||
DocumentStatus:
|
||||
type: object
|
||||
properties:
|
||||
docId:
|
||||
type: string
|
||||
expectedCount:
|
||||
type: integer
|
||||
signedCount:
|
||||
type: integer
|
||||
pendingCount:
|
||||
type: integer
|
||||
completionPercentage:
|
||||
type: number
|
||||
format: float
|
||||
|
||||
DocumentWithSigners:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Document'
|
||||
- type: object
|
||||
properties:
|
||||
expectedSigners:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ExpectedSigner'
|
||||
signatures:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Signature'
|
||||
|
||||
CreateDocumentRequest:
|
||||
type: object
|
||||
required:
|
||||
- reference
|
||||
properties:
|
||||
reference:
|
||||
type: string
|
||||
description: Document URL, path, or custom ID
|
||||
title:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
|
||||
UpdateDocumentMetadataRequest:
|
||||
type: object
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
checksum:
|
||||
type: string
|
||||
checksumAlgorithm:
|
||||
type: string
|
||||
enum: [SHA-256, SHA-512, MD5]
|
||||
description:
|
||||
type: string
|
||||
|
||||
CreateSignatureRequest:
|
||||
type: object
|
||||
required:
|
||||
- docId
|
||||
properties:
|
||||
docId:
|
||||
type: string
|
||||
referer:
|
||||
type: string
|
||||
description: Source service (Google Docs, GitHub, etc.)
|
||||
docChecksum:
|
||||
type: string
|
||||
description: Document checksum at signing time
|
||||
|
||||
AddExpectedSignerRequest:
|
||||
type: object
|
||||
required:
|
||||
- emails
|
||||
properties:
|
||||
emails:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: email
|
||||
notes:
|
||||
type: string
|
||||
|
||||
SendRemindersRequest:
|
||||
type: object
|
||||
properties:
|
||||
emails:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: email
|
||||
description: Specific emails to send to (omit to send to all pending)
|
||||
docURL:
|
||||
type: string
|
||||
description: Custom document URL for email
|
||||
locale:
|
||||
type: string
|
||||
enum: [en, fr, es, de, it]
|
||||
default: en
|
||||
description: Email language
|
||||
90
backend/pkg/crypto/encryption.go
Normal file
90
backend/pkg/crypto/encryption.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// EncryptToken encrypts a plaintext token using AES-256-GCM
|
||||
// The key must be 32 bytes for AES-256
|
||||
// Returns: nonce + ciphertext + auth tag (combined)
|
||||
func EncryptToken(plaintext string, key []byte) ([]byte, error) {
|
||||
if len(key) != 32 {
|
||||
return nil, fmt.Errorf("encryption key must be 32 bytes for AES-256, got %d bytes", len(key))
|
||||
}
|
||||
|
||||
if plaintext == "" {
|
||||
return nil, fmt.Errorf("cannot encrypt empty plaintext")
|
||||
}
|
||||
|
||||
// Create AES cipher block
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create cipher: %w", err)
|
||||
}
|
||||
|
||||
// Create GCM mode (provides both confidentiality and authenticity)
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create GCM: %w", err)
|
||||
}
|
||||
|
||||
// Generate a random nonce (must be unique for each encryption with the same key)
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate nonce: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt and authenticate the plaintext
|
||||
// Seal appends the ciphertext and tag to the nonce
|
||||
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
|
||||
return ciphertext, nil
|
||||
}
|
||||
|
||||
// DecryptToken decrypts a ciphertext using AES-256-GCM
|
||||
// The key must be 32 bytes for AES-256
|
||||
// Expects input format: nonce + ciphertext + auth tag (as created by EncryptToken)
|
||||
func DecryptToken(ciphertext []byte, key []byte) (string, error) {
|
||||
if len(key) != 32 {
|
||||
return "", fmt.Errorf("decryption key must be 32 bytes for AES-256, got %d bytes", len(key))
|
||||
}
|
||||
|
||||
if len(ciphertext) == 0 {
|
||||
return "", fmt.Errorf("cannot decrypt empty ciphertext")
|
||||
}
|
||||
|
||||
// Create AES cipher block
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create cipher: %w", err)
|
||||
}
|
||||
|
||||
// Create GCM mode
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create GCM: %w", err)
|
||||
}
|
||||
|
||||
// Check minimum length (nonce + at least 1 byte of data + tag)
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(ciphertext) < nonceSize {
|
||||
return "", fmt.Errorf("ciphertext too short: expected at least %d bytes, got %d", nonceSize, len(ciphertext))
|
||||
}
|
||||
|
||||
// Extract nonce and ciphertext+tag
|
||||
nonce := ciphertext[:nonceSize]
|
||||
encryptedData := ciphertext[nonceSize:]
|
||||
|
||||
// Decrypt and verify authenticity
|
||||
plaintext, err := gcm.Open(nil, nonce, encryptedData, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decrypt: %w", err)
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
}
|
||||
257
backend/pkg/crypto/encryption_test.go
Normal file
257
backend/pkg/crypto/encryption_test.go
Normal file
@@ -0,0 +1,257 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEncryptDecrypt(t *testing.T) {
|
||||
key := make([]byte, 32) // AES-256 requires 32 bytes
|
||||
_, err := rand.Read(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("encrypt and decrypt successfully", func(t *testing.T) {
|
||||
plaintext := "my-secret-refresh-token-12345"
|
||||
|
||||
ciphertext, err := EncryptToken(plaintext, key)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, ciphertext)
|
||||
|
||||
// Ciphertext should be different from plaintext
|
||||
assert.NotEqual(t, plaintext, string(ciphertext))
|
||||
|
||||
// Decrypt
|
||||
decrypted, err := DecryptToken(ciphertext, key)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, plaintext, decrypted)
|
||||
})
|
||||
|
||||
t.Run("encrypt produces different ciphertext each time", func(t *testing.T) {
|
||||
plaintext := "same-plaintext"
|
||||
|
||||
ciphertext1, err := EncryptToken(plaintext, key)
|
||||
require.NoError(t, err)
|
||||
|
||||
ciphertext2, err := EncryptToken(plaintext, key)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Different nonces should produce different ciphertexts
|
||||
assert.NotEqual(t, ciphertext1, ciphertext2)
|
||||
|
||||
// Both should decrypt to the same plaintext
|
||||
decrypted1, err := DecryptToken(ciphertext1, key)
|
||||
require.NoError(t, err)
|
||||
decrypted2, err := DecryptToken(ciphertext2, key)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, plaintext, decrypted1)
|
||||
assert.Equal(t, plaintext, decrypted2)
|
||||
})
|
||||
|
||||
t.Run("decrypt with wrong key fails", func(t *testing.T) {
|
||||
plaintext := "secret-token"
|
||||
wrongKey := make([]byte, 32)
|
||||
_, err := rand.Read(wrongKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
ciphertext, err := EncryptToken(plaintext, key)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to decrypt with wrong key
|
||||
_, err = DecryptToken(ciphertext, wrongKey)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to decrypt")
|
||||
})
|
||||
|
||||
t.Run("tampered ciphertext fails authentication", func(t *testing.T) {
|
||||
plaintext := "secret-token"
|
||||
|
||||
ciphertext, err := EncryptToken(plaintext, key)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Tamper with the ciphertext
|
||||
tampered := make([]byte, len(ciphertext))
|
||||
copy(tampered, ciphertext)
|
||||
tampered[len(tampered)-1] ^= 0xFF // Flip bits in the last byte
|
||||
|
||||
// Decryption should fail due to authentication tag mismatch
|
||||
_, err = DecryptToken(tampered, key)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to decrypt")
|
||||
})
|
||||
|
||||
t.Run("handles special characters", func(t *testing.T) {
|
||||
plaintext := "token-with-special-chars: !@#$%^&*()_+-={}[]|\\:\";<>?,./~`"
|
||||
|
||||
ciphertext, err := EncryptToken(plaintext, key)
|
||||
require.NoError(t, err)
|
||||
|
||||
decrypted, err := DecryptToken(ciphertext, key)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, plaintext, decrypted)
|
||||
})
|
||||
|
||||
t.Run("handles unicode characters", func(t *testing.T) {
|
||||
plaintext := "token-with-unicode: 你好世界 🔐🔑"
|
||||
|
||||
ciphertext, err := EncryptToken(plaintext, key)
|
||||
require.NoError(t, err)
|
||||
|
||||
decrypted, err := DecryptToken(ciphertext, key)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, plaintext, decrypted)
|
||||
})
|
||||
|
||||
t.Run("handles long tokens", func(t *testing.T) {
|
||||
plaintext := strings.Repeat("a", 10000) // 10KB token
|
||||
|
||||
ciphertext, err := EncryptToken(plaintext, key)
|
||||
require.NoError(t, err)
|
||||
|
||||
decrypted, err := DecryptToken(ciphertext, key)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, plaintext, decrypted)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEncryptToken_InvalidInputs(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
_, err := rand.Read(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("empty plaintext", func(t *testing.T) {
|
||||
_, err := EncryptToken("", key)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "cannot encrypt empty plaintext")
|
||||
})
|
||||
|
||||
t.Run("invalid key length - too short", func(t *testing.T) {
|
||||
shortKey := make([]byte, 16) // Only 16 bytes (AES-128)
|
||||
_, err := EncryptToken("plaintext", shortKey)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "encryption key must be 32 bytes")
|
||||
})
|
||||
|
||||
t.Run("invalid key length - too long", func(t *testing.T) {
|
||||
longKey := make([]byte, 64) // 64 bytes (too long)
|
||||
_, err := EncryptToken("plaintext", longKey)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "encryption key must be 32 bytes")
|
||||
})
|
||||
|
||||
t.Run("nil key", func(t *testing.T) {
|
||||
_, err := EncryptToken("plaintext", nil)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDecryptToken_InvalidInputs(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
_, err := rand.Read(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("empty ciphertext", func(t *testing.T) {
|
||||
_, err := DecryptToken([]byte{}, key)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "cannot decrypt empty ciphertext")
|
||||
})
|
||||
|
||||
t.Run("ciphertext too short", func(t *testing.T) {
|
||||
shortCiphertext := []byte{0x01, 0x02, 0x03} // Only 3 bytes
|
||||
_, err := DecryptToken(shortCiphertext, key)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "ciphertext too short")
|
||||
})
|
||||
|
||||
t.Run("invalid key length", func(t *testing.T) {
|
||||
ciphertext, err := EncryptToken("test", key)
|
||||
require.NoError(t, err)
|
||||
|
||||
shortKey := make([]byte, 16)
|
||||
_, err = DecryptToken(ciphertext, shortKey)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "decryption key must be 32 bytes")
|
||||
})
|
||||
|
||||
t.Run("nil key", func(t *testing.T) {
|
||||
ciphertext, err := EncryptToken("test", key)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = DecryptToken(ciphertext, nil)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("corrupted data", func(t *testing.T) {
|
||||
corruptedData := make([]byte, 50)
|
||||
_, err := rand.Read(corruptedData)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = DecryptToken(corruptedData, key)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to decrypt")
|
||||
})
|
||||
}
|
||||
|
||||
func TestEncryption_SecurityProperties(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
_, err := rand.Read(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("nonce uniqueness", func(t *testing.T) {
|
||||
plaintext := "test-token"
|
||||
nonces := make(map[string]bool)
|
||||
|
||||
// Generate 1000 encryptions and check nonce uniqueness
|
||||
for i := 0; i < 1000; i++ {
|
||||
ciphertext, err := EncryptToken(plaintext, key)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Extract nonce (first 12 bytes for GCM)
|
||||
nonce := string(ciphertext[:12])
|
||||
assert.False(t, nonces[nonce], "duplicate nonce detected")
|
||||
nonces[nonce] = true
|
||||
}
|
||||
|
||||
assert.Len(t, nonces, 1000)
|
||||
})
|
||||
|
||||
t.Run("ciphertext length", func(t *testing.T) {
|
||||
plaintext := "test-token-123"
|
||||
|
||||
ciphertext, err := EncryptToken(plaintext, key)
|
||||
require.NoError(t, err)
|
||||
|
||||
// GCM: nonce (12) + ciphertext (len(plaintext)) + tag (16)
|
||||
expectedLength := 12 + len(plaintext) + 16
|
||||
assert.Equal(t, expectedLength, len(ciphertext))
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkEncryptToken(b *testing.B) {
|
||||
key := make([]byte, 32)
|
||||
rand.Read(key)
|
||||
plaintext := "my-refresh-token-1234567890"
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = EncryptToken(plaintext, key)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkDecryptToken(b *testing.B) {
|
||||
key := make([]byte, 32)
|
||||
rand.Read(key)
|
||||
plaintext := "my-refresh-token-1234567890"
|
||||
|
||||
ciphertext, _ := EncryptToken(plaintext, key)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = DecryptToken(ciphertext, key)
|
||||
}
|
||||
}
|
||||
58
backend/pkg/crypto/pkce.go
Normal file
58
backend/pkg/crypto/pkce.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
const (
|
||||
// PKCE code verifier length (RFC 7636 recommends 43-128 characters)
|
||||
codeVerifierLength = 43
|
||||
|
||||
// Valid characters for code verifier: [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~"
|
||||
codeVerifierCharset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
)
|
||||
|
||||
var (
|
||||
// Regex to validate code verifier format (RFC 7636)
|
||||
codeVerifierRegex = regexp.MustCompile(`^[A-Za-z0-9\-\._~]{43,128}$`)
|
||||
)
|
||||
|
||||
// GenerateCodeVerifier generates a cryptographically secure PKCE code verifier
|
||||
// The verifier is a random string of 43-128 characters using the unreserved character set.
|
||||
// Returns a base64 URL-safe encoded string suitable for OAuth2 PKCE flow.
|
||||
func GenerateCodeVerifier() (string, error) {
|
||||
// Generate random bytes (32 bytes = 43 characters in base64)
|
||||
randomBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(randomBytes); err != nil {
|
||||
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
||||
}
|
||||
|
||||
// Encode to base64 URL-safe (no padding)
|
||||
verifier := base64.RawURLEncoding.EncodeToString(randomBytes)
|
||||
|
||||
// Validate the generated verifier
|
||||
if !ValidateCodeVerifier(verifier) {
|
||||
return "", fmt.Errorf("generated verifier failed validation")
|
||||
}
|
||||
|
||||
return verifier, nil
|
||||
}
|
||||
|
||||
// GenerateCodeChallenge generates a PKCE code challenge from a code verifier
|
||||
// Uses the S256 method: BASE64URL(SHA256(ASCII(code_verifier)))
|
||||
func GenerateCodeChallenge(verifier string) string {
|
||||
hash := sha256.Sum256([]byte(verifier))
|
||||
return base64.RawURLEncoding.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// ValidateCodeVerifier validates that a code verifier meets RFC 7636 requirements
|
||||
// - Length: 43-128 characters
|
||||
// - Characters: [A-Za-z0-9-._~]
|
||||
func ValidateCodeVerifier(verifier string) bool {
|
||||
return codeVerifierRegex.MatchString(verifier)
|
||||
}
|
||||
187
backend/pkg/crypto/pkce_test.go
Normal file
187
backend/pkg/crypto/pkce_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGenerateCodeVerifier(t *testing.T) {
|
||||
t.Run("generates valid verifier", func(t *testing.T) {
|
||||
verifier, err := GenerateCodeVerifier()
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, verifier)
|
||||
|
||||
// Check length (should be 43 characters for 32 bytes)
|
||||
assert.Len(t, verifier, 43)
|
||||
|
||||
// Validate format
|
||||
assert.True(t, ValidateCodeVerifier(verifier))
|
||||
})
|
||||
|
||||
t.Run("generates unique verifiers", func(t *testing.T) {
|
||||
verifiers := make(map[string]bool)
|
||||
iterations := 1000
|
||||
|
||||
for i := 0; i < iterations; i++ {
|
||||
verifier, err := GenerateCodeVerifier()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check no collision
|
||||
assert.False(t, verifiers[verifier], "duplicate verifier generated")
|
||||
verifiers[verifier] = true
|
||||
}
|
||||
|
||||
assert.Len(t, verifiers, iterations)
|
||||
})
|
||||
|
||||
t.Run("contains only URL-safe characters", func(t *testing.T) {
|
||||
verifier, err := GenerateCodeVerifier()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check it's valid base64 URL encoding
|
||||
_, err = base64.RawURLEncoding.DecodeString(verifier)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Should not contain padding
|
||||
assert.False(t, strings.Contains(verifier, "="))
|
||||
})
|
||||
}
|
||||
|
||||
func TestGenerateCodeChallenge(t *testing.T) {
|
||||
t.Run("generates correct SHA256 challenge", func(t *testing.T) {
|
||||
// RFC 7636 test vector
|
||||
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
expectedChallenge := "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||
|
||||
challenge := GenerateCodeChallenge(verifier)
|
||||
assert.Equal(t, expectedChallenge, challenge)
|
||||
})
|
||||
|
||||
t.Run("generates base64 URL-safe challenge", func(t *testing.T) {
|
||||
verifier, err := GenerateCodeVerifier()
|
||||
require.NoError(t, err)
|
||||
|
||||
challenge := GenerateCodeChallenge(verifier)
|
||||
assert.NotEmpty(t, challenge)
|
||||
|
||||
// Should be valid base64 URL encoding
|
||||
_, err = base64.RawURLEncoding.DecodeString(challenge)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Should not contain padding
|
||||
assert.False(t, strings.Contains(challenge, "="))
|
||||
|
||||
// Should be 43 characters (32 bytes SHA256 in base64)
|
||||
assert.Len(t, challenge, 43)
|
||||
})
|
||||
|
||||
t.Run("different verifiers produce different challenges", func(t *testing.T) {
|
||||
verifier1, _ := GenerateCodeVerifier()
|
||||
verifier2, _ := GenerateCodeVerifier()
|
||||
|
||||
challenge1 := GenerateCodeChallenge(verifier1)
|
||||
challenge2 := GenerateCodeChallenge(verifier2)
|
||||
|
||||
assert.NotEqual(t, challenge1, challenge2)
|
||||
})
|
||||
|
||||
t.Run("same verifier produces same challenge", func(t *testing.T) {
|
||||
verifier := "test_verifier_123456789012345678901234567"
|
||||
|
||||
challenge1 := GenerateCodeChallenge(verifier)
|
||||
challenge2 := GenerateCodeChallenge(verifier)
|
||||
|
||||
assert.Equal(t, challenge1, challenge2)
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateCodeVerifier(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
verifier string
|
||||
valid bool
|
||||
}{
|
||||
{
|
||||
name: "valid 43 character verifier",
|
||||
verifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "valid 128 character verifier",
|
||||
verifier: strings.Repeat("a", 128),
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "valid with numeric and alphanumeric (50 chars)",
|
||||
verifier: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWX",
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "valid with special chars (44 chars)",
|
||||
verifier: "abc123-._~ABC123-._~abc123-._~abc123-._~abc",
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "too short (42 chars)",
|
||||
verifier: strings.Repeat("a", 42),
|
||||
valid: false,
|
||||
},
|
||||
{
|
||||
name: "too long (129 chars)",
|
||||
verifier: strings.Repeat("a", 129),
|
||||
valid: false,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
verifier: "",
|
||||
valid: false,
|
||||
},
|
||||
{
|
||||
name: "contains invalid character (space)",
|
||||
verifier: "dBjftJeZ4CVP mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
|
||||
valid: false,
|
||||
},
|
||||
{
|
||||
name: "contains invalid character (+)",
|
||||
verifier: "dBjftJeZ4CVP+mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
|
||||
valid: false,
|
||||
},
|
||||
{
|
||||
name: "contains invalid character (/)",
|
||||
verifier: "dBjftJeZ4CVP/mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
|
||||
valid: false,
|
||||
},
|
||||
{
|
||||
name: "contains padding (=)",
|
||||
verifier: "dBjftJeZ4CVP=mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
|
||||
valid: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ValidateCodeVerifier(tt.verifier)
|
||||
assert.Equal(t, tt.valid, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGenerateCodeVerifier(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = GenerateCodeVerifier()
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGenerateCodeChallenge(b *testing.B) {
|
||||
verifier, _ := GenerateCodeVerifier()
|
||||
b.ResetTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = GenerateCodeChallenge(verifier)
|
||||
}
|
||||
}
|
||||
@@ -24,15 +24,16 @@ import (
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
httpServer *http.Server
|
||||
db *sql.DB
|
||||
router *chi.Mux
|
||||
emailSender email.Sender
|
||||
emailWorker *email.Worker
|
||||
baseURL string
|
||||
adminEmails []string
|
||||
authService *auth.OauthService
|
||||
autoLogin bool
|
||||
httpServer *http.Server
|
||||
db *sql.DB
|
||||
router *chi.Mux
|
||||
emailSender email.Sender
|
||||
emailWorker *email.Worker
|
||||
sessionWorker *auth.SessionWorker
|
||||
baseURL string
|
||||
adminEmails []string
|
||||
authService *auth.OauthService
|
||||
autoLogin bool
|
||||
}
|
||||
|
||||
func NewServer(ctx context.Context, cfg *config.Config, frontend embed.FS) (*Server, error) {
|
||||
@@ -41,6 +42,15 @@ func NewServer(ctx context.Context, cfg *config.Config, frontend embed.FS) (*Ser
|
||||
return nil, fmt.Errorf("failed to initialize infrastructure: %w", err)
|
||||
}
|
||||
|
||||
// Initialize repositories
|
||||
signatureRepo := database.NewSignatureRepository(db)
|
||||
documentRepo := database.NewDocumentRepository(db)
|
||||
expectedSignerRepo := database.NewExpectedSignerRepository(db)
|
||||
reminderRepo := database.NewReminderRepository(db)
|
||||
emailQueueRepo := database.NewEmailQueueRepository(db)
|
||||
oauthSessionRepo := database.NewOAuthSessionRepository(db)
|
||||
|
||||
// Initialize OAuth auth service with session repository
|
||||
authService := auth.NewOAuthService(auth.Config{
|
||||
BaseURL: cfg.App.BaseURL,
|
||||
ClientID: cfg.OAuth.ClientID,
|
||||
@@ -53,15 +63,9 @@ func NewServer(ctx context.Context, cfg *config.Config, frontend embed.FS) (*Ser
|
||||
AllowedDomain: cfg.OAuth.AllowedDomain,
|
||||
CookieSecret: cfg.OAuth.CookieSecret,
|
||||
SecureCookies: cfg.App.SecureCookies,
|
||||
SessionRepo: oauthSessionRepo,
|
||||
})
|
||||
|
||||
// Initialize repositories
|
||||
signatureRepo := database.NewSignatureRepository(db)
|
||||
documentRepo := database.NewDocumentRepository(db)
|
||||
expectedSignerRepo := database.NewExpectedSignerRepository(db)
|
||||
reminderRepo := database.NewReminderRepository(db)
|
||||
emailQueueRepo := database.NewEmailQueueRepository(db)
|
||||
|
||||
// Initialize services
|
||||
signatureService := services.NewSignatureService(signatureRepo, documentRepo, signer)
|
||||
signatureService.SetChecksumConfig(&cfg.Checksum)
|
||||
@@ -90,6 +94,16 @@ func NewServer(ctx context.Context, cfg *config.Config, frontend embed.FS) (*Ser
|
||||
)
|
||||
}
|
||||
|
||||
// Initialize OAuth session cleanup worker
|
||||
var sessionWorker *auth.SessionWorker
|
||||
if oauthSessionRepo != nil {
|
||||
workerConfig := auth.DefaultSessionWorkerConfig()
|
||||
sessionWorker = auth.NewSessionWorker(oauthSessionRepo, workerConfig)
|
||||
if err := sessionWorker.Start(); err != nil {
|
||||
return nil, fmt.Errorf("failed to start OAuth session worker: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
router := chi.NewRouter()
|
||||
|
||||
router.Use(i18n.Middleware(i18nService))
|
||||
@@ -118,15 +132,16 @@ func NewServer(ctx context.Context, cfg *config.Config, frontend embed.FS) (*Ser
|
||||
}
|
||||
|
||||
return &Server{
|
||||
httpServer: httpServer,
|
||||
db: db,
|
||||
router: router,
|
||||
emailSender: emailSender,
|
||||
emailWorker: emailWorker,
|
||||
baseURL: cfg.App.BaseURL,
|
||||
adminEmails: cfg.App.AdminEmails,
|
||||
authService: authService,
|
||||
autoLogin: cfg.OAuth.AutoLogin,
|
||||
httpServer: httpServer,
|
||||
db: db,
|
||||
router: router,
|
||||
emailSender: emailSender,
|
||||
emailWorker: emailWorker,
|
||||
sessionWorker: sessionWorker,
|
||||
baseURL: cfg.App.BaseURL,
|
||||
adminEmails: cfg.App.AdminEmails,
|
||||
authService: authService,
|
||||
autoLogin: cfg.OAuth.AutoLogin,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -135,7 +150,14 @@ func (s *Server) Start() error {
|
||||
}
|
||||
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
// Stop email worker first if it exists
|
||||
// Stop OAuth session worker first if it exists
|
||||
if s.sessionWorker != nil {
|
||||
if err := s.sessionWorker.Stop(); err != nil {
|
||||
fmt.Printf("Warning: failed to stop OAuth session worker: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop email worker if it exists
|
||||
if s.emailWorker != nil {
|
||||
if err := s.emailWorker.Stop(); err != nil {
|
||||
// Log but don't fail shutdown
|
||||
|
||||
25
compose.yml
25
compose.yml
@@ -7,12 +7,7 @@ services:
|
||||
container_name: ackify-ce-migrate
|
||||
environment:
|
||||
ACKIFY_LOG_LEVEL: "${ACKIFY_LOG_LEVEL}"
|
||||
ACKIFY_BASE_URL: "${ACKIFY_BASE_URL}"
|
||||
ACKIFY_ORGANISATION: "${ACKIFY_ORGANISATION}"
|
||||
ACKIFY_DB_DSN: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@ackify-db:5432/${POSTGRES_DB}?sslmode=disable"
|
||||
ACKIFY_OAUTH_PROVIDER: "${ACKIFY_OAUTH_PROVIDER}"
|
||||
ACKIFY_OAUTH_CLIENT_ID: "${ACKIFY_OAUTH_CLIENT_ID}"
|
||||
ACKIFY_OAUTH_CLIENT_SECRET: "${ACKIFY_OAUTH_CLIENT_SECRET}"
|
||||
depends_on:
|
||||
ackify-db:
|
||||
condition: service_healthy
|
||||
@@ -28,23 +23,27 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
ACKIFY_LOG_LEVEL: "${ACKIFY_LOG_LEVEL}"
|
||||
ACKIFY_BASE_URL: "https://${APP_DNS}"
|
||||
ACKIFY_BASE_URL: "${ACKIFY_BASE_URL}"
|
||||
ACKIFY_ORGANISATION: "${ACKIFY_ORGANISATION}"
|
||||
ACKIFY_DB_DSN: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@ackify-db:5432/${POSTGRES_DB}?sslmode=disable"
|
||||
ACKIFY_OAUTH_PROVIDER: "${ACKIFY_OAUTH_PROVIDER}"
|
||||
ACKIFY_OAUTH_CLIENT_ID: "${ACKIFY_OAUTH_CLIENT_ID}"
|
||||
ACKIFY_OAUTH_CLIENT_SECRET: "${ACKIFY_OAUTH_CLIENT_SECRET}"
|
||||
# -- NEEDED FOR CUSTOM PROVIDER
|
||||
# ACKIFY_OAUTH_AUTH_URL: "${ACKIFY_OAUTH_AUTH_URL}"
|
||||
# ACKIFY_OAUTH_TOKEN_URL: "${ACKIFY_OAUTH_TOKEN_URL}"
|
||||
# ACKIFY_OAUTH_USERINFO_URL: "${ACKIFY_OAUTH_USERINFO_URL}"
|
||||
# ACKIFY_OAUTH_SCOPES: "${ACKIFY_OAUTH_SCOPES}"
|
||||
# -- END NEEDED FOR CUSTOM PROVIDER
|
||||
ACKIFY_OAUTH_ALLOWED_DOMAIN: "${ACKIFY_OAUTH_ALLOWED_DOMAIN}"
|
||||
ACKIFY_OAUTH_AUTH_URL: "${ACKIFY_OAUTH_AUTH_URL:-}"
|
||||
ACKIFY_OAUTH_TOKEN_URL: "${ACKIFY_OAUTH_TOKEN_URL:-}"
|
||||
ACKIFY_OAUTH_USERINFO_URL: "${ACKIFY_OAUTH_USERINFO_URL:-}"
|
||||
ACKIFY_OAUTH_LOGOUT_URL: "${ACKIFY_OAUTH_LOGOUT_URL:-}"
|
||||
ACKIFY_OAUTH_ALLOWED_DOMAIN: "${ACKIFY_OAUTH_ALLOWED_DOMAIN:-}"
|
||||
ACKIFY_OAUTH_COOKIE_SECRET: "${ACKIFY_OAUTH_COOKIE_SECRET}"
|
||||
ACKIFY_ED25519_PRIVATE_KEY: "${ACKIFY_ED25519_PRIVATE_KEY}"
|
||||
ACKIFY_LISTEN_ADDR: ":8080"
|
||||
ACKIFY_ADMIN_EMAILS: "${ACKIFY_ADMIN_EMAILS}"
|
||||
ACKIFY_MAIL_HOST: "${ACKIFY_MAIL_HOST:-mailhog}"
|
||||
ACKIFY_MAIL_PORT: "${ACKIFY_MAIL_PORT:-1025}"
|
||||
ACKIFY_MAIL_TLS: "false"
|
||||
ACKIFY_MAIL_STARTTLS: "false"
|
||||
ACKIFY_MAIL_FROM: "${ACKIFY_MAIL_FROM:-noreply@ackify.local}"
|
||||
ACKIFY_MAIL_FROM_NAME: "${ACKIFY_MAIL_FROM_NAME:-Ackify}"
|
||||
depends_on:
|
||||
ackify-migrate:
|
||||
condition: service_completed_successfully
|
||||
|
||||
29
docs/README.md
Normal file
29
docs/README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Ackify Documentation
|
||||
|
||||
Complete documentation for Ackify - Proof of Read with cryptographic signatures.
|
||||
|
||||
🇬🇧 **[English Documentation](en/)** | 🇫🇷 **[Documentation Française](fr/)**
|
||||
|
||||
---
|
||||
|
||||
## Quick Links
|
||||
|
||||
### 🇬🇧 English
|
||||
- [Getting Started](en/getting-started.md)
|
||||
- [Configuration](en/configuration.md)
|
||||
- [Features](en/features/)
|
||||
- [API Reference](en/api.md)
|
||||
- [Deployment](en/deployment.md)
|
||||
|
||||
### 🇫🇷 Français
|
||||
- [Démarrage Rapide](fr/getting-started.md)
|
||||
- [Configuration](fr/configuration.md)
|
||||
- [Fonctionnalités](fr/features/)
|
||||
- [Référence API](fr/api.md)
|
||||
- [Déploiement](fr/deployment.md)
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
See [Development Guide](en/development.md) (English) or [Guide de Développement](fr/development.md) (Français).
|
||||
41
docs/en/README.md
Normal file
41
docs/en/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Ackify Documentation (English)
|
||||
|
||||
Complete documentation for Ackify - Proof of Read with cryptographic signatures.
|
||||
|
||||
🇫🇷 **[Version Française](../fr/)**
|
||||
|
||||
## Quick Start
|
||||
|
||||
- **[Getting Started](getting-started.md)** - Installation and first steps with Docker Compose
|
||||
- **[Configuration](configuration.md)** - Environment variables and settings
|
||||
|
||||
## Features
|
||||
|
||||
- **[Cryptographic Signatures](features/signatures.md)** - Ed25519 signature flow
|
||||
- **[Expected Signers](features/expected-signers.md)** - Tracking and email reminders
|
||||
- **[Checksums](features/checksums.md)** - Document integrity verification
|
||||
- **[Embedding](features/embedding.md)** - oEmbed, iframes, third-party integrations
|
||||
- **[Internationalization](features/i18n.md)** - Multi-language support (fr, en, es, de, it)
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
- **[OAuth Providers](configuration/oauth-providers.md)** - Google, GitHub, GitLab, Custom
|
||||
- **[Email Setup](configuration/email-setup.md)** - SMTP configuration for reminders
|
||||
|
||||
## Architecture & Development
|
||||
|
||||
- **[Architecture](architecture.md)** - Tech stack, project structure, Clean Architecture principles
|
||||
- **[Database](database.md)** - PostgreSQL schema, migrations, constraints
|
||||
- **[API Reference](api.md)** - REST endpoints, examples, OpenAPI
|
||||
- **[Deployment](deployment.md)** - Production, security, monitoring
|
||||
- **[Development](development.md)** - Dev setup, tests, contributing
|
||||
|
||||
## Integrations
|
||||
|
||||
- **[Google Docs](../integrations/google-doc/)** - Google Workspace integration
|
||||
- More integrations coming...
|
||||
|
||||
## Support
|
||||
|
||||
- [GitHub Issues](https://github.com/btouchard/ackify-ce/issues) - Bugs and feature requests
|
||||
- [GitHub Discussions](https://github.com/btouchard/ackify-ce/discussions) - Questions and discussions
|
||||
184
docs/en/configuration.md
Normal file
184
docs/en/configuration.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# Configuration
|
||||
|
||||
Complete configuration guide for Ackify via environment variables.
|
||||
|
||||
## Required Variables
|
||||
|
||||
These variables are **required** to start Ackify:
|
||||
|
||||
```bash
|
||||
# Public URL of your instance (used for OAuth callbacks)
|
||||
APP_DNS=sign.your-domain.com
|
||||
ACKIFY_BASE_URL=https://sign.your-domain.com
|
||||
|
||||
# Your organization name (displayed in the interface)
|
||||
ACKIFY_ORGANISATION="Your Organization Name"
|
||||
|
||||
# PostgreSQL configuration
|
||||
POSTGRES_USER=ackifyr
|
||||
POSTGRES_PASSWORD=your_secure_password
|
||||
POSTGRES_DB=ackify
|
||||
|
||||
# OAuth2 Provider
|
||||
ACKIFY_OAUTH_PROVIDER=google # or github, gitlab, or empty for custom
|
||||
ACKIFY_OAUTH_CLIENT_ID=your_oauth_client_id
|
||||
ACKIFY_OAUTH_CLIENT_SECRET=your_oauth_client_secret
|
||||
|
||||
# Secret to encrypt session cookies (generate with: openssl rand -base64 32)
|
||||
ACKIFY_OAUTH_COOKIE_SECRET=your_base64_encoded_secret_key
|
||||
```
|
||||
|
||||
## Optional Variables
|
||||
|
||||
### Server
|
||||
|
||||
```bash
|
||||
# HTTP listening address (default: :8080)
|
||||
ACKIFY_LISTEN_ADDR=:8080
|
||||
|
||||
# Log level: debug, info, warn, error (default: info)
|
||||
ACKIFY_LOG_LEVEL=info
|
||||
```
|
||||
|
||||
### Security & OAuth2
|
||||
|
||||
```bash
|
||||
# Restrict access to a specific email domain
|
||||
ACKIFY_OAUTH_ALLOWED_DOMAIN=@company.com
|
||||
|
||||
# Enable silent auto-login (default: false)
|
||||
ACKIFY_OAUTH_AUTO_LOGIN=false
|
||||
|
||||
# Custom logout URL (optional)
|
||||
ACKIFY_OAUTH_LOGOUT_URL=https://your-provider.com/logout
|
||||
|
||||
# Custom OAuth2 scopes (default: openid,email,profile)
|
||||
ACKIFY_OAUTH_SCOPES=openid,email,profile
|
||||
```
|
||||
|
||||
### Administration
|
||||
|
||||
```bash
|
||||
# Admin email list (comma-separated)
|
||||
ACKIFY_ADMIN_EMAILS=admin@company.com,admin2@company.com
|
||||
```
|
||||
|
||||
Admins have access to:
|
||||
- Admin dashboard (`/admin`)
|
||||
- Document metadata management
|
||||
- Expected signers tracking
|
||||
- Email reminders sending
|
||||
- Document deletion
|
||||
|
||||
### Document Checksum (Optional)
|
||||
|
||||
Configuration for automatic checksum computation when creating documents from URLs:
|
||||
|
||||
```bash
|
||||
# Maximum file size to download for checksum calculation (default: 10485760 = 10MB)
|
||||
ACKIFY_CHECKSUM_MAX_BYTES=10485760
|
||||
|
||||
# Timeout for checksum download in milliseconds (default: 5000ms = 5s)
|
||||
ACKIFY_CHECKSUM_TIMEOUT_MS=5000
|
||||
|
||||
# Maximum number of HTTP redirects to follow (default: 3)
|
||||
ACKIFY_CHECKSUM_MAX_REDIRECTS=3
|
||||
|
||||
# Comma-separated list of allowed MIME types (default includes PDF, images, Office docs, ODF)
|
||||
ACKIFY_CHECKSUM_ALLOWED_TYPES=application/pdf,image/*,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.oasis.opendocument.*
|
||||
```
|
||||
|
||||
**Note**: These settings only apply when admins create documents via the admin dashboard with a remote URL. The system will attempt to download and calculate the SHA-256 checksum automatically.
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### OAuth2 Providers
|
||||
|
||||
See [OAuth Providers](configuration/oauth-providers.md) for detailed configuration of:
|
||||
- Google OAuth2
|
||||
- GitHub OAuth2
|
||||
- GitLab OAuth2 (public + self-hosted)
|
||||
- Custom OAuth2 provider
|
||||
|
||||
### Email (SMTP)
|
||||
|
||||
See [Email Setup](configuration/email-setup.md) to configure email reminders sending.
|
||||
|
||||
## Complete Example
|
||||
|
||||
Example `.env` for a production installation:
|
||||
|
||||
```bash
|
||||
# Application
|
||||
APP_DNS=sign.company.com
|
||||
ACKIFY_BASE_URL=https://sign.company.com
|
||||
ACKIFY_ORGANISATION="ACME Corporation"
|
||||
ACKIFY_LOG_LEVEL=info
|
||||
ACKIFY_LISTEN_ADDR=:8080
|
||||
|
||||
# Database
|
||||
POSTGRES_USER=ackifyr
|
||||
POSTGRES_PASSWORD=super_secure_password_123
|
||||
POSTGRES_DB=ackify
|
||||
|
||||
# OAuth2 (Google)
|
||||
ACKIFY_OAUTH_PROVIDER=google
|
||||
ACKIFY_OAUTH_CLIENT_ID=123456789-abc.apps.googleusercontent.com
|
||||
ACKIFY_OAUTH_CLIENT_SECRET=GOCSPX-xyz123
|
||||
ACKIFY_OAUTH_ALLOWED_DOMAIN=@company.com
|
||||
|
||||
# Security
|
||||
ACKIFY_OAUTH_COOKIE_SECRET=ZXhhbXBsZV9iYXNlNjRfc2VjcmV0X2tleQ==
|
||||
|
||||
# Administration
|
||||
ACKIFY_ADMIN_EMAILS=admin@company.com,cto@company.com
|
||||
|
||||
# Email (optional - omit MAIL_HOST to disable)
|
||||
ACKIFY_MAIL_HOST=smtp.gmail.com
|
||||
ACKIFY_MAIL_PORT=587
|
||||
ACKIFY_MAIL_USERNAME=noreply@company.com
|
||||
ACKIFY_MAIL_PASSWORD=app_specific_password
|
||||
ACKIFY_MAIL_FROM=noreply@company.com
|
||||
ACKIFY_MAIL_FROM_NAME="Ackify - ACME"
|
||||
ACKIFY_MAIL_TEMPLATE_DIR=templates/emails
|
||||
ACKIFY_MAIL_DEFAULT_LOCALE=en
|
||||
|
||||
# Document Checksum (optional - for auto-checksum from URLs)
|
||||
ACKIFY_CHECKSUM_MAX_BYTES=10485760
|
||||
ACKIFY_CHECKSUM_TIMEOUT_MS=5000
|
||||
ACKIFY_CHECKSUM_MAX_REDIRECTS=3
|
||||
```
|
||||
|
||||
## Configuration Validation
|
||||
|
||||
After modifying `.env`, restart:
|
||||
|
||||
```bash
|
||||
docker compose restart ackify-ce
|
||||
```
|
||||
|
||||
Check logs:
|
||||
|
||||
```bash
|
||||
docker compose logs -f ackify-ce
|
||||
```
|
||||
|
||||
Test the health check:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/api/v1/health
|
||||
```
|
||||
|
||||
## Production Variables
|
||||
|
||||
**Production security checklist**:
|
||||
|
||||
- ✅ Use HTTPS (`ACKIFY_BASE_URL=https://...`)
|
||||
- ✅ Generate strong secrets (64+ characters)
|
||||
- ✅ Restrict OAuth domain (`ACKIFY_OAUTH_ALLOWED_DOMAIN`)
|
||||
- ✅ Configure admin emails (`ACKIFY_ADMIN_EMAILS`)
|
||||
- ✅ Use PostgreSQL with SSL in production
|
||||
- ✅ Log in `info` mode (not `debug`)
|
||||
- ✅ Regularly backup the database
|
||||
|
||||
See [Deployment](deployment.md) for more details on production deployment.
|
||||
353
docs/en/configuration/email-setup.md
Normal file
353
docs/en/configuration/email-setup.md
Normal file
@@ -0,0 +1,353 @@
|
||||
# Email Setup
|
||||
|
||||
SMTP configuration for sending email reminders to expected signers.
|
||||
|
||||
## Overview
|
||||
|
||||
Ackify's email service allows sending automatic reminders to users who have not yet signed a document.
|
||||
|
||||
**Features**:
|
||||
- Multilingual reminder sending (fr, en, es, de, it)
|
||||
- HTML and plain text templates
|
||||
- Send history in PostgreSQL
|
||||
- TLS/STARTTLS support
|
||||
- Configurable timeout
|
||||
|
||||
**Note**: Email service is **optional**. If `ACKIFY_MAIL_HOST` is not defined, emails are disabled.
|
||||
|
||||
## Basic Configuration
|
||||
|
||||
### Required Variables
|
||||
|
||||
```bash
|
||||
# SMTP server
|
||||
ACKIFY_MAIL_HOST=smtp.gmail.com
|
||||
ACKIFY_MAIL_PORT=587
|
||||
ACKIFY_MAIL_USERNAME=your-email@gmail.com
|
||||
ACKIFY_MAIL_PASSWORD=your-app-password
|
||||
|
||||
# Sender address
|
||||
ACKIFY_MAIL_FROM=noreply@company.com
|
||||
```
|
||||
|
||||
### Optional Variables
|
||||
|
||||
```bash
|
||||
# Displayed sender name (default: ACKIFY_ORGANISATION)
|
||||
ACKIFY_MAIL_FROM_NAME="Ackify - ACME Corporation"
|
||||
|
||||
# Email subject prefix (optional)
|
||||
ACKIFY_MAIL_SUBJECT_PREFIX="[Ackify]"
|
||||
|
||||
# Enable TLS (default: true)
|
||||
ACKIFY_MAIL_TLS=true
|
||||
|
||||
# Enable STARTTLS (default: true)
|
||||
ACKIFY_MAIL_STARTTLS=true
|
||||
|
||||
# Connection timeout (default: 10s)
|
||||
ACKIFY_MAIL_TIMEOUT=10s
|
||||
|
||||
# Email template directory (default: templates/emails)
|
||||
ACKIFY_MAIL_TEMPLATE_DIR=templates/emails
|
||||
|
||||
# Default email language/locale (default: en)
|
||||
# Supported: en, fr, es, de, it
|
||||
ACKIFY_MAIL_DEFAULT_LOCALE=en
|
||||
```
|
||||
|
||||
## Popular SMTP Providers
|
||||
|
||||
### Gmail
|
||||
|
||||
**Configuration**:
|
||||
```bash
|
||||
ACKIFY_MAIL_HOST=smtp.gmail.com
|
||||
ACKIFY_MAIL_PORT=587
|
||||
ACKIFY_MAIL_USERNAME=your-email@gmail.com
|
||||
ACKIFY_MAIL_PASSWORD=your-app-password
|
||||
ACKIFY_MAIL_TLS=true
|
||||
ACKIFY_MAIL_STARTTLS=true
|
||||
```
|
||||
|
||||
**Prerequisites**:
|
||||
1. Enable 2-step verification on your Google account
|
||||
2. Generate an "App Password": https://myaccount.google.com/apppasswords
|
||||
3. Use this password in `ACKIFY_MAIL_PASSWORD`
|
||||
|
||||
### SendGrid
|
||||
|
||||
```bash
|
||||
ACKIFY_MAIL_HOST=smtp.sendgrid.net
|
||||
ACKIFY_MAIL_PORT=587
|
||||
ACKIFY_MAIL_USERNAME=apikey
|
||||
ACKIFY_MAIL_PASSWORD=your-sendgrid-api-key
|
||||
ACKIFY_MAIL_FROM=noreply@your-domain.com
|
||||
ACKIFY_MAIL_TLS=true
|
||||
```
|
||||
|
||||
### Amazon SES
|
||||
|
||||
```bash
|
||||
ACKIFY_MAIL_HOST=email-smtp.us-east-1.amazonaws.com
|
||||
ACKIFY_MAIL_PORT=587
|
||||
ACKIFY_MAIL_USERNAME=your-smtp-username
|
||||
ACKIFY_MAIL_PASSWORD=your-smtp-password
|
||||
ACKIFY_MAIL_FROM=noreply@verified-domain.com
|
||||
ACKIFY_MAIL_TLS=true
|
||||
```
|
||||
|
||||
**Important**: Verify your domain in AWS SES before sending.
|
||||
|
||||
### Mailgun
|
||||
|
||||
```bash
|
||||
ACKIFY_MAIL_HOST=smtp.mailgun.org
|
||||
ACKIFY_MAIL_PORT=587
|
||||
ACKIFY_MAIL_USERNAME=postmaster@your-domain.mailgun.org
|
||||
ACKIFY_MAIL_PASSWORD=your-mailgun-smtp-password
|
||||
ACKIFY_MAIL_FROM=noreply@your-domain.com
|
||||
ACKIFY_MAIL_TLS=true
|
||||
```
|
||||
|
||||
### Custom SMTP (Self-hosted)
|
||||
|
||||
```bash
|
||||
ACKIFY_MAIL_HOST=mail.company.com
|
||||
ACKIFY_MAIL_PORT=587
|
||||
ACKIFY_MAIL_USERNAME=ackify@company.com
|
||||
ACKIFY_MAIL_PASSWORD=secure_password
|
||||
ACKIFY_MAIL_FROM=ackify@company.com
|
||||
ACKIFY_MAIL_TLS=true
|
||||
ACKIFY_MAIL_STARTTLS=true
|
||||
```
|
||||
|
||||
## Email Templates
|
||||
|
||||
Templates are in `/backend/templates/emails/` with multilingual support.
|
||||
|
||||
### Structure
|
||||
|
||||
```
|
||||
templates/emails/
|
||||
├── fr/
|
||||
│ ├── reminder.html # French HTML template
|
||||
│ └── reminder.txt # French plain text template
|
||||
├── en/
|
||||
│ ├── reminder.html # English HTML template
|
||||
│ └── reminder.txt # English plain text template
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Available Variables
|
||||
|
||||
In templates, you can use:
|
||||
|
||||
```go
|
||||
{{.RecipientName}} // Recipient name
|
||||
{{.DocumentID}} // Document ID
|
||||
{{.DocumentTitle}} // Document title
|
||||
{{.DocumentURL}} // Document URL (if defined in metadata)
|
||||
{{.SignURL}} // URL to sign
|
||||
{{.OrganisationName}} // Organization name
|
||||
{{.SenderName}} // Sender name (admin)
|
||||
```
|
||||
|
||||
### HTML Template Example
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Signature reminder</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Hello {{.RecipientName}},</h1>
|
||||
<p>
|
||||
You are expected to sign the document
|
||||
<strong>{{.DocumentTitle}}</strong>.
|
||||
</p>
|
||||
<p>
|
||||
<a href="{{.SignURL}}" style="background: #0066cc; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">
|
||||
Sign now
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
Best regards,<br>
|
||||
{{.OrganisationName}}
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### Customize Templates
|
||||
|
||||
To use custom templates:
|
||||
|
||||
```bash
|
||||
ACKIFY_MAIL_TEMPLATE_DIR=/custom/path/to/email/templates
|
||||
```
|
||||
|
||||
Make sure to maintain the same directory structure (locale/reminder.html).
|
||||
|
||||
## Sending Reminders
|
||||
|
||||
### Via Admin Dashboard
|
||||
|
||||
1. Go to `/admin`
|
||||
2. Select a document
|
||||
3. Click "Expected Signers"
|
||||
4. Select recipients
|
||||
5. Click "Send Reminders"
|
||||
|
||||
### Via API
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/admin/documents/doc_id/reminders \
|
||||
-b cookies.txt \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-CSRF-Token: YOUR_TOKEN" \
|
||||
-d '{
|
||||
"emails": ["user1@company.com", "user2@company.com"],
|
||||
"locale": "fr"
|
||||
}'
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"sent": 2,
|
||||
"failed": 0,
|
||||
"errors": []
|
||||
}
|
||||
```
|
||||
|
||||
## Reminder History
|
||||
|
||||
Sends are tracked in the `reminder_logs` table:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
recipient_email,
|
||||
sent_at,
|
||||
status,
|
||||
error_message
|
||||
FROM reminder_logs
|
||||
WHERE doc_id = 'my_document'
|
||||
ORDER BY sent_at DESC;
|
||||
```
|
||||
|
||||
**Possible statuses**:
|
||||
- `sent` - Successfully sent
|
||||
- `failed` - Send failure
|
||||
- `bounced` - Bounced (invalid email)
|
||||
|
||||
## Testing the Configuration
|
||||
|
||||
### Manual Test via API
|
||||
|
||||
```bash
|
||||
# 1. Login as admin
|
||||
# 2. Add an expected signer with your email
|
||||
curl -X POST http://localhost:8080/api/v1/admin/documents/test_doc/signers \
|
||||
-b cookies.txt \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-CSRF-Token: YOUR_TOKEN" \
|
||||
-d '{
|
||||
"email": "your-email@company.com",
|
||||
"name": "Test User"
|
||||
}'
|
||||
|
||||
# 3. Send a test reminder
|
||||
curl -X POST http://localhost:8080/api/v1/admin/documents/test_doc/reminders \
|
||||
-b cookies.txt \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-CSRF-Token: YOUR_TOKEN" \
|
||||
-d '{
|
||||
"emails": ["your-email@company.com"],
|
||||
"locale": "en"
|
||||
}'
|
||||
```
|
||||
|
||||
### Check Logs
|
||||
|
||||
```bash
|
||||
docker compose logs -f ackify-ce | grep -i mail
|
||||
```
|
||||
|
||||
You should see:
|
||||
```
|
||||
INFO Email sent successfully to: your-email@company.com
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Error "SMTP connection failed"
|
||||
|
||||
Verify:
|
||||
- `ACKIFY_MAIL_HOST` and `ACKIFY_MAIL_PORT` are correct
|
||||
- Your server allows outgoing connections on the SMTP port
|
||||
- `ACKIFY_MAIL_TLS=true` if the server requires TLS
|
||||
|
||||
### Error "Authentication failed"
|
||||
|
||||
Verify:
|
||||
- `ACKIFY_MAIL_USERNAME` and `ACKIFY_MAIL_PASSWORD` are correct
|
||||
- For Gmail: use an "App Password", not your main password
|
||||
- For SendGrid: username must be `apikey`
|
||||
|
||||
### Email not received but status "sent"
|
||||
|
||||
Verify:
|
||||
- Spam/junk folder
|
||||
- SPF/DKIM/DMARC of your domain (to avoid spam filters)
|
||||
- The `ACKIFY_MAIL_FROM` address is verified with your provider
|
||||
|
||||
### Template not found
|
||||
|
||||
Verify:
|
||||
- `ACKIFY_MAIL_TEMPLATE_DIR` points to the correct directory
|
||||
- The structure `{locale}/reminder.html` exists
|
||||
- Files have the correct permissions (readable)
|
||||
|
||||
### Timeout during send
|
||||
|
||||
Increase timeout:
|
||||
```bash
|
||||
ACKIFY_MAIL_TIMEOUT=30s
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Production
|
||||
|
||||
- ✅ Use a dedicated SMTP service (SendGrid, Mailgun, SES)
|
||||
- ✅ Verify your domain (SPF, DKIM, DMARC)
|
||||
- ✅ Use a `noreply@` address for `ACKIFY_MAIL_FROM`
|
||||
- ✅ Monitor `reminder_logs` to detect failures
|
||||
- ✅ Regularly test email sending
|
||||
|
||||
### Security
|
||||
|
||||
- ✅ Never commit `ACKIFY_MAIL_PASSWORD` to git
|
||||
- ✅ Use Docker secrets or environment variables
|
||||
- ✅ Restrict SMTP account permissions
|
||||
- ✅ Enable TLS/STARTTLS in production
|
||||
|
||||
### Performance
|
||||
|
||||
- Emails are sent **synchronously** during API call
|
||||
- For large volumes, consider an asynchronous queue
|
||||
- Limit number of recipients per batch (recommended: < 100)
|
||||
|
||||
## Disabling Emails
|
||||
|
||||
To completely disable the email service:
|
||||
|
||||
```bash
|
||||
# Remove or comment out ACKIFY_MAIL_HOST
|
||||
# ACKIFY_MAIL_HOST=
|
||||
```
|
||||
|
||||
The admin dashboard will no longer display reminder sending options.
|
||||
254
docs/en/configuration/oauth-providers.md
Normal file
254
docs/en/configuration/oauth-providers.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# OAuth2 Providers
|
||||
|
||||
Detailed configuration of different OAuth2 providers supported by Ackify.
|
||||
|
||||
## Supported Providers
|
||||
|
||||
| Provider | Configuration | Auto-detection |
|
||||
|----------|--------------|----------------|
|
||||
| Google | `ACKIFY_OAUTH_PROVIDER=google` | ✅ |
|
||||
| GitHub | `ACKIFY_OAUTH_PROVIDER=github` | ✅ |
|
||||
| GitLab | `ACKIFY_OAUTH_PROVIDER=gitlab` | ✅ |
|
||||
| Custom | Leave empty + manual URLs | ❌ |
|
||||
|
||||
## Google OAuth2
|
||||
|
||||
### Configuration steps
|
||||
|
||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. Create a project or select an existing one
|
||||
3. Enable "Google+ API" (to retrieve user profile)
|
||||
4. Create OAuth 2.0 credentials:
|
||||
- **Application type**: Web application
|
||||
- **Authorized JavaScript origins**: `https://sign.your-domain.com`
|
||||
- **Authorized redirect URIs**: `https://sign.your-domain.com/api/v1/auth/callback`
|
||||
|
||||
### `.env` Configuration
|
||||
|
||||
```bash
|
||||
ACKIFY_OAUTH_PROVIDER=google
|
||||
ACKIFY_OAUTH_CLIENT_ID=123456789-abc.apps.googleusercontent.com
|
||||
ACKIFY_OAUTH_CLIENT_SECRET=GOCSPX-xyz123abc
|
||||
|
||||
# Optional: restrict to @company.com emails
|
||||
ACKIFY_OAUTH_ALLOWED_DOMAIN=@company.com
|
||||
```
|
||||
|
||||
### Automatic scopes
|
||||
|
||||
By default, Ackify requests:
|
||||
- `openid` - OAuth2 identity
|
||||
- `email` - Email address
|
||||
- `profile` - Full name
|
||||
|
||||
## GitHub OAuth2
|
||||
|
||||
### Configuration steps
|
||||
|
||||
1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
|
||||
2. Click "New OAuth App"
|
||||
3. Fill the form:
|
||||
- **Application name**: Ackify
|
||||
- **Homepage URL**: `https://sign.your-domain.com`
|
||||
- **Authorization callback URL**: `https://sign.your-domain.com/api/v1/auth/callback`
|
||||
4. Generate a client secret
|
||||
|
||||
### `.env` Configuration
|
||||
|
||||
```bash
|
||||
ACKIFY_OAUTH_PROVIDER=github
|
||||
ACKIFY_OAUTH_CLIENT_ID=Iv1.abc123xyz
|
||||
ACKIFY_OAUTH_CLIENT_SECRET=ghp_1234567890abcdef
|
||||
|
||||
# Optional: restrict to verified emails from an organization
|
||||
ACKIFY_OAUTH_ALLOWED_DOMAIN=@company.com
|
||||
```
|
||||
|
||||
### Automatic scopes
|
||||
|
||||
By default:
|
||||
- `read:user` - Profile reading
|
||||
- `user:email` - Email access
|
||||
|
||||
## GitLab OAuth2
|
||||
|
||||
### GitLab.com (public)
|
||||
|
||||
1. Go to [GitLab Applications](https://gitlab.com/-/profile/applications)
|
||||
2. Create a new application:
|
||||
- **Name**: Ackify
|
||||
- **Redirect URI**: `https://sign.your-domain.com/api/v1/auth/callback`
|
||||
- **Scopes**: `openid`, `email`, `profile`
|
||||
3. Copy the Application ID and Secret
|
||||
|
||||
```bash
|
||||
ACKIFY_OAUTH_PROVIDER=gitlab
|
||||
ACKIFY_OAUTH_CLIENT_ID=abc123xyz
|
||||
ACKIFY_OAUTH_CLIENT_SECRET=glpat-xyz123
|
||||
```
|
||||
|
||||
### GitLab Self-Hosted
|
||||
|
||||
For a private GitLab instance:
|
||||
|
||||
```bash
|
||||
ACKIFY_OAUTH_PROVIDER=gitlab
|
||||
ACKIFY_OAUTH_GITLAB_URL=https://gitlab.company.com
|
||||
ACKIFY_OAUTH_CLIENT_ID=abc123xyz
|
||||
ACKIFY_OAUTH_CLIENT_SECRET=glpat-xyz123
|
||||
```
|
||||
|
||||
**Important**: `ACKIFY_OAUTH_GITLAB_URL` must point to your GitLab instance without trailing slash.
|
||||
|
||||
## Custom OAuth2 Provider
|
||||
|
||||
To use a non-standard OAuth2 provider (Keycloak, Okta, Auth0, etc.).
|
||||
|
||||
### Complete configuration
|
||||
|
||||
```bash
|
||||
# Do not define ACKIFY_OAUTH_PROVIDER (or leave empty)
|
||||
ACKIFY_OAUTH_PROVIDER=
|
||||
|
||||
# Manual URLs
|
||||
ACKIFY_OAUTH_AUTH_URL=https://auth.company.com/oauth/authorize
|
||||
ACKIFY_OAUTH_TOKEN_URL=https://auth.company.com/oauth/token
|
||||
ACKIFY_OAUTH_USERINFO_URL=https://auth.company.com/api/user
|
||||
|
||||
# Custom scopes (optional)
|
||||
ACKIFY_OAUTH_SCOPES=openid,email,profile
|
||||
|
||||
# Custom logout URL (optional)
|
||||
ACKIFY_OAUTH_LOGOUT_URL=https://auth.company.com/logout
|
||||
|
||||
# Credentials
|
||||
ACKIFY_OAUTH_CLIENT_ID=your_client_id
|
||||
ACKIFY_OAUTH_CLIENT_SECRET=your_client_secret
|
||||
```
|
||||
|
||||
### Example with Keycloak
|
||||
|
||||
```bash
|
||||
ACKIFY_OAUTH_PROVIDER=
|
||||
ACKIFY_OAUTH_AUTH_URL=https://keycloak.company.com/realms/myrealm/protocol/openid-connect/auth
|
||||
ACKIFY_OAUTH_TOKEN_URL=https://keycloak.company.com/realms/myrealm/protocol/openid-connect/token
|
||||
ACKIFY_OAUTH_USERINFO_URL=https://keycloak.company.com/realms/myrealm/protocol/openid-connect/userinfo
|
||||
ACKIFY_OAUTH_LOGOUT_URL=https://keycloak.company.com/realms/myrealm/protocol/openid-connect/logout
|
||||
ACKIFY_OAUTH_SCOPES=openid,email,profile
|
||||
ACKIFY_OAUTH_CLIENT_ID=ackify-client
|
||||
ACKIFY_OAUTH_CLIENT_SECRET=secret123
|
||||
```
|
||||
|
||||
### Example with Okta
|
||||
|
||||
```bash
|
||||
ACKIFY_OAUTH_PROVIDER=
|
||||
ACKIFY_OAUTH_AUTH_URL=https://dev-123456.okta.com/oauth2/default/v1/authorize
|
||||
ACKIFY_OAUTH_TOKEN_URL=https://dev-123456.okta.com/oauth2/default/v1/token
|
||||
ACKIFY_OAUTH_USERINFO_URL=https://dev-123456.okta.com/oauth2/default/v1/userinfo
|
||||
ACKIFY_OAUTH_SCOPES=openid,email,profile
|
||||
ACKIFY_OAUTH_CLIENT_ID=0oa123xyz
|
||||
ACKIFY_OAUTH_CLIENT_SECRET=secret123
|
||||
```
|
||||
|
||||
## Domain Restriction
|
||||
|
||||
For **all providers**, you can restrict access to emails from a specific domain:
|
||||
|
||||
```bash
|
||||
# Accept only @company.com emails
|
||||
ACKIFY_OAUTH_ALLOWED_DOMAIN=@company.com
|
||||
```
|
||||
|
||||
**Behavior**:
|
||||
- Users with a different email will see an error when logging in
|
||||
- Verification is case-insensitive
|
||||
- Works with all providers (Google, GitHub, GitLab, custom)
|
||||
|
||||
## Auto-Login
|
||||
|
||||
Enable silent auto-login for better UX:
|
||||
|
||||
```bash
|
||||
ACKIFY_OAUTH_AUTO_LOGIN=true
|
||||
```
|
||||
|
||||
**How it works**:
|
||||
- If the user already has an active OAuth session, automatic redirect
|
||||
- No click required on "Sign in"
|
||||
- Useful for corporate integrations (Google Workspace, Microsoft 365)
|
||||
|
||||
**Warning**: Can create infinite redirects if misconfigured.
|
||||
|
||||
## OAuth2 Security
|
||||
|
||||
### PKCE (Proof Key for Code Exchange)
|
||||
|
||||
Ackify **automatically** implements PKCE for all providers:
|
||||
- Protection against authorization code interception
|
||||
- Method: S256 (SHA-256)
|
||||
- Enabled by default, no configuration required
|
||||
|
||||
### Refresh Tokens
|
||||
|
||||
Refresh tokens are:
|
||||
- Stored **encrypted** in PostgreSQL (AES-256-GCM)
|
||||
- Used to maintain sessions for 30 days
|
||||
- Automatically cleaned up after expiration (37 days)
|
||||
- Protected by IP + User-Agent tracking
|
||||
|
||||
### Secure Sessions
|
||||
|
||||
```bash
|
||||
# Strong secret required (minimum 32 bytes in base64)
|
||||
ACKIFY_OAUTH_COOKIE_SECRET=$(openssl rand -base64 32)
|
||||
```
|
||||
|
||||
Session cookies use:
|
||||
- HMAC-SHA256 for integrity
|
||||
- AES-256-GCM encryption
|
||||
- `Secure` flags (HTTPS only) and `HttpOnly`
|
||||
- `SameSite=Lax` for CSRF protection
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Error "invalid redirect_uri"
|
||||
|
||||
Verify that:
|
||||
- `ACKIFY_BASE_URL` exactly matches your domain
|
||||
- The callback URL in the provider includes `/api/v1/auth/callback`
|
||||
- No trailing slash in `ACKIFY_BASE_URL`
|
||||
|
||||
### Error "unauthorized_client"
|
||||
|
||||
Verify:
|
||||
- The `client_id` and `client_secret` are correct
|
||||
- The OAuth application is properly enabled on the provider side
|
||||
- The requested scopes are authorized
|
||||
|
||||
### Error "access_denied"
|
||||
|
||||
The user refused authorization, or:
|
||||
- Their email doesn't match `ACKIFY_OAUTH_ALLOWED_DOMAIN`
|
||||
- The application doesn't have the required permissions
|
||||
|
||||
### Custom provider doesn't work
|
||||
|
||||
Verify:
|
||||
- `ACKIFY_OAUTH_PROVIDER` is **empty** or undefined
|
||||
- The 3 URLs (auth, token, userinfo) are complete and correct
|
||||
- The response from `/userinfo` contains `sub`, `email`, `name`
|
||||
|
||||
## Testing the Configuration
|
||||
|
||||
```bash
|
||||
# Restart after changes
|
||||
docker compose restart ackify-ce
|
||||
|
||||
# Test OAuth connection
|
||||
curl -X POST http://localhost:8080/api/v1/auth/start \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"redirect_to": "/"}'
|
||||
|
||||
# Should return a redirect_url to the OAuth provider
|
||||
```
|
||||
235
docs/en/features/checksums.md
Normal file
235
docs/en/features/checksums.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# Checksums
|
||||
|
||||
Document integrity verification with tracking.
|
||||
|
||||
## Overview
|
||||
|
||||
Ackify allows storing and verifying document checksums (fingerprints) to ensure their integrity.
|
||||
|
||||
**Supported algorithms**:
|
||||
- SHA-256 (recommended)
|
||||
- SHA-512
|
||||
- MD5 (legacy)
|
||||
|
||||
## Calculating a Checksum
|
||||
|
||||
### Command Line
|
||||
|
||||
```bash
|
||||
# Linux/Mac - SHA-256
|
||||
sha256sum document.pdf
|
||||
# Output: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 document.pdf
|
||||
|
||||
# SHA-512
|
||||
sha512sum document.pdf
|
||||
|
||||
# MD5
|
||||
md5sum document.pdf
|
||||
|
||||
# Windows PowerShell
|
||||
Get-FileHash document.pdf -Algorithm SHA256
|
||||
Get-FileHash document.pdf -Algorithm SHA512
|
||||
Get-FileHash document.pdf -Algorithm MD5
|
||||
```
|
||||
|
||||
### Client-Side (JavaScript)
|
||||
|
||||
The Vue.js frontend uses the **Web Crypto API**:
|
||||
|
||||
```javascript
|
||||
async function calculateChecksum(file) {
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
// Usage
|
||||
const file = document.querySelector('input[type="file"]').files[0]
|
||||
const checksum = await calculateChecksum(file)
|
||||
console.log('SHA-256:', checksum)
|
||||
```
|
||||
|
||||
## Storing the Checksum
|
||||
|
||||
### Via Admin Dashboard
|
||||
|
||||
1. Go to `/admin`
|
||||
2. Select a document
|
||||
3. Click "Edit Metadata"
|
||||
4. Fill in:
|
||||
- **Checksum**: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
|
||||
- **Algorithm**: SHA-256
|
||||
- **Document URL**: https://docs.company.com/policy.pdf
|
||||
|
||||
### Via API
|
||||
|
||||
```http
|
||||
PUT /api/v1/admin/documents/policy_2025/metadata
|
||||
Content-Type: application/json
|
||||
X-CSRF-Token: abc123
|
||||
|
||||
{
|
||||
"title": "Security Policy 2025",
|
||||
"url": "https://docs.company.com/policy.pdf",
|
||||
"checksum": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"checksumAlgorithm": "SHA-256",
|
||||
"description": "Annual security policy"
|
||||
}
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
### User Interface
|
||||
|
||||
The frontend displays:
|
||||
```
|
||||
Document: Security Policy 2025
|
||||
Checksum (SHA-256): e3b0c44...52b855 [Copy]
|
||||
URL: https://docs.company.com/policy.pdf [Open]
|
||||
|
||||
[Upload file to verify]
|
||||
```
|
||||
|
||||
**User workflow**:
|
||||
1. Downloads document from URL
|
||||
2. Uploads to verification interface
|
||||
3. Checksum is calculated client-side
|
||||
4. Automatic comparison with stored value
|
||||
5. ✅ Match or ❌ Mismatch
|
||||
|
||||
### Manual Verification
|
||||
|
||||
```bash
|
||||
# 1. Download the document
|
||||
wget https://docs.company.com/policy.pdf
|
||||
|
||||
# 2. Calculate checksum
|
||||
sha256sum policy.pdf
|
||||
# e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
|
||||
|
||||
# 3. Compare with stored value (via API)
|
||||
curl http://localhost:8080/api/v1/documents/policy_2025
|
||||
# "checksum": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
|
||||
# 4. If identical → Document is intact
|
||||
```
|
||||
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Document Compliance
|
||||
|
||||
```
|
||||
Document: "ISO 27001 Certification"
|
||||
Checksum: SHA-256 of official PDF
|
||||
```
|
||||
|
||||
**Workflow**:
|
||||
- Store checksum of certified document
|
||||
- Each reviewer verifies integrity before signing
|
||||
- Audit trail of all verifications
|
||||
|
||||
### Legal Contract
|
||||
|
||||
```
|
||||
Document: "Service Agreement v2.3"
|
||||
Checksum: SHA-512 for maximum security
|
||||
URL: https://legal.company.com/contracts/sa-v2.3.pdf
|
||||
```
|
||||
|
||||
**Guarantees**:
|
||||
- Signed document matches exactly the checksum version
|
||||
- Detection of any modification
|
||||
- Traceability of verifications
|
||||
|
||||
### Training with Materials
|
||||
|
||||
```
|
||||
Document: "GDPR Training Materials"
|
||||
Checksum: SHA-256 of ZIP file
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
- Participants download ZIP
|
||||
- Verify checksum before starting
|
||||
- Sign after completion
|
||||
|
||||
## Security
|
||||
|
||||
### Algorithm Choice
|
||||
|
||||
| Algorithm | Security | Performance | Recommendation |
|
||||
|-----------|----------|-------------|----------------|
|
||||
| SHA-256 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ✅ Recommended |
|
||||
| SHA-512 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | Maximum security |
|
||||
| MD5 | ⭐⭐ | ⭐⭐⭐⭐⭐ | ❌ Legacy only |
|
||||
|
||||
**Recommendation**: Use **SHA-256** by default.
|
||||
|
||||
### MD5 Limitations
|
||||
|
||||
MD5 is **deprecated** for security:
|
||||
- Collisions possible (two different files = same hash)
|
||||
- Usable only for legacy compatibility
|
||||
|
||||
### Web Crypto API
|
||||
|
||||
Client-side verification uses browser's native API:
|
||||
- No external dependency
|
||||
- Native performance
|
||||
- Supported by all modern browsers
|
||||
|
||||
## Integration with Signatures
|
||||
|
||||
Complete workflow:
|
||||
|
||||
```
|
||||
1. Admin uploads document → calculates checksum → stores metadata
|
||||
2. User downloads document → verifies checksum client-side
|
||||
3. If checksum OK → User signs document
|
||||
4. Signature linked to doc_id with stored checksum
|
||||
```
|
||||
|
||||
**Guarantee**: Signature proves user read **exactly** the checksum version.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Storage
|
||||
|
||||
- ✅ Always store checksum **before** sending signature link
|
||||
- ✅ Include document URL in metadata
|
||||
- ✅ Use SHA-256 minimum
|
||||
- ✅ Document the algorithm used
|
||||
|
||||
### Verification
|
||||
|
||||
- ✅ Encourage users to verify before signing
|
||||
- ✅ Display checksum visibly (with Copy button)
|
||||
- ✅ Alert on mismatch
|
||||
|
||||
### Audit
|
||||
|
||||
- ✅ Monitor document integrity
|
||||
- ✅ Review checksums regularly
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Manual verification only** - Users must manually calculate and compare checksums
|
||||
- **No server-side verification API** - Checksum verification is performed client-side or manually
|
||||
- **No automated audit trail** - The `checksum_verifications` table exists in the database schema but is not currently used by the API
|
||||
- No checksum signing (future feature: sign checksum with Ed25519)
|
||||
- No cloud storage integration (S3, GCS) for automatic retrieval
|
||||
|
||||
## Current Implementation
|
||||
|
||||
Currently, Ackify supports:
|
||||
- ✅ Storing checksums in document metadata (via admin dashboard or API)
|
||||
- ✅ Displaying checksums to users for manual verification
|
||||
- ✅ Client-side checksum calculation using Web Crypto API
|
||||
- ✅ Automatic checksum computation for remote URLs (admin only)
|
||||
|
||||
Future features may include:
|
||||
- API endpoints for checksum verification tracking
|
||||
- Automated verification workflows
|
||||
- Integration with external verification services
|
||||
339
docs/en/features/embedding.md
Normal file
339
docs/en/features/embedding.md
Normal file
@@ -0,0 +1,339 @@
|
||||
# Embedding & Integrations
|
||||
|
||||
Integrate Ackify into your tools (Notion, Outline, Google Docs, etc.).
|
||||
|
||||
## Integration Methods
|
||||
|
||||
### 1. Direct Link
|
||||
|
||||
The simplest:
|
||||
```
|
||||
https://sign.company.com/?doc=policy_2025
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
- Email
|
||||
- Chat (Slack, Teams)
|
||||
- Wiki
|
||||
- Documentation
|
||||
|
||||
**Behavior**:
|
||||
- User clicks → Arrives at signature page
|
||||
- Logs in via OAuth2
|
||||
- Signs the document
|
||||
|
||||
### 2. iFrame Embed
|
||||
|
||||
To integrate in a web page:
|
||||
|
||||
```html
|
||||
<iframe src="https://sign.company.com/?doc=policy_2025"
|
||||
width="600"
|
||||
height="200"
|
||||
frameborder="0"
|
||||
style="border: 1px solid #ddd; border-radius: 6px;">
|
||||
</iframe>
|
||||
```
|
||||
|
||||
**Render**:
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 📄 Security Policy 2025 │
|
||||
│ 42 confirmations │
|
||||
│ [Sign this document] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3. oEmbed (Auto-discovery)
|
||||
|
||||
For platforms supporting oEmbed (Notion, Outline, Confluence, etc.).
|
||||
|
||||
#### How it works
|
||||
|
||||
1. Paste URL in your editor:
|
||||
```
|
||||
https://sign.company.com/?doc=policy_2025
|
||||
```
|
||||
|
||||
2. Editor auto-detects via meta tag:
|
||||
```html
|
||||
<link rel="alternate" type="application/json+oembed"
|
||||
href="https://sign.company.com/oembed?url=..." />
|
||||
```
|
||||
|
||||
3. Editor calls `/oembed` and receives:
|
||||
```json
|
||||
{
|
||||
"type": "rich",
|
||||
"version": "1.0",
|
||||
"title": "Security Policy 2025 - 42 confirmations",
|
||||
"provider_name": "Ackify",
|
||||
"html": "<iframe src=\"https://sign.company.com/?doc=policy_2025\" ...>",
|
||||
"height": 200
|
||||
}
|
||||
```
|
||||
|
||||
4. Editor displays iframe automatically
|
||||
|
||||
#### Supported Platforms
|
||||
|
||||
- ✅ **Notion** - Paste URL → Auto-embed
|
||||
- ✅ **Outline** - Paste URL → Auto-embed
|
||||
- ✅ **Confluence** - oEmbed macro
|
||||
- ✅ **AppFlowy** - URL unfurling
|
||||
- ✅ **Slack** - Link unfurling (Open Graph)
|
||||
- ✅ **Microsoft Teams** - Card preview
|
||||
- ✅ **Discord** - Rich embed
|
||||
|
||||
## Open Graph & Twitter Cards
|
||||
|
||||
Ackify automatically generates meta tags for previews:
|
||||
|
||||
```html
|
||||
<!-- Auto-generated for /?doc=policy_2025 -->
|
||||
<meta property="og:title" content="Security Policy 2025 - 42 confirmations" />
|
||||
<meta property="og:description" content="42 people confirmed reading the document" />
|
||||
<meta property="og:url" content="https://sign.company.com/?doc=policy_2025" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
```
|
||||
|
||||
**Result in Slack/Teams**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 🔐 Ackify │
|
||||
│ Security Policy 2025 │
|
||||
│ 42 confirmations │
|
||||
│ sign.company.com │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Specific Integrations
|
||||
|
||||
### Notion
|
||||
|
||||
1. Paste URL in a Notion page:
|
||||
```
|
||||
https://sign.company.com/?doc=policy_2025
|
||||
```
|
||||
|
||||
2. Notion auto-detects oEmbed
|
||||
3. Widget appears with signature button
|
||||
|
||||
**Alternative**: Create manual embed
|
||||
- `/embed` → Paste URL
|
||||
|
||||
### Outline
|
||||
|
||||
1. In an Outline document, paste:
|
||||
```
|
||||
https://sign.company.com/?doc=policy_2025
|
||||
```
|
||||
|
||||
2. Outline automatically loads the widget
|
||||
|
||||
### Google Docs
|
||||
|
||||
Google Docs doesn't support iframes directly, but:
|
||||
|
||||
1. **Option 1 - Link**:
|
||||
```
|
||||
Please sign: https://sign.company.com/?doc=policy_2025
|
||||
```
|
||||
|
||||
2. **Option 2 - Image Badge**:
|
||||
```
|
||||

|
||||
```
|
||||
|
||||
3. **Option 3 - Google Sites**:
|
||||
- Create a Google Sites page
|
||||
- Insert iframe
|
||||
- Link from Google Docs
|
||||
|
||||
See [docs/integrations/google-doc/](../integrations/google-doc/) for more details.
|
||||
|
||||
### Confluence
|
||||
|
||||
1. Edit a Confluence page
|
||||
2. Insert "oEmbed" or "HTML Embed" macro
|
||||
3. Paste:
|
||||
```
|
||||
https://sign.company.com/?doc=policy_2025
|
||||
```
|
||||
|
||||
### Slack
|
||||
|
||||
**Link Unfurling**:
|
||||
|
||||
1. Post URL in a channel:
|
||||
```
|
||||
Hey team, please sign: https://sign.company.com/?doc=policy_2025
|
||||
```
|
||||
|
||||
2. Slack automatically displays preview (Open Graph)
|
||||
|
||||
**Slash Command** (future):
|
||||
```
|
||||
/ackify sign policy_2025
|
||||
```
|
||||
|
||||
### Microsoft Teams
|
||||
|
||||
1. Post URL in a conversation
|
||||
2. Teams displays card preview (Open Graph)
|
||||
|
||||
## PNG Badge
|
||||
|
||||
Generate a visual badge for README, wiki, etc.
|
||||
|
||||
### Badge URL
|
||||
|
||||
```
|
||||
https://sign.company.com/badge/policy_2025.png
|
||||
```
|
||||
|
||||
**Render**:
|
||||
|
||||

|
||||
|
||||
### Markdown
|
||||
|
||||
```markdown
|
||||
[](https://sign.company.com/?doc=policy_2025)
|
||||
```
|
||||
|
||||
### HTML
|
||||
|
||||
```html
|
||||
<a href="https://sign.company.com/?doc=policy_2025">
|
||||
<img src="https://sign.company.com/badge/policy_2025.png" alt="Signature status">
|
||||
</a>
|
||||
```
|
||||
|
||||
## oEmbed API
|
||||
|
||||
### Endpoint
|
||||
|
||||
```http
|
||||
GET /oembed?url=https://sign.company.com/?doc=policy_2025
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"type": "rich",
|
||||
"version": "1.0",
|
||||
"title": "Document policy_2025 - 42 confirmations",
|
||||
"provider_name": "Ackify",
|
||||
"provider_url": "https://sign.company.com",
|
||||
"html": "<iframe src=\"https://sign.company.com/?doc=policy_2025\" width=\"100%\" height=\"200\" frameborder=\"0\" style=\"border: 1px solid #ddd; border-radius: 6px;\" allowtransparency=\"true\"></iframe>",
|
||||
"width": null,
|
||||
"height": 200
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Parameter | Description | Example |
|
||||
|-----------|-------------|---------|
|
||||
| `url` | Document URL (required) | `?url=https://...` |
|
||||
| `maxwidth` | Max width (optional) | `?maxwidth=800` |
|
||||
| `maxheight` | Max height (optional) | `?maxheight=300` |
|
||||
|
||||
### Discovery
|
||||
|
||||
All pages include discovery tag:
|
||||
|
||||
```html
|
||||
<link rel="alternate"
|
||||
type="application/json+oembed"
|
||||
href="https://sign.company.com/oembed?url=..."
|
||||
title="Document title" />
|
||||
```
|
||||
|
||||
## Customization
|
||||
|
||||
### Dark Mode Theme
|
||||
|
||||
Widget automatically detects browser's dark mode:
|
||||
|
||||
```css
|
||||
@media (prefers-color-scheme: dark) {
|
||||
/* Automatic dark theme */
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Size
|
||||
|
||||
```html
|
||||
<iframe src="https://sign.company.com/?doc=policy_2025"
|
||||
width="800"
|
||||
height="300"
|
||||
frameborder="0">
|
||||
</iframe>
|
||||
```
|
||||
|
||||
### Language
|
||||
|
||||
Widget automatically detects browser language:
|
||||
- `fr` - Français
|
||||
- `en` - English
|
||||
- `es` - Español
|
||||
- `de` - Deutsch
|
||||
- `it` - Italiano
|
||||
|
||||
## Security
|
||||
|
||||
### iFrame Sandboxing
|
||||
|
||||
By default, Ackify iframes allow:
|
||||
- `allow-same-origin` - OAuth2 cookies
|
||||
- `allow-scripts` - Vue.js features
|
||||
- `allow-forms` - Signature submission
|
||||
- `allow-popups` - OAuth redirect
|
||||
|
||||
### CORS
|
||||
|
||||
Ackify automatically configures CORS for:
|
||||
- All origins (public reading)
|
||||
- Credentials via `Access-Control-Allow-Credentials`
|
||||
|
||||
### CSP
|
||||
|
||||
Content Security Policy headers configured to allow embedding:
|
||||
|
||||
```
|
||||
X-Frame-Options: SAMEORIGIN
|
||||
Content-Security-Policy: frame-ancestors 'self' https://notion.so https://outline.com
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### iframe not displaying
|
||||
|
||||
Verify:
|
||||
- HTTPS enabled (required for OAuth)
|
||||
- CSP headers allow embedding
|
||||
- No content blocker (uBlock, Privacy Badger)
|
||||
|
||||
### oEmbed not detected
|
||||
|
||||
Verify:
|
||||
- Tag `<link rel="alternate" type="application/json+oembed">` is present
|
||||
- URL is exact (with `?doc=...`)
|
||||
- Platform supports oEmbed discovery
|
||||
|
||||
### Slack preview empty
|
||||
|
||||
Verify:
|
||||
- Open Graph meta tags present
|
||||
- URL publicly accessible
|
||||
- No infinite redirect
|
||||
|
||||
## Complete Examples
|
||||
|
||||
See:
|
||||
- [docs/integrations/google-doc/](../integrations/google-doc/) - Google Workspace integration
|
||||
- More examples coming...
|
||||
328
docs/en/features/expected-signers.md
Normal file
328
docs/en/features/expected-signers.md
Normal file
@@ -0,0 +1,328 @@
|
||||
# Expected Signers
|
||||
|
||||
Tracking expected signers with email reminders.
|
||||
|
||||
## Overview
|
||||
|
||||
The "Expected Signers" feature allows you to:
|
||||
- Define who should sign a document
|
||||
- Track completion rate
|
||||
- Send automatic email reminders
|
||||
- Detect unexpected signatures
|
||||
|
||||
## Adding Signers
|
||||
|
||||
### Via Admin Dashboard
|
||||
|
||||
1. Go to `/admin`
|
||||
2. Select a document
|
||||
3. Click "Expected Signers"
|
||||
4. Paste email list:
|
||||
|
||||
```
|
||||
Alice Smith <alice@company.com>
|
||||
bob@company.com
|
||||
charlie@company.com
|
||||
```
|
||||
|
||||
**Supported formats**:
|
||||
- One email per line
|
||||
- Comma-separated emails
|
||||
- Semicolon-separated emails
|
||||
- Format with name: `Alice Smith <alice@company.com>`
|
||||
|
||||
### Via API
|
||||
|
||||
```http
|
||||
POST /api/v1/admin/documents/policy_2025/signers
|
||||
Content-Type: application/json
|
||||
X-CSRF-Token: abc123
|
||||
|
||||
{
|
||||
"email": "alice@company.com",
|
||||
"name": "Alice Smith",
|
||||
"notes": "Engineering team lead"
|
||||
}
|
||||
```
|
||||
|
||||
### Batch Adding
|
||||
|
||||
```bash
|
||||
# Email list in a file
|
||||
cat emails.txt | while read email; do
|
||||
curl -X POST http://localhost:8080/api/v1/admin/documents/policy_2025/signers \
|
||||
-b cookies.txt \
|
||||
-H "X-CSRF-Token: $CSRF_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"email\": \"$email\"}"
|
||||
done
|
||||
```
|
||||
|
||||
## Completion Tracking
|
||||
|
||||
### Admin Dashboard
|
||||
|
||||
Displays:
|
||||
- **Progress bar** - Visual with percentage
|
||||
- **Signer list**:
|
||||
- ✓ Email (signed on MM/DD/YYYY HH:MM)
|
||||
- ⏳ Email (pending)
|
||||
- **Statistics**:
|
||||
- Expected: 50
|
||||
- Signed: 42
|
||||
- Pending: 8
|
||||
- Completion: 84%
|
||||
|
||||
### Via API
|
||||
|
||||
```http
|
||||
GET /api/v1/documents/policy_2025/expected-signers
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"docId": "policy_2025",
|
||||
"expectedSigners": [
|
||||
{
|
||||
"email": "alice@company.com",
|
||||
"name": "Alice Smith",
|
||||
"addedAt": "2025-01-15T10:00:00Z",
|
||||
"hasSigned": true,
|
||||
"signedAt": "2025-01-15T14:30:00Z"
|
||||
},
|
||||
{
|
||||
"email": "bob@company.com",
|
||||
"name": "Bob Jones",
|
||||
"addedAt": "2025-01-15T10:00:00Z",
|
||||
"hasSigned": false
|
||||
}
|
||||
],
|
||||
"completionStats": {
|
||||
"expected": 50,
|
||||
"signed": 42,
|
||||
"pending": 8,
|
||||
"completionPercentage": 84.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Email Reminders
|
||||
|
||||
### Sending Reminders
|
||||
|
||||
**Via Dashboard**:
|
||||
1. Select recipients (or "Select all pending")
|
||||
2. Choose language (fr, en, es, de, it)
|
||||
3. Click "Send Reminders"
|
||||
|
||||
**Via API**:
|
||||
```http
|
||||
POST /api/v1/admin/documents/policy_2025/reminders
|
||||
Content-Type: application/json
|
||||
X-CSRF-Token: abc123
|
||||
|
||||
{
|
||||
"emails": ["bob@company.com", "charlie@company.com"],
|
||||
"locale": "fr"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"sent": 2,
|
||||
"failed": 0,
|
||||
"errors": []
|
||||
}
|
||||
```
|
||||
|
||||
### Email Content
|
||||
|
||||
Templates are in `/backend/templates/emails/{locale}/reminder.html`:
|
||||
|
||||
```html
|
||||
Hello {{.RecipientName}},
|
||||
|
||||
You are expected to sign the document "{{.DocumentTitle}}".
|
||||
|
||||
[Button: Sign now] → {{.SignURL}}
|
||||
|
||||
Document available here: {{.DocumentURL}}
|
||||
|
||||
Best regards,
|
||||
{{.OrganisationName}}
|
||||
```
|
||||
|
||||
**Available variables**:
|
||||
- `RecipientName` - Recipient name
|
||||
- `DocumentTitle` - Document title
|
||||
- `DocumentURL` - Document URL (metadata)
|
||||
- `SignURL` - Direct link to signature page
|
||||
- `OrganisationName` - Your organization name
|
||||
|
||||
### Reminder History
|
||||
|
||||
```http
|
||||
GET /api/v1/admin/documents/policy_2025/reminders
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"reminders": [
|
||||
{
|
||||
"recipientEmail": "bob@company.com",
|
||||
"sentAt": "2025-01-15T15:00:00Z",
|
||||
"sentBy": "admin@company.com",
|
||||
"status": "sent",
|
||||
"templateUsed": "reminder"
|
||||
},
|
||||
{
|
||||
"recipientEmail": "charlie@company.com",
|
||||
"sentAt": "2025-01-15T15:00:05Z",
|
||||
"sentBy": "admin@company.com",
|
||||
"status": "failed",
|
||||
"errorMessage": "SMTP timeout"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Statuses**:
|
||||
- `sent` - Successfully sent
|
||||
- `failed` - Send failure
|
||||
- `bounced` - Invalid email (bounce)
|
||||
|
||||
## Unexpected Signatures
|
||||
|
||||
Automatically detects users who signed **without being expected**.
|
||||
|
||||
### Via Dashboard
|
||||
|
||||
"Unexpected Signatures" section displays:
|
||||
```
|
||||
⚠️ 3 unexpected signatures detected
|
||||
- stranger@external.com (signed on 01/15/2025)
|
||||
- unknown@gmail.com (signed on 01/16/2025)
|
||||
```
|
||||
|
||||
### Via API
|
||||
|
||||
SQL query to detect:
|
||||
```sql
|
||||
SELECT s.user_email, s.signed_at
|
||||
FROM signatures s
|
||||
LEFT JOIN expected_signers e ON s.user_email = e.email AND s.doc_id = e.doc_id
|
||||
WHERE s.doc_id = 'policy_2025' AND e.id IS NULL;
|
||||
```
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Mandatory Training
|
||||
|
||||
```
|
||||
Document: "GDPR Training 2025"
|
||||
Expected: All employees (CSV import)
|
||||
```
|
||||
|
||||
**Workflow**:
|
||||
1. Import CSV with employee emails
|
||||
2. Send signature link to everyone
|
||||
3. Automatic reminder on D+7 to non-signers
|
||||
4. Final export for HR
|
||||
|
||||
### Security Policy
|
||||
|
||||
```
|
||||
Document: "Security Policy v3"
|
||||
Expected: Engineers + DevOps (50 people)
|
||||
```
|
||||
|
||||
**Features used**:
|
||||
- Real-time tracking (dashboard)
|
||||
- Selective reminders (only some)
|
||||
- Document metadata (URL + checksum)
|
||||
|
||||
### Contractual
|
||||
|
||||
```
|
||||
Document: "NDA 2025"
|
||||
Expected: External contractors (manual list)
|
||||
```
|
||||
|
||||
**Particularity**:
|
||||
- Restricted OAuth domain disabled
|
||||
- Allows external emails to sign
|
||||
- Unexpected signature detection crucial
|
||||
|
||||
## Removing a Signer
|
||||
|
||||
```http
|
||||
DELETE /api/v1/admin/documents/policy_2025/signers/alice@company.com
|
||||
X-CSRF-Token: abc123
|
||||
```
|
||||
|
||||
**Behavior**:
|
||||
- Removes from expected_signers list
|
||||
- Signature (if exists) remains in database
|
||||
- Completion rate is recalculated
|
||||
|
||||
## Email Configuration
|
||||
|
||||
For reminders to work, configure SMTP:
|
||||
|
||||
```bash
|
||||
ACKIFY_MAIL_HOST=smtp.gmail.com
|
||||
ACKIFY_MAIL_PORT=587
|
||||
ACKIFY_MAIL_USERNAME=noreply@company.com
|
||||
ACKIFY_MAIL_PASSWORD=app_password
|
||||
ACKIFY_MAIL_FROM=noreply@company.com
|
||||
```
|
||||
|
||||
See [Email Setup](../configuration/email-setup.md) for more details.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### CSV Import
|
||||
|
||||
For bulk import:
|
||||
|
||||
```python
|
||||
import csv
|
||||
import requests
|
||||
|
||||
with open('employees.csv') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
requests.post(
|
||||
'http://localhost:8080/api/v1/admin/documents/policy_2025/signers',
|
||||
json={'email': row['email'], 'name': row['name']},
|
||||
headers={'X-CSRF-Token': csrf_token},
|
||||
cookies=cookies
|
||||
)
|
||||
```
|
||||
|
||||
### Customization
|
||||
|
||||
For more personalized reminders:
|
||||
1. Modify templates in `/backend/templates/emails/`
|
||||
2. Add custom variables in email service
|
||||
3. Rebuild Docker image
|
||||
|
||||
### Monitoring
|
||||
|
||||
Monitor `reminder_logs` to detect:
|
||||
- High bounce rate (invalid emails)
|
||||
- Repeated SMTP failures
|
||||
- Reminder effectiveness (conversion rate)
|
||||
|
||||
## Limitations
|
||||
|
||||
- Maximum **1000 expected signers** per document (soft limit)
|
||||
- Reminders sent **synchronously** (no queue)
|
||||
- No automatic scheduled reminders (manual only)
|
||||
|
||||
## API Reference
|
||||
|
||||
See [API Documentation](../api.md#expected-signers-admin) for all endpoints.
|
||||
320
docs/en/features/i18n.md
Normal file
320
docs/en/features/i18n.md
Normal file
@@ -0,0 +1,320 @@
|
||||
# Internationalization (i18n)
|
||||
|
||||
Complete multilingual support for Ackify frontend.
|
||||
|
||||
## Supported Languages
|
||||
|
||||
- 🇫🇷 **Français** (default)
|
||||
- 🇬🇧 **English** (fallback)
|
||||
- 🇪🇸 **Español**
|
||||
- 🇩🇪 **Deutsch**
|
||||
- 🇮🇹 **Italiano**
|
||||
|
||||
## Frontend (Vue.js)
|
||||
|
||||
### Language Selection
|
||||
|
||||
The frontend automatically detects language via:
|
||||
|
||||
1. **localStorage** - Saved user choice
|
||||
2. **navigator.language** - Browser language
|
||||
3. **Fallback** - English if not supported
|
||||
|
||||
### Language Switcher
|
||||
|
||||
User interface with Unicode flags:
|
||||
|
||||
```
|
||||
🇫🇷 FR | 🇬🇧 EN | 🇪🇸 ES | 🇩🇪 DE | 🇮🇹 IT
|
||||
```
|
||||
|
||||
Click → Language changes + saves to localStorage
|
||||
|
||||
### Translation Files
|
||||
|
||||
Located in `/webapp/src/locales/`:
|
||||
|
||||
```
|
||||
locales/
|
||||
├── fr.json # Français
|
||||
├── en.json # English
|
||||
├── es.json # Español
|
||||
├── de.json # Deutsch
|
||||
└── it.json # Italiano
|
||||
```
|
||||
|
||||
### JSON Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"home": {
|
||||
"title": "Ackify - Proof of Read",
|
||||
"subtitle": "Cryptographic read signatures"
|
||||
},
|
||||
"document": {
|
||||
"sign": "Sign this document",
|
||||
"signed": "Document signed",
|
||||
"signatures": "{count} confirmation | {count} confirmations"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pluralization**:
|
||||
```json
|
||||
"signatures": "{count} confirmation | {count} confirmations"
|
||||
```
|
||||
|
||||
Usage:
|
||||
```vue
|
||||
{{ $t('document.signatures', { count: 42 }) }}
|
||||
// → "42 confirmations"
|
||||
```
|
||||
|
||||
## Backend (Go)
|
||||
|
||||
### Email Templates
|
||||
|
||||
Emails use multilingual templates in `/backend/templates/emails/`:
|
||||
|
||||
```
|
||||
templates/emails/
|
||||
├── fr/
|
||||
│ ├── reminder.html
|
||||
│ └── reminder.txt
|
||||
├── en/
|
||||
│ ├── reminder.html
|
||||
│ └── reminder.txt
|
||||
├── es/...
|
||||
├── de/...
|
||||
└── it/...
|
||||
```
|
||||
|
||||
### Sending with Locale
|
||||
|
||||
```http
|
||||
POST /api/v1/admin/documents/doc_id/reminders
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"emails": ["user@company.com"],
|
||||
"locale": "fr"
|
||||
}
|
||||
```
|
||||
|
||||
Backend loads template `fr/reminder.html`.
|
||||
|
||||
### Configuration
|
||||
|
||||
```bash
|
||||
# Default language for emails (default: en)
|
||||
ACKIFY_MAIL_DEFAULT_LOCALE=fr
|
||||
```
|
||||
|
||||
## Adding a Language
|
||||
|
||||
### Frontend
|
||||
|
||||
1. **Create translation file**:
|
||||
|
||||
```bash
|
||||
cd webapp/src/locales
|
||||
cp en.json pt.json # Portuguese
|
||||
```
|
||||
|
||||
2. **Translate**:
|
||||
|
||||
```json
|
||||
{
|
||||
"home": {
|
||||
"title": "Ackify - Prova de Leitura",
|
||||
"subtitle": "Assinaturas criptográficas de leitura"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Register in i18n**:
|
||||
|
||||
```typescript
|
||||
// webapp/src/i18n.ts
|
||||
import pt from './locales/pt.json'
|
||||
|
||||
const i18n = createI18n({
|
||||
locale: 'fr',
|
||||
fallbackLocale: 'en',
|
||||
messages: {
|
||||
fr, en, es, de, it,
|
||||
pt // Add here
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
4. **Add to selector**:
|
||||
|
||||
```vue
|
||||
<!-- components/LanguageSwitcher.vue -->
|
||||
<button @click="changeLocale('pt')">🇵🇹 PT</button>
|
||||
```
|
||||
|
||||
### Backend
|
||||
|
||||
1. **Create directory**:
|
||||
|
||||
```bash
|
||||
mkdir -p backend/templates/emails/pt
|
||||
```
|
||||
|
||||
2. **Create templates**:
|
||||
|
||||
```bash
|
||||
cp backend/templates/emails/en/reminder.html backend/templates/emails/pt/
|
||||
cp backend/templates/emails/en/reminder.txt backend/templates/emails/pt/
|
||||
```
|
||||
|
||||
3. **Translate templates**
|
||||
|
||||
4. **Rebuild**:
|
||||
|
||||
```bash
|
||||
docker compose up -d --force-recreate ackify-ce --build
|
||||
```
|
||||
|
||||
## i18n Verification
|
||||
|
||||
### Validation Script
|
||||
|
||||
Project includes a script to verify translation completeness:
|
||||
|
||||
```bash
|
||||
cd webapp
|
||||
npm run lint:i18n
|
||||
```
|
||||
|
||||
**Output**:
|
||||
```
|
||||
✅ fr.json - 156 keys
|
||||
✅ en.json - 156 keys
|
||||
✅ es.json - 156 keys
|
||||
✅ de.json - 156 keys
|
||||
✅ it.json - 156 keys
|
||||
|
||||
All translations are complete!
|
||||
```
|
||||
|
||||
### CI/CD
|
||||
|
||||
Script automatically runs in GitHub Actions to block PRs with missing translations.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Translation Keys
|
||||
|
||||
- ✅ Use structured keys: `feature.action.label`
|
||||
- ✅ Group by page/component
|
||||
- ✅ Avoid too generic keys (`button`, `title`)
|
||||
- ✅ Use placeholders: `{count}`, `{name}`
|
||||
|
||||
**Example**:
|
||||
```json
|
||||
{
|
||||
"admin": {
|
||||
"documents": {
|
||||
"list": {
|
||||
"title": "Document list",
|
||||
"count": "{count} document | {count} documents"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Synchronization
|
||||
|
||||
When adding new keys in French:
|
||||
|
||||
```bash
|
||||
# Sync script (to create)
|
||||
node scripts/sync-i18n-from-fr.js
|
||||
```
|
||||
|
||||
Automatically copies new keys to other languages with `[TODO]`.
|
||||
|
||||
### Long Texts
|
||||
|
||||
For long texts, use arrays:
|
||||
|
||||
```json
|
||||
{
|
||||
"help": {
|
||||
"intro": [
|
||||
"Ackify allows creating cryptographic signatures.",
|
||||
"Each signature is timestamped and non-repudiable.",
|
||||
"Data is stored immutably."
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Usage:
|
||||
```vue
|
||||
<p v-for="line in $tm('help.intro')" :key="line">
|
||||
{{ line }}
|
||||
</p>
|
||||
```
|
||||
|
||||
## Specific Formats
|
||||
|
||||
### Dates
|
||||
|
||||
```typescript
|
||||
// Format with current locale
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { locale } = useI18n()
|
||||
const formatted = new Date().toLocaleDateString(locale.value, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
// fr: "15 janvier 2025"
|
||||
// en: "January 15, 2025"
|
||||
```
|
||||
|
||||
### Numbers
|
||||
|
||||
```typescript
|
||||
const formatted = (42000).toLocaleString(locale.value)
|
||||
// fr: "42 000"
|
||||
// en: "42,000"
|
||||
```
|
||||
|
||||
## SEO & Meta Tags
|
||||
|
||||
Meta tags are dynamically translated:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useHead } from '@vueuse/head'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
useHead({
|
||||
title: t('home.title'),
|
||||
meta: [
|
||||
{ name: 'description', content: t('home.description') }
|
||||
]
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Complete Documentation
|
||||
|
||||
For more details on frontend i18n implementation, see:
|
||||
|
||||
**[webapp/I18N.md](../../webapp/I18N.md)**
|
||||
|
||||
This file contains:
|
||||
- Complete vue-i18n architecture
|
||||
- Contribution guide
|
||||
- Synchronization scripts
|
||||
- Advanced examples
|
||||
235
docs/en/features/signatures.md
Normal file
235
docs/en/features/signatures.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# Cryptographic Signatures
|
||||
|
||||
Complete signature flow with Ed25519 and security guarantees.
|
||||
|
||||
## Principle
|
||||
|
||||
Ackify uses **Ed25519** (elliptic curve) to create non-repudiable cryptographic signatures.
|
||||
|
||||
**Guarantees**:
|
||||
- ✅ **Non-repudiation** - The signature proves the signer's identity
|
||||
- ✅ **Integrity** - SHA-256 hash detects any modification
|
||||
- ✅ **Immutable timestamp** - PostgreSQL triggers prevent backdating
|
||||
- ✅ **Uniqueness** - One signature per user/document
|
||||
|
||||
## Signature Flow
|
||||
|
||||
### 1. User accesses the document
|
||||
|
||||
```
|
||||
https://sign.company.com/?doc=policy_2025
|
||||
```
|
||||
|
||||
The Vue.js frontend loads and displays:
|
||||
- Document title (if metadata exists)
|
||||
- Number of existing signatures
|
||||
- "Sign this document" button
|
||||
|
||||
### 2. Session verification
|
||||
|
||||
The frontend calls:
|
||||
```http
|
||||
GET /api/v1/users/me
|
||||
```
|
||||
|
||||
**If not logged in** → OAuth2 redirect
|
||||
**If logged in** → Display signature button
|
||||
|
||||
### 3. Signature
|
||||
|
||||
When clicking "Sign", the frontend:
|
||||
|
||||
1. Gets a CSRF token:
|
||||
```http
|
||||
GET /api/v1/csrf
|
||||
```
|
||||
|
||||
2. Sends the signature:
|
||||
```http
|
||||
POST /api/v1/signatures
|
||||
Content-Type: application/json
|
||||
X-CSRF-Token: abc123
|
||||
|
||||
{
|
||||
"doc_id": "policy_2025"
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Backend Processing
|
||||
|
||||
The backend (Go):
|
||||
|
||||
1. **Verifies the session** - User authenticated
|
||||
2. **Generates Ed25519 signature**:
|
||||
```go
|
||||
payload := fmt.Sprintf("%s:%s:%s:%s", docID, userSub, userEmail, timestamp)
|
||||
hash := sha256.Sum256([]byte(payload))
|
||||
signature := ed25519.Sign(privateKey, hash[:])
|
||||
```
|
||||
3. **Calculates prev_hash** - Hash of the last signature (chaining)
|
||||
4. **Inserts into database**:
|
||||
```sql
|
||||
INSERT INTO signatures (doc_id, user_sub, user_email, signed_at, payload_hash, signature, nonce, prev_hash)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
```
|
||||
5. **Returns the signature** to the frontend
|
||||
|
||||
### 5. Confirmation
|
||||
|
||||
The frontend displays:
|
||||
- ✅ Signature confirmed
|
||||
- Timestamp
|
||||
- Link to signatures list
|
||||
|
||||
## Signature Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"docId": "policy_2025",
|
||||
"userEmail": "alice@company.com",
|
||||
"userName": "Alice Smith",
|
||||
"signedAt": "2025-01-15T14:30:00Z",
|
||||
"payloadHash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"signature": "ed25519:3045022100...",
|
||||
"nonce": "abc123xyz",
|
||||
"prevHash": "sha256:prev..."
|
||||
}
|
||||
```
|
||||
|
||||
**Fields**:
|
||||
- `payloadHash` - SHA-256 of the payload (doc_id:user_sub:email:timestamp)
|
||||
- `signature` - Ed25519 signature in base64
|
||||
- `nonce` - Anti-replay protection
|
||||
- `prevHash` - Hash of the previous signature (blockchain-like)
|
||||
|
||||
## Signature Verification
|
||||
|
||||
### Manual (via API)
|
||||
|
||||
```http
|
||||
GET /api/v1/documents/policy_2025/signatures
|
||||
```
|
||||
|
||||
Returns all signatures with:
|
||||
- Signer email
|
||||
- Timestamp
|
||||
- Hash + signature
|
||||
|
||||
### Programmatic (Go)
|
||||
|
||||
```go
|
||||
import "crypto/ed25519"
|
||||
|
||||
func VerifySignature(publicKey ed25519.PublicKey, payload, signature []byte) bool {
|
||||
hash := sha256.Sum256(payload)
|
||||
return ed25519.Verify(publicKey, hash[:], signature)
|
||||
}
|
||||
```
|
||||
|
||||
## PostgreSQL Constraints
|
||||
|
||||
### One signature per user/document
|
||||
|
||||
```sql
|
||||
UNIQUE (doc_id, user_sub)
|
||||
```
|
||||
|
||||
**Behavior**:
|
||||
- If the user tries to sign twice → 409 Conflict error
|
||||
- The frontend detects this and displays "Already signed"
|
||||
|
||||
### Immutability of `created_at`
|
||||
|
||||
PostgreSQL trigger:
|
||||
```sql
|
||||
CREATE TRIGGER prevent_signatures_created_at_update
|
||||
BEFORE UPDATE ON signatures
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION prevent_created_at_update();
|
||||
```
|
||||
|
||||
**Guarantee**: Impossible to backdate a signature.
|
||||
|
||||
## Chaining (Blockchain-like)
|
||||
|
||||
Each signature references the previous one via `prev_hash`:
|
||||
|
||||
```
|
||||
Signature 1 → hash1
|
||||
Signature 2 → hash2 (prev_hash = hash1)
|
||||
Signature 3 → hash3 (prev_hash = hash2)
|
||||
```
|
||||
|
||||
**Tampering detection**:
|
||||
- If a signature is modified, the `prev_hash` of the next one no longer matches
|
||||
- Allows detection of any history modification
|
||||
|
||||
## Security
|
||||
|
||||
### Ed25519 Private Key
|
||||
|
||||
Auto-generated on first startup or via:
|
||||
|
||||
```bash
|
||||
ACKIFY_ED25519_PRIVATE_KEY=$(openssl rand -base64 64)
|
||||
```
|
||||
|
||||
**Important**:
|
||||
- The private key never leaves the server
|
||||
- Stored in memory only (not in database)
|
||||
- Backup required if you want to keep the same key after redeployment
|
||||
|
||||
### Anti-Replay Protection
|
||||
|
||||
The unique `nonce` prevents signature reuse:
|
||||
```go
|
||||
nonce := fmt.Sprintf("%s-%d", userSub, time.Now().UnixNano())
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
Signatures are limited to **100 requests/minute** per IP.
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Policy Read Validation
|
||||
|
||||
```
|
||||
Document: "Security Policy 2025"
|
||||
URL: https://sign.company.com/?doc=security_policy_2025
|
||||
```
|
||||
|
||||
**Workflow**:
|
||||
1. Admin sends the link to employees
|
||||
2. Each employee clicks, reads, and signs
|
||||
3. Admin sees completion in `/admin`
|
||||
|
||||
### Training Acknowledgment
|
||||
|
||||
```
|
||||
Document: "GDPR Training 2025"
|
||||
Expected signers: 50 employees
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- Completion tracking (42/50 = 84%)
|
||||
- Automatic email reminders
|
||||
- Signature export
|
||||
|
||||
### Contractual Acknowledgment
|
||||
|
||||
```
|
||||
Document: "Terms of Service v3"
|
||||
Checksum: SHA-256 of the PDF
|
||||
```
|
||||
|
||||
**Verification**:
|
||||
- User calculates the PDF checksum
|
||||
- Compares with stored metadata
|
||||
- Signs if identical
|
||||
|
||||
See [Checksums](checksums.md) for more details.
|
||||
|
||||
## API Reference
|
||||
|
||||
See [API Documentation](../api.md) for all signature-related endpoints.
|
||||
208
docs/en/getting-started.md
Normal file
208
docs/en/getting-started.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# Getting Started
|
||||
|
||||
Installation and configuration guide for Ackify with Docker Compose.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker and Docker Compose installed
|
||||
- A domain (or localhost for testing)
|
||||
- OAuth2 credentials (Google, GitHub, GitLab, or custom)
|
||||
|
||||
## Quick Installation
|
||||
|
||||
### 1. Clone the repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/btouchard/ackify-ce.git
|
||||
cd ackify-ce
|
||||
```
|
||||
|
||||
### 2. Configuration
|
||||
|
||||
Copy the example file and edit it:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
**Minimum required variables**:
|
||||
|
||||
```bash
|
||||
# Public domain of your instance
|
||||
APP_DNS=sign.your-domain.com
|
||||
ACKIFY_BASE_URL=https://sign.your-domain.com
|
||||
ACKIFY_ORGANISATION="Your Organization Name"
|
||||
|
||||
# PostgreSQL database
|
||||
POSTGRES_USER=ackifyr
|
||||
POSTGRES_PASSWORD=your_secure_password_here
|
||||
POSTGRES_DB=ackify
|
||||
|
||||
# OAuth2 (example with Google)
|
||||
ACKIFY_OAUTH_PROVIDER=google
|
||||
ACKIFY_OAUTH_CLIENT_ID=your_google_client_id
|
||||
ACKIFY_OAUTH_CLIENT_SECRET=your_google_client_secret
|
||||
|
||||
# Security - generate with: openssl rand -base64 32
|
||||
ACKIFY_OAUTH_COOKIE_SECRET=your_base64_encoded_secret_key
|
||||
```
|
||||
|
||||
### 3. Start
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This command will:
|
||||
- Download necessary Docker images
|
||||
- Start PostgreSQL with healthcheck
|
||||
- Apply database migrations
|
||||
- Launch the Ackify application
|
||||
|
||||
### 4. Verification
|
||||
|
||||
```bash
|
||||
# View logs
|
||||
docker compose logs -f ackify-ce
|
||||
|
||||
# Check health endpoint
|
||||
curl http://localhost:8080/api/v1/health
|
||||
# Expected: {"status":"healthy","database":"connected"}
|
||||
```
|
||||
|
||||
### 5. Access the interface
|
||||
|
||||
Open your browser:
|
||||
- **Public interface**: http://localhost:8080
|
||||
- **Admin dashboard**: http://localhost:8080/admin (requires email in ACKIFY_ADMIN_EMAILS)
|
||||
|
||||
## OAuth2 Configuration
|
||||
|
||||
Before using Ackify, configure your OAuth2 provider.
|
||||
|
||||
### Google OAuth2
|
||||
|
||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. Create a new project or select an existing one
|
||||
3. Enable the "Google+ API"
|
||||
4. Create OAuth 2.0 credentials:
|
||||
- Type: Web application
|
||||
- Authorized redirect URIs: `https://sign.your-domain.com/api/v1/auth/callback`
|
||||
5. Copy the Client ID and Client Secret to `.env`
|
||||
|
||||
```bash
|
||||
ACKIFY_OAUTH_PROVIDER=google
|
||||
ACKIFY_OAUTH_CLIENT_ID=123456789-abc.apps.googleusercontent.com
|
||||
ACKIFY_OAUTH_CLIENT_SECRET=GOCSPX-xyz...
|
||||
```
|
||||
|
||||
### GitHub OAuth2
|
||||
|
||||
1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
|
||||
2. Create a new OAuth App
|
||||
3. Configuration:
|
||||
- Homepage URL: `https://sign.your-domain.com`
|
||||
- Callback URL: `https://sign.your-domain.com/api/v1/auth/callback`
|
||||
4. Generate a client secret
|
||||
|
||||
```bash
|
||||
ACKIFY_OAUTH_PROVIDER=github
|
||||
ACKIFY_OAUTH_CLIENT_ID=Iv1.abc123
|
||||
ACKIFY_OAUTH_CLIENT_SECRET=ghp_xyz...
|
||||
```
|
||||
|
||||
See [OAuth Providers](configuration/oauth-providers.md) for GitLab and custom providers.
|
||||
|
||||
## Generating Secrets
|
||||
|
||||
```bash
|
||||
# Cookie secret (required)
|
||||
openssl rand -base64 32
|
||||
|
||||
# Ed25519 private key (optional, auto-generated if missing)
|
||||
openssl rand -base64 64
|
||||
```
|
||||
|
||||
## First Steps
|
||||
|
||||
### Create your first signature
|
||||
|
||||
1. Go to `http://localhost:8080/?doc=test_document`
|
||||
2. Click "Sign this document"
|
||||
3. Login via OAuth2
|
||||
4. Validate the signature
|
||||
|
||||
### Access the admin dashboard
|
||||
|
||||
1. Add your email in `.env`:
|
||||
```bash
|
||||
ACKIFY_ADMIN_EMAILS=admin@company.com
|
||||
```
|
||||
2. Restart:
|
||||
```bash
|
||||
docker compose restart ackify-ce
|
||||
```
|
||||
3. Login and access `/admin`
|
||||
|
||||
### Embed in a page
|
||||
|
||||
```html
|
||||
<!-- Embeddable widget -->
|
||||
<iframe src="https://sign.your-domain.com/?doc=test_document"
|
||||
width="600" height="200"
|
||||
frameborder="0"
|
||||
style="border: 1px solid #ddd; border-radius: 6px;"></iframe>
|
||||
```
|
||||
|
||||
## Useful Commands
|
||||
|
||||
```bash
|
||||
# View logs
|
||||
docker compose logs -f ackify-ce
|
||||
|
||||
# Restart
|
||||
docker compose restart ackify-ce
|
||||
|
||||
# Stop
|
||||
docker compose down
|
||||
|
||||
# Rebuild after changes
|
||||
docker compose up -d --force-recreate ackify-ce --build
|
||||
|
||||
# Access the database
|
||||
docker compose exec ackify-db psql -U ackifyr -d ackify
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Application doesn't start
|
||||
|
||||
```bash
|
||||
# Check logs
|
||||
docker compose logs ackify-ce
|
||||
|
||||
# Check PostgreSQL health
|
||||
docker compose ps ackify-db
|
||||
```
|
||||
|
||||
### Migration error
|
||||
|
||||
```bash
|
||||
# Manually re-run migrations
|
||||
docker compose up ackify-migrate
|
||||
```
|
||||
|
||||
### OAuth callback error
|
||||
|
||||
Verify that:
|
||||
- `ACKIFY_BASE_URL` exactly matches your domain
|
||||
- The callback URL in the OAuth2 provider is correct
|
||||
- The cookie secret is properly configured
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Complete configuration](configuration.md)
|
||||
- [Production deployment](deployment.md)
|
||||
- [Features configuration](features/)
|
||||
- [API Reference](api.md)
|
||||
39
docs/fr/README.md
Normal file
39
docs/fr/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Ackify Documentation
|
||||
|
||||
Documentation complète pour Ackify - Proof of Read avec signatures cryptographiques.
|
||||
|
||||
## Démarrage Rapide
|
||||
|
||||
- **[Getting Started](getting-started.md)** - Installation et premiers pas avec Docker Compose
|
||||
- **[Configuration](configuration.md)** - Variables d'environnement et paramétrage
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
- **[Signatures Cryptographiques](features/signatures.md)** - Flow de signature Ed25519
|
||||
- **[Expected Signers](features/expected-signers.md)** - Tracking et rappels email
|
||||
- **[Checksums](features/checksums.md)** - Vérification d'intégrité des documents
|
||||
- **[Embedding](features/embedding.md)** - oEmbed, iframes, intégrations tierces
|
||||
- **[Internationalisation](features/i18n.md)** - Support multilingue (fr, en, es, de, it)
|
||||
|
||||
## Configuration Avancée
|
||||
|
||||
- **[OAuth Providers](configuration/oauth-providers.md)** - Google, GitHub, GitLab, Custom
|
||||
- **[Email Setup](configuration/email-setup.md)** - Configuration SMTP pour les rappels
|
||||
|
||||
## Architecture & Développement
|
||||
|
||||
- **[Architecture](architecture.md)** - Stack technique, structure projet, principes Clean Architecture
|
||||
- **[Database](database.md)** - Schéma PostgreSQL, migrations, contraintes
|
||||
- **[API Reference](api.md)** - Endpoints REST, exemples, OpenAPI
|
||||
- **[Deployment](deployment.md)** - Production, sécurité, monitoring
|
||||
- **[Development](development.md)** - Setup développement, tests, contribution
|
||||
|
||||
## Intégrations
|
||||
|
||||
- **[Google Docs](integrations/google-doc/)** - Intégration avec Google Workspace
|
||||
- Plus d'intégrations à venir...
|
||||
|
||||
## Support
|
||||
|
||||
- [GitHub Issues](https://github.com/btouchard/ackify-ce/issues) - Bugs et demandes de fonctionnalités
|
||||
- [GitHub Discussions](https://github.com/btouchard/ackify-ce/discussions) - Questions et discussions
|
||||
355
docs/fr/architecture.md
Normal file
355
docs/fr/architecture.md
Normal file
@@ -0,0 +1,355 @@
|
||||
# Architecture
|
||||
|
||||
Stack technique et principes de conception d'Ackify.
|
||||
|
||||
## Vue d'Ensemble
|
||||
|
||||
Ackify est une **application monolithique moderne** avec séparation claire backend/frontend.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Client Browser │
|
||||
│ (Vue.js 3 SPA + TypeScript) │
|
||||
└──────────────┬──────────────────────────┘
|
||||
│ HTTPS / JSON
|
||||
┌──────────────▼──────────────────────────┐
|
||||
│ Go Backend (API-first) │
|
||||
│ ├─ RESTful API v1 (chi router) │
|
||||
│ ├─ OAuth2 Service │
|
||||
│ ├─ Ed25519 Crypto │
|
||||
│ └─ SMTP Email (optionnel) │
|
||||
└──────────────┬──────────────────────────┘
|
||||
│ PostgreSQL protocol
|
||||
┌──────────────▼──────────────────────────┐
|
||||
│ PostgreSQL 16 Database │
|
||||
│ (Signatures + Metadata + Sessions) │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Backend (Go)
|
||||
|
||||
### Clean Architecture Simplifiée
|
||||
|
||||
```
|
||||
backend/
|
||||
├── cmd/
|
||||
│ ├── community/ # Point d'entrée + injection dépendances
|
||||
│ └── migrate/ # Outil migrations SQL
|
||||
├── internal/
|
||||
│ ├── domain/
|
||||
│ │ └── models/ # Entités métier (User, Signature, Document)
|
||||
│ ├── application/
|
||||
│ │ └── services/ # Logique métier (SignatureService, etc.)
|
||||
│ ├── infrastructure/
|
||||
│ │ ├── auth/ # OAuth2 service
|
||||
│ │ ├── database/ # Repositories PostgreSQL
|
||||
│ │ ├── email/ # SMTP service
|
||||
│ │ ├── config/ # Variables d'environnement
|
||||
│ │ └── i18n/ # Backend i18n
|
||||
│ └── presentation/
|
||||
│ ├── api/ # HTTP handlers API v1
|
||||
│ └── handlers/ # Legacy OAuth handlers
|
||||
├── pkg/
|
||||
│ ├── crypto/ # Ed25519 signatures
|
||||
│ ├── logger/ # Structured logging
|
||||
│ ├── services/ # OAuth provider detection
|
||||
│ └── web/ # HTTP server setup
|
||||
├── migrations/ # SQL migrations
|
||||
├── templates/ # Email templates (HTML/text)
|
||||
└── locales/ # Backend translations
|
||||
```
|
||||
|
||||
### Principes Go Appliqués
|
||||
|
||||
**Interfaces** :
|
||||
- ✅ Définies dans le package qui les utilise
|
||||
- ✅ Principe "accept interfaces, return structs"
|
||||
- ✅ Repositories implémentés dans `infrastructure/database/`
|
||||
|
||||
**Injection de Dépendances** :
|
||||
- ✅ Constructeurs explicites dans `main.go`
|
||||
- ✅ Pas de container DI complexe
|
||||
- ✅ Dépendances claires et visibles
|
||||
|
||||
**Code Quality** :
|
||||
- ✅ `go fmt` et `go vet` clean
|
||||
- ✅ Pas de code mort
|
||||
- ✅ Interfaces simples et focalisées
|
||||
|
||||
## Frontend (Vue.js 3)
|
||||
|
||||
### Structure SPA
|
||||
|
||||
```
|
||||
webapp/
|
||||
├── src/
|
||||
│ ├── components/ # Composants réutilisables
|
||||
│ │ ├── ui/ # shadcn/vue components
|
||||
│ │ └── ...
|
||||
│ ├── pages/ # Pages (router views)
|
||||
│ │ ├── Home.vue
|
||||
│ │ ├── Admin.vue
|
||||
│ │ └── ...
|
||||
│ ├── services/ # API client (axios)
|
||||
│ ├── stores/ # Pinia state management
|
||||
│ ├── router/ # Vue Router config
|
||||
│ ├── locales/ # Traductions (fr, en, es, de, it)
|
||||
│ └── composables/ # Vue composables
|
||||
├── public/ # Assets statiques
|
||||
└── scripts/ # Build scripts
|
||||
```
|
||||
|
||||
### Stack Frontend
|
||||
|
||||
- **Vue 3** - Composition API
|
||||
- **TypeScript** - Type safety
|
||||
- **Vite** - Build tool (HMR rapide)
|
||||
- **Pinia** - State management
|
||||
- **Vue Router** - Client routing
|
||||
- **Tailwind CSS** - Utility-first styling
|
||||
- **shadcn/vue** - UI components
|
||||
- **vue-i18n** - Internationalisation
|
||||
|
||||
### Routing
|
||||
|
||||
```typescript
|
||||
const routes = [
|
||||
{ path: '/', component: Home }, // Public
|
||||
{ path: '/signatures', component: MySignatures }, // Auth required
|
||||
{ path: '/admin', component: Admin } // Admin only
|
||||
]
|
||||
```
|
||||
|
||||
Le frontend gère :
|
||||
- Route `/` avec query param `?doc=xxx` → Page de signature
|
||||
- Route `/admin` → Dashboard admin
|
||||
- Route `/signatures` → Mes signatures
|
||||
|
||||
## Base de Données
|
||||
|
||||
### Schéma PostgreSQL
|
||||
|
||||
Tables principales :
|
||||
- `signatures` - Signatures Ed25519
|
||||
- `documents` - Métadonnées documents
|
||||
- `expected_signers` - Tracking signataires
|
||||
- `reminder_logs` - Historique emails
|
||||
- `checksum_verifications` - Vérifications intégrité
|
||||
- `oauth_sessions` - Sessions OAuth2 + refresh tokens
|
||||
|
||||
Voir [Database](database.md) pour le schéma complet.
|
||||
|
||||
### Migrations
|
||||
|
||||
- Format : `XXXX_description.up.sql` / `XXXX_description.down.sql`
|
||||
- Appliquées automatiquement au démarrage (service `ackify-migrate`)
|
||||
- Outil : `/backend/cmd/migrate`
|
||||
|
||||
## Sécurité
|
||||
|
||||
### Cryptographie
|
||||
|
||||
**Ed25519** :
|
||||
- Signatures digitales (courbe elliptique)
|
||||
- Clé privée 256 bits
|
||||
- Non-répudiation garantie
|
||||
|
||||
**SHA-256** :
|
||||
- Hachage payload avant signature
|
||||
- Détection de tampering
|
||||
- Chaînage blockchain-like (`prev_hash`)
|
||||
|
||||
**AES-256-GCM** :
|
||||
- Chiffrement refresh tokens OAuth2
|
||||
- Clé dérivée de `ACKIFY_OAUTH_COOKIE_SECRET`
|
||||
|
||||
### OAuth2 + PKCE
|
||||
|
||||
**Flow** :
|
||||
1. Client génère `code_verifier` (random)
|
||||
2. Calcule `code_challenge = SHA256(code_verifier)`
|
||||
3. Auth request avec `code_challenge`
|
||||
4. Provider retourne `code`
|
||||
5. Token exchange avec `code + code_verifier`
|
||||
|
||||
**Sécurité** :
|
||||
- Protection contre interception du code
|
||||
- Méthode S256 (SHA-256)
|
||||
- Activé automatiquement
|
||||
|
||||
### Sessions
|
||||
|
||||
- Cookies sécurisés (HttpOnly, Secure, SameSite=Lax)
|
||||
- Chiffrement HMAC-SHA256
|
||||
- Stockage PostgreSQL avec refresh tokens chiffrés
|
||||
- Durée : 30 jours
|
||||
- Cleanup automatique : 37 jours
|
||||
|
||||
## Build & Déploiement
|
||||
|
||||
### Multi-Stage Docker
|
||||
|
||||
```dockerfile
|
||||
# Stage 1 - Frontend build
|
||||
FROM node:22-alpine AS frontend
|
||||
COPY webapp/ /build/webapp/
|
||||
RUN npm ci && npm run build
|
||||
# Output: webapp/dist/
|
||||
|
||||
# Stage 2 - Backend build + embed frontend
|
||||
FROM golang:alpine AS backend
|
||||
ENV GOTOOLCHAIN=auto
|
||||
COPY backend/ /build/backend/
|
||||
COPY --from=frontend /build/webapp/dist/ /build/backend/cmd/community/web/dist/
|
||||
RUN go build -o community ./cmd/community
|
||||
# Frontend embedded via embed.FS
|
||||
|
||||
# Stage 3 - Runtime (distroless)
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
COPY --from=backend /build/backend/community /app/community
|
||||
CMD ["/app/community"]
|
||||
```
|
||||
|
||||
**Résultat** :
|
||||
- Image finale < 30 MB
|
||||
- Binaire unique (backend + frontend)
|
||||
- Aucune dépendance runtime
|
||||
|
||||
### Injection Runtime
|
||||
|
||||
Le `ACKIFY_BASE_URL` est injecté dans `index.html` au démarrage :
|
||||
|
||||
```go
|
||||
// Remplace __ACKIFY_BASE_URL__ par la valeur réelle
|
||||
html = strings.ReplaceAll(html, "__ACKIFY_BASE_URL__", baseURL)
|
||||
```
|
||||
|
||||
Permet de changer le domaine sans rebuild.
|
||||
|
||||
## Performance
|
||||
|
||||
### Backend
|
||||
|
||||
- **Connection pooling** PostgreSQL (25 max)
|
||||
- **Prepared statements** - Anti-injection SQL
|
||||
- **Rate limiting** - 5 auth/min, 10 doc/min, 100 req/min
|
||||
- **Structured logging** - JSON avec request IDs
|
||||
|
||||
### Frontend
|
||||
|
||||
- **Code splitting** - Lazy loading routes
|
||||
- **Tree shaking** - Dead code elimination
|
||||
- **Minification** - Production builds optimisés
|
||||
- **HMR** - Hot Module Replacement (dev)
|
||||
|
||||
### Database
|
||||
|
||||
- **Index** sur (doc_id, user_sub, session_id)
|
||||
- **Constraints** UNIQUE pour garanties
|
||||
- **Triggers** pour immutabilité
|
||||
- **Autovacuum** activé
|
||||
|
||||
## Scalabilité
|
||||
|
||||
### Limites Actuelles
|
||||
|
||||
- ✅ Monolithe : ~10k req/s
|
||||
- ✅ PostgreSQL : Single instance
|
||||
- ✅ Sessions : In-database (pas de Redis)
|
||||
|
||||
### Scaling Horizontal (futur)
|
||||
|
||||
Pour > 100k req/s :
|
||||
1. **Load Balancer** - Multiple instances backend
|
||||
2. **PostgreSQL read replicas** - Séparation read/write
|
||||
3. **Redis** - Cache sessions + rate limiting
|
||||
4. **CDN** - Assets statiques
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Logs Structurés
|
||||
|
||||
Format JSON :
|
||||
```json
|
||||
{
|
||||
"level": "info",
|
||||
"timestamp": "2025-01-15T14:30:00Z",
|
||||
"request_id": "abc123",
|
||||
"method": "POST",
|
||||
"path": "/api/v1/signatures",
|
||||
"duration_ms": 42,
|
||||
"status": 201
|
||||
}
|
||||
```
|
||||
|
||||
### Health Check
|
||||
|
||||
```http
|
||||
GET /api/v1/health
|
||||
```
|
||||
|
||||
Response :
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"database": "connected"
|
||||
}
|
||||
```
|
||||
|
||||
### Métriques (futur)
|
||||
|
||||
- Prometheus metrics endpoint
|
||||
- Grafana dashboards
|
||||
- Alerting (PagerDuty, Slack)
|
||||
|
||||
## Tests
|
||||
|
||||
### Coverage
|
||||
|
||||
**72.6% code coverage** (unit + integration)
|
||||
|
||||
- Unit tests : 180+ tests
|
||||
- Integration tests : 33 tests PostgreSQL
|
||||
- CI/CD : GitHub Actions + Codecov
|
||||
|
||||
Voir [Development](development.md) pour lancer les tests.
|
||||
|
||||
## Choix Techniques
|
||||
|
||||
### Pourquoi Go ?
|
||||
|
||||
- ✅ Performance native (compiled)
|
||||
- ✅ Concurrency simple (goroutines)
|
||||
- ✅ Typage fort
|
||||
- ✅ Binaire unique
|
||||
- ✅ Déploiement simple
|
||||
|
||||
### Pourquoi Vue 3 ?
|
||||
|
||||
- ✅ Composition API moderne
|
||||
- ✅ TypeScript natif
|
||||
- ✅ Reactive par défaut
|
||||
- ✅ Ecosystème riche
|
||||
- ✅ Performances excellentes
|
||||
|
||||
### Pourquoi PostgreSQL ?
|
||||
|
||||
- ✅ ACID compliance
|
||||
- ✅ Contraintes d'intégrité
|
||||
- ✅ Triggers
|
||||
- ✅ JSON support
|
||||
- ✅ Mature et stable
|
||||
|
||||
### Pourquoi Ed25519 ?
|
||||
|
||||
- ✅ Sécurité moderne (courbe elliptique)
|
||||
- ✅ Performance > RSA
|
||||
- ✅ Signatures courtes (64 bytes)
|
||||
- ✅ Standard crypto/ed25519 Go
|
||||
|
||||
## Références
|
||||
|
||||
- [Go Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
|
||||
- [Vue 3 Composition API](https://vuejs.org/guide/extras/composition-api-faq.html)
|
||||
- [Ed25519 Spec](https://ed25519.cr.yp.to/)
|
||||
- [OAuth2 + PKCE](https://oauth.net/2/pkce/)
|
||||
184
docs/fr/configuration.md
Normal file
184
docs/fr/configuration.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# Configuration
|
||||
|
||||
Guide complet de configuration d'Ackify via variables d'environnement.
|
||||
|
||||
## Variables Obligatoires
|
||||
|
||||
Ces variables sont **requises** pour démarrer Ackify :
|
||||
|
||||
```bash
|
||||
# URL publique de votre instance (utilisée pour OAuth callbacks)
|
||||
APP_DNS=sign.your-domain.com
|
||||
ACKIFY_BASE_URL=https://sign.your-domain.com
|
||||
|
||||
# Nom de votre organisation (affiché dans l'interface)
|
||||
ACKIFY_ORGANISATION="Your Organization Name"
|
||||
|
||||
# Configuration PostgreSQL
|
||||
POSTGRES_USER=ackifyr
|
||||
POSTGRES_PASSWORD=your_secure_password
|
||||
POSTGRES_DB=ackify
|
||||
|
||||
# OAuth2 Provider
|
||||
ACKIFY_OAUTH_PROVIDER=google # ou github, gitlab, ou vide pour custom
|
||||
ACKIFY_OAUTH_CLIENT_ID=your_oauth_client_id
|
||||
ACKIFY_OAUTH_CLIENT_SECRET=your_oauth_client_secret
|
||||
|
||||
# Secret pour chiffrer les cookies de session (générer avec: openssl rand -base64 32)
|
||||
ACKIFY_OAUTH_COOKIE_SECRET=your_base64_encoded_secret_key
|
||||
```
|
||||
|
||||
## Variables Optionnelles
|
||||
|
||||
### Serveur
|
||||
|
||||
```bash
|
||||
# Adresse d'écoute HTTP (défaut: :8080)
|
||||
ACKIFY_LISTEN_ADDR=:8080
|
||||
|
||||
# Niveau de logs: debug, info, warn, error (défaut: info)
|
||||
ACKIFY_LOG_LEVEL=info
|
||||
```
|
||||
|
||||
### Sécurité & OAuth2
|
||||
|
||||
```bash
|
||||
# Restreindre l'accès à un domaine email spécifique
|
||||
ACKIFY_OAUTH_ALLOWED_DOMAIN=@company.com
|
||||
|
||||
# Activer l'auto-login silencieux (défaut: false)
|
||||
ACKIFY_OAUTH_AUTO_LOGIN=false
|
||||
|
||||
# URL de logout personnalisée (optionnel)
|
||||
ACKIFY_OAUTH_LOGOUT_URL=https://your-provider.com/logout
|
||||
|
||||
# Scopes OAuth2 personnalisés (défaut: openid,email,profile)
|
||||
ACKIFY_OAUTH_SCOPES=openid,email,profile
|
||||
```
|
||||
|
||||
### Administration
|
||||
|
||||
```bash
|
||||
# Liste d'emails admin (séparés par virgules)
|
||||
ACKIFY_ADMIN_EMAILS=admin@company.com,admin2@company.com
|
||||
```
|
||||
|
||||
Les admins ont accès à :
|
||||
- Dashboard admin (`/admin`)
|
||||
- Gestion des métadonnées documents
|
||||
- Tracking des signataires attendus
|
||||
- Envoi de rappels email
|
||||
- Suppression de documents
|
||||
|
||||
### Checksums Documents (Optionnel)
|
||||
|
||||
Configuration pour le calcul automatique de checksum lors de la création de documents depuis des URLs :
|
||||
|
||||
```bash
|
||||
# Taille maximale de fichier à télécharger pour le calcul de checksum (défaut: 10485760 = 10MB)
|
||||
ACKIFY_CHECKSUM_MAX_BYTES=10485760
|
||||
|
||||
# Timeout pour le téléchargement checksum en millisecondes (défaut: 5000ms = 5s)
|
||||
ACKIFY_CHECKSUM_TIMEOUT_MS=5000
|
||||
|
||||
# Nombre maximum de redirections HTTP à suivre (défaut: 3)
|
||||
ACKIFY_CHECKSUM_MAX_REDIRECTS=3
|
||||
|
||||
# Liste de types MIME autorisés séparés par virgules (défaut inclut PDF, images, docs Office, ODF)
|
||||
ACKIFY_CHECKSUM_ALLOWED_TYPES=application/pdf,image/*,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.oasis.opendocument.*
|
||||
```
|
||||
|
||||
**Note** : Ces paramètres s'appliquent uniquement lorsque les admins créent des documents via le dashboard admin avec une URL distante. Le système tentera de télécharger et calculer le checksum SHA-256 automatiquement.
|
||||
|
||||
## Configuration Avancée
|
||||
|
||||
### OAuth2 Providers
|
||||
|
||||
Voir [OAuth Providers](configuration/oauth-providers.md) pour la configuration détaillée de :
|
||||
- Google OAuth2
|
||||
- GitHub OAuth2
|
||||
- GitLab OAuth2 (public + self-hosted)
|
||||
- Custom OAuth2 provider
|
||||
|
||||
### Email (SMTP)
|
||||
|
||||
Voir [Email Setup](configuration/email-setup.md) pour configurer l'envoi de rappels email.
|
||||
|
||||
## Exemple Complet
|
||||
|
||||
Exemple de `.env` pour une installation en production :
|
||||
|
||||
```bash
|
||||
# Application
|
||||
APP_DNS=sign.company.com
|
||||
ACKIFY_BASE_URL=https://sign.company.com
|
||||
ACKIFY_ORGANISATION="ACME Corporation"
|
||||
ACKIFY_LOG_LEVEL=info
|
||||
ACKIFY_LISTEN_ADDR=:8080
|
||||
|
||||
# Base de données
|
||||
POSTGRES_USER=ackifyr
|
||||
POSTGRES_PASSWORD=super_secure_password_123
|
||||
POSTGRES_DB=ackify
|
||||
|
||||
# OAuth2 (Google)
|
||||
ACKIFY_OAUTH_PROVIDER=google
|
||||
ACKIFY_OAUTH_CLIENT_ID=123456789-abc.apps.googleusercontent.com
|
||||
ACKIFY_OAUTH_CLIENT_SECRET=GOCSPX-xyz123
|
||||
ACKIFY_OAUTH_ALLOWED_DOMAIN=@company.com
|
||||
|
||||
# Sécurité
|
||||
ACKIFY_OAUTH_COOKIE_SECRET=ZXhhbXBsZV9iYXNlNjRfc2VjcmV0X2tleQ==
|
||||
|
||||
# Administration
|
||||
ACKIFY_ADMIN_EMAILS=admin@company.com,cto@company.com
|
||||
|
||||
# Email (optionnel - omettre MAIL_HOST pour désactiver)
|
||||
ACKIFY_MAIL_HOST=smtp.gmail.com
|
||||
ACKIFY_MAIL_PORT=587
|
||||
ACKIFY_MAIL_USERNAME=noreply@company.com
|
||||
ACKIFY_MAIL_PASSWORD=app_specific_password
|
||||
ACKIFY_MAIL_FROM=noreply@company.com
|
||||
ACKIFY_MAIL_FROM_NAME="Ackify - ACME"
|
||||
ACKIFY_MAIL_TEMPLATE_DIR=templates/emails
|
||||
ACKIFY_MAIL_DEFAULT_LOCALE=fr
|
||||
|
||||
# Checksums Documents (optionnel - pour auto-checksum depuis URLs)
|
||||
ACKIFY_CHECKSUM_MAX_BYTES=10485760
|
||||
ACKIFY_CHECKSUM_TIMEOUT_MS=5000
|
||||
ACKIFY_CHECKSUM_MAX_REDIRECTS=3
|
||||
```
|
||||
|
||||
## Validation de la Configuration
|
||||
|
||||
Après modification du `.env`, redémarrer :
|
||||
|
||||
```bash
|
||||
docker compose restart ackify-ce
|
||||
```
|
||||
|
||||
Vérifier les logs :
|
||||
|
||||
```bash
|
||||
docker compose logs -f ackify-ce
|
||||
```
|
||||
|
||||
Tester le health check :
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/api/v1/health
|
||||
```
|
||||
|
||||
## Variables de Production
|
||||
|
||||
**Checklist sécurité production** :
|
||||
|
||||
- ✅ Utiliser HTTPS (`ACKIFY_BASE_URL=https://...`)
|
||||
- ✅ Générer des secrets forts (64+ caractères)
|
||||
- ✅ Restreindre le domaine OAuth (`ACKIFY_OAUTH_ALLOWED_DOMAIN`)
|
||||
- ✅ Configurer les emails admin (`ACKIFY_ADMIN_EMAILS`)
|
||||
- ✅ Utiliser PostgreSQL avec SSL en production
|
||||
- ✅ Logger en mode `info` (pas `debug`)
|
||||
- ✅ Sauvegarder régulièrement la base de données
|
||||
|
||||
Voir [Deployment](deployment.md) pour plus de détails sur le déploiement en production.
|
||||
353
docs/fr/configuration/email-setup.md
Normal file
353
docs/fr/configuration/email-setup.md
Normal file
@@ -0,0 +1,353 @@
|
||||
# Email Setup
|
||||
|
||||
Configuration SMTP pour l'envoi de rappels email aux signataires attendus.
|
||||
|
||||
## Vue d'Ensemble
|
||||
|
||||
Le service email d'Ackify permet d'envoyer des rappels automatiques aux utilisateurs qui n'ont pas encore signé un document.
|
||||
|
||||
**Fonctionnalités** :
|
||||
- Envoi de rappels multilingues (fr, en, es, de, it)
|
||||
- Templates HTML et texte brut
|
||||
- Historique des envois dans PostgreSQL
|
||||
- Support TLS/STARTTLS
|
||||
- Timeout configurable
|
||||
|
||||
**Note** : Le service email est **optionnel**. Si `ACKIFY_MAIL_HOST` n'est pas défini, les emails sont désactivés.
|
||||
|
||||
## Configuration de Base
|
||||
|
||||
### Variables Obligatoires
|
||||
|
||||
```bash
|
||||
# Serveur SMTP
|
||||
ACKIFY_MAIL_HOST=smtp.gmail.com
|
||||
ACKIFY_MAIL_PORT=587
|
||||
ACKIFY_MAIL_USERNAME=your-email@gmail.com
|
||||
ACKIFY_MAIL_PASSWORD=your-app-password
|
||||
|
||||
# Adresse expéditeur
|
||||
ACKIFY_MAIL_FROM=noreply@company.com
|
||||
```
|
||||
|
||||
### Variables Optionnelles
|
||||
|
||||
```bash
|
||||
# Nom affiché de l'expéditeur (défaut: ACKIFY_ORGANISATION)
|
||||
ACKIFY_MAIL_FROM_NAME="Ackify - ACME Corporation"
|
||||
|
||||
# Préfixe pour le sujet des emails (optionnel)
|
||||
ACKIFY_MAIL_SUBJECT_PREFIX="[Ackify]"
|
||||
|
||||
# Activer TLS (défaut: true)
|
||||
ACKIFY_MAIL_TLS=true
|
||||
|
||||
# Activer STARTTLS (défaut: true)
|
||||
ACKIFY_MAIL_STARTTLS=true
|
||||
|
||||
# Timeout de connexion (défaut: 10s)
|
||||
ACKIFY_MAIL_TIMEOUT=10s
|
||||
|
||||
# Répertoire des templates email (défaut: templates/emails)
|
||||
ACKIFY_MAIL_TEMPLATE_DIR=templates/emails
|
||||
|
||||
# Langue par défaut pour les emails (défaut: en)
|
||||
# Langues supportées : en, fr, es, de, it
|
||||
ACKIFY_MAIL_DEFAULT_LOCALE=fr
|
||||
```
|
||||
|
||||
## Providers SMTP Populaires
|
||||
|
||||
### Gmail
|
||||
|
||||
**Configuration** :
|
||||
```bash
|
||||
ACKIFY_MAIL_HOST=smtp.gmail.com
|
||||
ACKIFY_MAIL_PORT=587
|
||||
ACKIFY_MAIL_USERNAME=your-email@gmail.com
|
||||
ACKIFY_MAIL_PASSWORD=your-app-password
|
||||
ACKIFY_MAIL_TLS=true
|
||||
ACKIFY_MAIL_STARTTLS=true
|
||||
```
|
||||
|
||||
**Prérequis** :
|
||||
1. Activer la validation en 2 étapes sur votre compte Google
|
||||
2. Générer un "App Password" : https://myaccount.google.com/apppasswords
|
||||
3. Utiliser ce mot de passe dans `ACKIFY_MAIL_PASSWORD`
|
||||
|
||||
### SendGrid
|
||||
|
||||
```bash
|
||||
ACKIFY_MAIL_HOST=smtp.sendgrid.net
|
||||
ACKIFY_MAIL_PORT=587
|
||||
ACKIFY_MAIL_USERNAME=apikey
|
||||
ACKIFY_MAIL_PASSWORD=your-sendgrid-api-key
|
||||
ACKIFY_MAIL_FROM=noreply@your-domain.com
|
||||
ACKIFY_MAIL_TLS=true
|
||||
```
|
||||
|
||||
### Amazon SES
|
||||
|
||||
```bash
|
||||
ACKIFY_MAIL_HOST=email-smtp.us-east-1.amazonaws.com
|
||||
ACKIFY_MAIL_PORT=587
|
||||
ACKIFY_MAIL_USERNAME=your-smtp-username
|
||||
ACKIFY_MAIL_PASSWORD=your-smtp-password
|
||||
ACKIFY_MAIL_FROM=noreply@verified-domain.com
|
||||
ACKIFY_MAIL_TLS=true
|
||||
```
|
||||
|
||||
**Important** : Vérifier votre domaine dans AWS SES avant d'envoyer.
|
||||
|
||||
### Mailgun
|
||||
|
||||
```bash
|
||||
ACKIFY_MAIL_HOST=smtp.mailgun.org
|
||||
ACKIFY_MAIL_PORT=587
|
||||
ACKIFY_MAIL_USERNAME=postmaster@your-domain.mailgun.org
|
||||
ACKIFY_MAIL_PASSWORD=your-mailgun-smtp-password
|
||||
ACKIFY_MAIL_FROM=noreply@your-domain.com
|
||||
ACKIFY_MAIL_TLS=true
|
||||
```
|
||||
|
||||
### SMTP Custom (Self-hosted)
|
||||
|
||||
```bash
|
||||
ACKIFY_MAIL_HOST=mail.company.com
|
||||
ACKIFY_MAIL_PORT=587
|
||||
ACKIFY_MAIL_USERNAME=ackify@company.com
|
||||
ACKIFY_MAIL_PASSWORD=secure_password
|
||||
ACKIFY_MAIL_FROM=ackify@company.com
|
||||
ACKIFY_MAIL_TLS=true
|
||||
ACKIFY_MAIL_STARTTLS=true
|
||||
```
|
||||
|
||||
## Templates Email
|
||||
|
||||
Les templates sont dans `/backend/templates/emails/` avec support multilingue.
|
||||
|
||||
### Structure
|
||||
|
||||
```
|
||||
templates/emails/
|
||||
├── fr/
|
||||
│ ├── reminder.html # Template HTML français
|
||||
│ └── reminder.txt # Template texte brut français
|
||||
├── en/
|
||||
│ ├── reminder.html # Template HTML anglais
|
||||
│ └── reminder.txt # Template texte brut anglais
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Variables Disponibles
|
||||
|
||||
Dans les templates, vous pouvez utiliser :
|
||||
|
||||
```go
|
||||
{{.RecipientName}} // Nom du destinataire
|
||||
{{.DocumentID}} // ID du document
|
||||
{{.DocumentTitle}} // Titre du document
|
||||
{{.DocumentURL}} // URL du document (si définie dans metadata)
|
||||
{{.SignURL}} // URL pour signer
|
||||
{{.OrganisationName}} // Nom de l'organisation
|
||||
{{.SenderName}} // Nom de l'expéditeur (admin)
|
||||
```
|
||||
|
||||
### Exemple de Template HTML
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Rappel de signature</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Bonjour {{.RecipientName}},</h1>
|
||||
<p>
|
||||
Vous êtes attendu(e) pour signer le document
|
||||
<strong>{{.DocumentTitle}}</strong>.
|
||||
</p>
|
||||
<p>
|
||||
<a href="{{.SignURL}}" style="background: #0066cc; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">
|
||||
Signer maintenant
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
Cordialement,<br>
|
||||
{{.OrganisationName}}
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### Personnaliser les Templates
|
||||
|
||||
Pour utiliser des templates personnalisés :
|
||||
|
||||
```bash
|
||||
ACKIFY_MAIL_TEMPLATE_DIR=/custom/path/to/email/templates
|
||||
```
|
||||
|
||||
Assurez-vous de conserver la même structure de répertoires (langue/reminder.html).
|
||||
|
||||
## Envoi de Rappels
|
||||
|
||||
### Via le Dashboard Admin
|
||||
|
||||
1. Aller sur `/admin`
|
||||
2. Sélectionner un document
|
||||
3. Cliquer sur "Expected Signers"
|
||||
4. Sélectionner les destinataires
|
||||
5. Cliquer sur "Send Reminders"
|
||||
|
||||
### Via l'API
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/admin/documents/doc_id/reminders \
|
||||
-b cookies.txt \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-CSRF-Token: YOUR_TOKEN" \
|
||||
-d '{
|
||||
"emails": ["user1@company.com", "user2@company.com"],
|
||||
"locale": "fr"
|
||||
}'
|
||||
```
|
||||
|
||||
**Réponse** :
|
||||
```json
|
||||
{
|
||||
"sent": 2,
|
||||
"failed": 0,
|
||||
"errors": []
|
||||
}
|
||||
```
|
||||
|
||||
## Historique des Rappels
|
||||
|
||||
Les envois sont tracés dans la table `reminder_logs` :
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
recipient_email,
|
||||
sent_at,
|
||||
status,
|
||||
error_message
|
||||
FROM reminder_logs
|
||||
WHERE doc_id = 'my_document'
|
||||
ORDER BY sent_at DESC;
|
||||
```
|
||||
|
||||
**Statuts possibles** :
|
||||
- `sent` - Envoyé avec succès
|
||||
- `failed` - Échec d'envoi
|
||||
- `bounced` - Rebond (email invalide)
|
||||
|
||||
## Tester la Configuration
|
||||
|
||||
### Test Manuel via API
|
||||
|
||||
```bash
|
||||
# 1. Se connecter en tant qu'admin
|
||||
# 2. Ajouter un expected signer avec votre email
|
||||
curl -X POST http://localhost:8080/api/v1/admin/documents/test_doc/signers \
|
||||
-b cookies.txt \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-CSRF-Token: YOUR_TOKEN" \
|
||||
-d '{
|
||||
"email": "your-email@company.com",
|
||||
"name": "Test User"
|
||||
}'
|
||||
|
||||
# 3. Envoyer un rappel test
|
||||
curl -X POST http://localhost:8080/api/v1/admin/documents/test_doc/reminders \
|
||||
-b cookies.txt \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-CSRF-Token: YOUR_TOKEN" \
|
||||
-d '{
|
||||
"emails": ["your-email@company.com"],
|
||||
"locale": "en"
|
||||
}'
|
||||
```
|
||||
|
||||
### Vérifier les Logs
|
||||
|
||||
```bash
|
||||
docker compose logs -f ackify-ce | grep -i mail
|
||||
```
|
||||
|
||||
Vous devriez voir :
|
||||
```
|
||||
INFO Email sent successfully to: your-email@company.com
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Erreur "SMTP connection failed"
|
||||
|
||||
Vérifier :
|
||||
- `ACKIFY_MAIL_HOST` et `ACKIFY_MAIL_PORT` sont corrects
|
||||
- Votre serveur autorise les connexions sortantes sur le port SMTP
|
||||
- `ACKIFY_MAIL_TLS=true` si le serveur requiert TLS
|
||||
|
||||
### Erreur "Authentication failed"
|
||||
|
||||
Vérifier :
|
||||
- `ACKIFY_MAIL_USERNAME` et `ACKIFY_MAIL_PASSWORD` sont corrects
|
||||
- Pour Gmail : utiliser un "App Password", pas votre mot de passe principal
|
||||
- Pour SendGrid : le username doit être `apikey`
|
||||
|
||||
### Email non reçu mais status "sent"
|
||||
|
||||
Vérifier :
|
||||
- Dossier spam/courrier indésirable
|
||||
- SPF/DKIM/DMARC de votre domaine (pour éviter les filtres anti-spam)
|
||||
- L'adresse `ACKIFY_MAIL_FROM` est vérifiée chez votre provider
|
||||
|
||||
### Template non trouvé
|
||||
|
||||
Vérifier :
|
||||
- `ACKIFY_MAIL_TEMPLATE_DIR` pointe vers le bon répertoire
|
||||
- La structure `{locale}/reminder.html` existe
|
||||
- Les fichiers ont les bonnes permissions (readable)
|
||||
|
||||
### Timeout lors de l'envoi
|
||||
|
||||
Augmenter le timeout :
|
||||
```bash
|
||||
ACKIFY_MAIL_TIMEOUT=30s
|
||||
```
|
||||
|
||||
## Bonnes Pratiques
|
||||
|
||||
### Production
|
||||
|
||||
- ✅ Utiliser un service SMTP dédié (SendGrid, Mailgun, SES)
|
||||
- ✅ Vérifier votre domaine (SPF, DKIM, DMARC)
|
||||
- ✅ Utiliser une adresse `noreply@` pour `ACKIFY_MAIL_FROM`
|
||||
- ✅ Monitorer les `reminder_logs` pour détecter les échecs
|
||||
- ✅ Tester régulièrement l'envoi d'emails
|
||||
|
||||
### Sécurité
|
||||
|
||||
- ✅ Ne jamais commiter `ACKIFY_MAIL_PASSWORD` dans git
|
||||
- ✅ Utiliser des secrets Docker ou variables d'environnement
|
||||
- ✅ Restreindre les permissions du compte SMTP
|
||||
- ✅ Activer TLS/STARTTLS en production
|
||||
|
||||
### Performance
|
||||
|
||||
- Les emails sont envoyés **synchrones** lors de l'appel API
|
||||
- Pour de gros volumes, envisager une queue asynchrone
|
||||
- Limiter le nombre de destinataires par batch (recommandé : < 100)
|
||||
|
||||
## Désactiver les Emails
|
||||
|
||||
Pour désactiver complètement le service email :
|
||||
|
||||
```bash
|
||||
# Supprimer ou commenter ACKIFY_MAIL_HOST
|
||||
# ACKIFY_MAIL_HOST=
|
||||
```
|
||||
|
||||
Le dashboard admin n'affichera plus les options d'envoi de rappels.
|
||||
254
docs/fr/configuration/oauth-providers.md
Normal file
254
docs/fr/configuration/oauth-providers.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# OAuth2 Providers
|
||||
|
||||
Configuration détaillée des différents providers OAuth2 supportés par Ackify.
|
||||
|
||||
## Providers Supportés
|
||||
|
||||
| Provider | Configuration | Auto-détection |
|
||||
|----------|--------------|----------------|
|
||||
| Google | `ACKIFY_OAUTH_PROVIDER=google` | ✅ |
|
||||
| GitHub | `ACKIFY_OAUTH_PROVIDER=github` | ✅ |
|
||||
| GitLab | `ACKIFY_OAUTH_PROVIDER=gitlab` | ✅ |
|
||||
| Custom | Laisser vide + URLs manuelles | ❌ |
|
||||
|
||||
## Google OAuth2
|
||||
|
||||
### Étapes de configuration
|
||||
|
||||
1. Aller sur [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. Créer un projet ou sélectionner un existant
|
||||
3. Activer "Google+ API" (pour récupérer le profil utilisateur)
|
||||
4. Créer des credentials OAuth 2.0 :
|
||||
- **Application type** : Web application
|
||||
- **Authorized JavaScript origins** : `https://sign.your-domain.com`
|
||||
- **Authorized redirect URIs** : `https://sign.your-domain.com/api/v1/auth/callback`
|
||||
|
||||
### Configuration `.env`
|
||||
|
||||
```bash
|
||||
ACKIFY_OAUTH_PROVIDER=google
|
||||
ACKIFY_OAUTH_CLIENT_ID=123456789-abc.apps.googleusercontent.com
|
||||
ACKIFY_OAUTH_CLIENT_SECRET=GOCSPX-xyz123abc
|
||||
|
||||
# Optionnel : restreindre aux emails @company.com
|
||||
ACKIFY_OAUTH_ALLOWED_DOMAIN=@company.com
|
||||
```
|
||||
|
||||
### Scopes automatiques
|
||||
|
||||
Par défaut, Ackify demande :
|
||||
- `openid` - Identité OAuth2
|
||||
- `email` - Adresse email
|
||||
- `profile` - Nom complet
|
||||
|
||||
## GitHub OAuth2
|
||||
|
||||
### Étapes de configuration
|
||||
|
||||
1. Aller sur [GitHub Developer Settings](https://github.com/settings/developers)
|
||||
2. Cliquer sur "New OAuth App"
|
||||
3. Remplir le formulaire :
|
||||
- **Application name** : Ackify
|
||||
- **Homepage URL** : `https://sign.your-domain.com`
|
||||
- **Authorization callback URL** : `https://sign.your-domain.com/api/v1/auth/callback`
|
||||
4. Générer un client secret
|
||||
|
||||
### Configuration `.env`
|
||||
|
||||
```bash
|
||||
ACKIFY_OAUTH_PROVIDER=github
|
||||
ACKIFY_OAUTH_CLIENT_ID=Iv1.abc123xyz
|
||||
ACKIFY_OAUTH_CLIENT_SECRET=ghp_1234567890abcdef
|
||||
|
||||
# Optionnel : restreindre aux emails vérifiés d'une organisation
|
||||
ACKIFY_OAUTH_ALLOWED_DOMAIN=@company.com
|
||||
```
|
||||
|
||||
### Scopes automatiques
|
||||
|
||||
Par défaut :
|
||||
- `read:user` - Lecture du profil
|
||||
- `user:email` - Accès aux emails
|
||||
|
||||
## GitLab OAuth2
|
||||
|
||||
### GitLab.com (public)
|
||||
|
||||
1. Aller sur [GitLab Applications](https://gitlab.com/-/profile/applications)
|
||||
2. Créer une nouvelle application :
|
||||
- **Name** : Ackify
|
||||
- **Redirect URI** : `https://sign.your-domain.com/api/v1/auth/callback`
|
||||
- **Scopes** : `openid`, `email`, `profile`
|
||||
3. Copier l'Application ID et le Secret
|
||||
|
||||
```bash
|
||||
ACKIFY_OAUTH_PROVIDER=gitlab
|
||||
ACKIFY_OAUTH_CLIENT_ID=abc123xyz
|
||||
ACKIFY_OAUTH_CLIENT_SECRET=glpat-xyz123
|
||||
```
|
||||
|
||||
### GitLab Self-Hosted
|
||||
|
||||
Pour une instance GitLab privée :
|
||||
|
||||
```bash
|
||||
ACKIFY_OAUTH_PROVIDER=gitlab
|
||||
ACKIFY_OAUTH_GITLAB_URL=https://gitlab.company.com
|
||||
ACKIFY_OAUTH_CLIENT_ID=abc123xyz
|
||||
ACKIFY_OAUTH_CLIENT_SECRET=glpat-xyz123
|
||||
```
|
||||
|
||||
**Important** : `ACKIFY_OAUTH_GITLAB_URL` doit pointer vers votre instance GitLab sans trailing slash.
|
||||
|
||||
## Custom OAuth2 Provider
|
||||
|
||||
Pour utiliser un provider OAuth2 non standard (Keycloak, Okta, Auth0, etc.).
|
||||
|
||||
### Configuration complète
|
||||
|
||||
```bash
|
||||
# Ne pas définir ACKIFY_OAUTH_PROVIDER (ou laisser vide)
|
||||
ACKIFY_OAUTH_PROVIDER=
|
||||
|
||||
# URLs manuelles
|
||||
ACKIFY_OAUTH_AUTH_URL=https://auth.company.com/oauth/authorize
|
||||
ACKIFY_OAUTH_TOKEN_URL=https://auth.company.com/oauth/token
|
||||
ACKIFY_OAUTH_USERINFO_URL=https://auth.company.com/api/user
|
||||
|
||||
# Scopes personnalisés (optionnel)
|
||||
ACKIFY_OAUTH_SCOPES=openid,email,profile
|
||||
|
||||
# URL de logout personnalisée (optionnel)
|
||||
ACKIFY_OAUTH_LOGOUT_URL=https://auth.company.com/logout
|
||||
|
||||
# Credentials
|
||||
ACKIFY_OAUTH_CLIENT_ID=your_client_id
|
||||
ACKIFY_OAUTH_CLIENT_SECRET=your_client_secret
|
||||
```
|
||||
|
||||
### Exemple avec Keycloak
|
||||
|
||||
```bash
|
||||
ACKIFY_OAUTH_PROVIDER=
|
||||
ACKIFY_OAUTH_AUTH_URL=https://keycloak.company.com/realms/myrealm/protocol/openid-connect/auth
|
||||
ACKIFY_OAUTH_TOKEN_URL=https://keycloak.company.com/realms/myrealm/protocol/openid-connect/token
|
||||
ACKIFY_OAUTH_USERINFO_URL=https://keycloak.company.com/realms/myrealm/protocol/openid-connect/userinfo
|
||||
ACKIFY_OAUTH_LOGOUT_URL=https://keycloak.company.com/realms/myrealm/protocol/openid-connect/logout
|
||||
ACKIFY_OAUTH_SCOPES=openid,email,profile
|
||||
ACKIFY_OAUTH_CLIENT_ID=ackify-client
|
||||
ACKIFY_OAUTH_CLIENT_SECRET=secret123
|
||||
```
|
||||
|
||||
### Exemple avec Okta
|
||||
|
||||
```bash
|
||||
ACKIFY_OAUTH_PROVIDER=
|
||||
ACKIFY_OAUTH_AUTH_URL=https://dev-123456.okta.com/oauth2/default/v1/authorize
|
||||
ACKIFY_OAUTH_TOKEN_URL=https://dev-123456.okta.com/oauth2/default/v1/token
|
||||
ACKIFY_OAUTH_USERINFO_URL=https://dev-123456.okta.com/oauth2/default/v1/userinfo
|
||||
ACKIFY_OAUTH_SCOPES=openid,email,profile
|
||||
ACKIFY_OAUTH_CLIENT_ID=0oa123xyz
|
||||
ACKIFY_OAUTH_CLIENT_SECRET=secret123
|
||||
```
|
||||
|
||||
## Restriction de Domaine
|
||||
|
||||
Pour **tous les providers**, vous pouvez restreindre l'accès aux emails d'un domaine spécifique :
|
||||
|
||||
```bash
|
||||
# Accepter uniquement les emails @company.com
|
||||
ACKIFY_OAUTH_ALLOWED_DOMAIN=@company.com
|
||||
```
|
||||
|
||||
**Comportement** :
|
||||
- Les utilisateurs avec un email différent verront une erreur lors de la connexion
|
||||
- La vérification est case-insensitive
|
||||
- Fonctionne avec tous les providers (Google, GitHub, GitLab, custom)
|
||||
|
||||
## Auto-Login
|
||||
|
||||
Activer l'auto-login silencieux pour une meilleure UX :
|
||||
|
||||
```bash
|
||||
ACKIFY_OAUTH_AUTO_LOGIN=true
|
||||
```
|
||||
|
||||
**Fonctionnement** :
|
||||
- Si l'utilisateur a déjà une session OAuth active, redirection automatique
|
||||
- Pas de clic requis sur "Sign in"
|
||||
- Utile pour les intégrations corporate (Google Workspace, Microsoft 365)
|
||||
|
||||
**Attention** : Peut créer des redirections infinies si mal configuré.
|
||||
|
||||
## Sécurité OAuth2
|
||||
|
||||
### PKCE (Proof Key for Code Exchange)
|
||||
|
||||
Ackify implémente **automatiquement** PKCE pour tous les providers :
|
||||
- Protection contre l'interception du code d'autorisation
|
||||
- Méthode : S256 (SHA-256)
|
||||
- Activé par défaut, aucune configuration requise
|
||||
|
||||
### Refresh Tokens
|
||||
|
||||
Les refresh tokens sont :
|
||||
- Stockés **chiffrés** dans PostgreSQL (AES-256-GCM)
|
||||
- Utilisés pour maintenir les sessions 30 jours
|
||||
- Automatiquement nettoyés après expiration (37 jours)
|
||||
- Protégés par IP + User-Agent tracking
|
||||
|
||||
### Sessions Sécurisées
|
||||
|
||||
```bash
|
||||
# Secret fort requis (minimum 32 bytes en base64)
|
||||
ACKIFY_OAUTH_COOKIE_SECRET=$(openssl rand -base64 32)
|
||||
```
|
||||
|
||||
Les cookies de session utilisent :
|
||||
- HMAC-SHA256 pour l'intégrité
|
||||
- Chiffrement AES-256-GCM
|
||||
- Flags `Secure` (HTTPS uniquement) et `HttpOnly`
|
||||
- `SameSite=Lax` pour protection CSRF
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Erreur "invalid redirect_uri"
|
||||
|
||||
Vérifier que :
|
||||
- `ACKIFY_BASE_URL` correspond exactement à votre domaine
|
||||
- La callback URL dans le provider inclut `/api/v1/auth/callback`
|
||||
- Pas de trailing slash dans `ACKIFY_BASE_URL`
|
||||
|
||||
### Erreur "unauthorized_client"
|
||||
|
||||
Vérifier :
|
||||
- Le `client_id` et `client_secret` sont corrects
|
||||
- L'application OAuth est bien activée côté provider
|
||||
- Les scopes demandés sont autorisés
|
||||
|
||||
### Erreur "access_denied"
|
||||
|
||||
L'utilisateur a refusé l'autorisation, ou :
|
||||
- Son email ne correspond pas à `ACKIFY_OAUTH_ALLOWED_DOMAIN`
|
||||
- L'application n'a pas les permissions requises
|
||||
|
||||
### Custom provider ne fonctionne pas
|
||||
|
||||
Vérifier :
|
||||
- `ACKIFY_OAUTH_PROVIDER` est **vide** ou non défini
|
||||
- Les 3 URLs (auth, token, userinfo) sont complètes et correctes
|
||||
- La réponse de `/userinfo` contient bien `sub`, `email`, `name`
|
||||
|
||||
## Tester la Configuration
|
||||
|
||||
```bash
|
||||
# Redémarrer après modification
|
||||
docker compose restart ackify-ce
|
||||
|
||||
# Tester la connexion OAuth
|
||||
curl -X POST http://localhost:8080/api/v1/auth/start \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"redirect_to": "/"}'
|
||||
|
||||
# Devrait retourner une redirect_url vers le provider OAuth
|
||||
```
|
||||
604
docs/fr/database.md
Normal file
604
docs/fr/database.md
Normal file
@@ -0,0 +1,604 @@
|
||||
# Database
|
||||
|
||||
Schéma PostgreSQL, migrations, et garanties d'intégrité.
|
||||
|
||||
## Vue d'Ensemble
|
||||
|
||||
Ackify utilise **PostgreSQL 16+** avec :
|
||||
- Migrations versionnées SQL
|
||||
- Contraintes d'intégrité strictes
|
||||
- Triggers pour immutabilité
|
||||
- Index pour performances
|
||||
|
||||
## Schéma Principal
|
||||
|
||||
### Table `signatures`
|
||||
|
||||
Stocke les signatures cryptographiques Ed25519.
|
||||
|
||||
```sql
|
||||
CREATE TABLE signatures (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
doc_id TEXT NOT NULL,
|
||||
user_sub TEXT NOT NULL, -- OAuth user ID (sub claim)
|
||||
user_email TEXT NOT NULL,
|
||||
user_name TEXT, -- Nom utilisateur (optionnel)
|
||||
signed_at TIMESTAMPTZ NOT NULL,
|
||||
payload_hash TEXT NOT NULL, -- SHA-256 du payload
|
||||
signature TEXT NOT NULL, -- Signature Ed25519 (base64)
|
||||
nonce TEXT NOT NULL, -- Anti-replay attack
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
referer TEXT, -- Source (optionnel)
|
||||
prev_hash TEXT, -- Hash de la signature précédente (chaînage)
|
||||
UNIQUE (doc_id, user_sub) -- UNE signature par user/document
|
||||
);
|
||||
|
||||
CREATE INDEX idx_signatures_doc_id ON signatures(doc_id);
|
||||
CREATE INDEX idx_signatures_user_sub ON signatures(user_sub);
|
||||
```
|
||||
|
||||
**Garanties** :
|
||||
- ✅ Une signature par utilisateur/document (contrainte UNIQUE)
|
||||
- ✅ Horodatage immutable via trigger PostgreSQL
|
||||
- ✅ Chaînage hash (blockchain-like) via `prev_hash`
|
||||
- ✅ Non-répudiation cryptographique (Ed25519)
|
||||
|
||||
### Table `documents`
|
||||
|
||||
Métadonnées des documents.
|
||||
|
||||
```sql
|
||||
CREATE TABLE documents (
|
||||
doc_id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
url TEXT NOT NULL DEFAULT '', -- URL du document source
|
||||
checksum TEXT NOT NULL DEFAULT '', -- SHA-256, SHA-512, ou MD5
|
||||
checksum_algorithm TEXT NOT NULL DEFAULT 'SHA-256',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by TEXT NOT NULL DEFAULT '' -- user_sub de l'admin créateur
|
||||
);
|
||||
```
|
||||
|
||||
**Utilisation** :
|
||||
- Titre, description affichés dans l'interface
|
||||
- URL incluse dans les emails de rappel
|
||||
- Checksum pour vérification d'intégrité (optionnel)
|
||||
|
||||
### Table `expected_signers`
|
||||
|
||||
Signataires attendus pour tracking.
|
||||
|
||||
```sql
|
||||
CREATE TABLE expected_signers (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
doc_id TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '', -- Nom pour personnalisation
|
||||
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
added_by TEXT NOT NULL, -- Admin qui a ajouté
|
||||
notes TEXT,
|
||||
UNIQUE (doc_id, email)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_expected_signers_doc_id ON expected_signers(doc_id);
|
||||
```
|
||||
|
||||
**Fonctionnalités** :
|
||||
- Tracking de complétion (% signé)
|
||||
- Envoi de rappels email
|
||||
- Détection de signatures inattendues
|
||||
|
||||
### Table `reminder_logs`
|
||||
|
||||
Historique des rappels email.
|
||||
|
||||
```sql
|
||||
CREATE TABLE reminder_logs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
doc_id TEXT NOT NULL,
|
||||
recipient_email TEXT NOT NULL,
|
||||
sent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
sent_by TEXT NOT NULL, -- Admin qui a envoyé
|
||||
template_used TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('sent', 'failed', 'bounced')),
|
||||
error_message TEXT,
|
||||
FOREIGN KEY (doc_id, recipient_email)
|
||||
REFERENCES expected_signers(doc_id, email)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_reminder_logs_doc_id ON reminder_logs(doc_id);
|
||||
```
|
||||
|
||||
### Table `checksum_verifications`
|
||||
|
||||
Historique des vérifications d'intégrité.
|
||||
|
||||
```sql
|
||||
CREATE TABLE checksum_verifications (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
doc_id TEXT NOT NULL,
|
||||
verified_by TEXT NOT NULL,
|
||||
verified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
stored_checksum TEXT NOT NULL,
|
||||
calculated_checksum TEXT NOT NULL,
|
||||
algorithm TEXT NOT NULL,
|
||||
is_valid BOOLEAN NOT NULL,
|
||||
error_message TEXT,
|
||||
FOREIGN KEY (doc_id) REFERENCES documents(doc_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_checksum_verifications_doc_id ON checksum_verifications(doc_id);
|
||||
```
|
||||
|
||||
### Table `oauth_sessions`
|
||||
|
||||
Sessions OAuth2 avec refresh tokens chiffrés.
|
||||
|
||||
```sql
|
||||
CREATE TABLE oauth_sessions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
session_id TEXT NOT NULL UNIQUE, -- ID session Gorilla
|
||||
user_sub TEXT NOT NULL, -- OAuth user ID
|
||||
refresh_token_encrypted BYTEA NOT NULL, -- Chiffré AES-256-GCM
|
||||
access_token_expires_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_refreshed_at TIMESTAMPTZ,
|
||||
user_agent TEXT,
|
||||
ip_address INET
|
||||
);
|
||||
|
||||
CREATE INDEX idx_oauth_sessions_session_id ON oauth_sessions(session_id);
|
||||
CREATE INDEX idx_oauth_sessions_user_sub ON oauth_sessions(user_sub);
|
||||
CREATE INDEX idx_oauth_sessions_updated_at ON oauth_sessions(updated_at);
|
||||
```
|
||||
|
||||
**Sécurité** :
|
||||
- Refresh tokens chiffrés (AES-256-GCM)
|
||||
- Cleanup automatique après 37 jours
|
||||
- Tracking IP + User-Agent pour détecter vols
|
||||
|
||||
### Table `email_queue`
|
||||
|
||||
File d'attente d'emails asynchrone avec mécanisme de retry.
|
||||
|
||||
```sql
|
||||
CREATE TABLE email_queue (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
|
||||
-- Métadonnées email
|
||||
to_addresses TEXT[] NOT NULL, -- Adresses email destinataires
|
||||
cc_addresses TEXT[], -- Adresses CC (optionnel)
|
||||
bcc_addresses TEXT[], -- Adresses BCC (optionnel)
|
||||
subject TEXT NOT NULL, -- Sujet de l'email
|
||||
template TEXT NOT NULL, -- Nom du template (ex: 'reminder')
|
||||
locale TEXT NOT NULL DEFAULT 'fr', -- Langue email (en, fr, es, de, it)
|
||||
data JSONB NOT NULL DEFAULT '{}', -- Variables du template
|
||||
headers JSONB, -- Headers email personnalisés (optionnel)
|
||||
|
||||
-- Gestion de la file
|
||||
status TEXT NOT NULL DEFAULT 'pending' -- pending, processing, sent, failed, cancelled
|
||||
CHECK (status IN ('pending', 'processing', 'sent', 'failed', 'cancelled')),
|
||||
priority INT NOT NULL DEFAULT 0, -- Plus élevé = traité en premier (0=normal, 10=high, 100=urgent)
|
||||
retry_count INT NOT NULL DEFAULT 0, -- Nombre de tentatives de retry
|
||||
max_retries INT NOT NULL DEFAULT 3, -- Limite maximale de retry
|
||||
|
||||
-- Suivi
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
scheduled_for TIMESTAMPTZ NOT NULL DEFAULT now(), -- Heure de traitement la plus tôt
|
||||
processed_at TIMESTAMPTZ, -- Quand l'email a été envoyé
|
||||
next_retry_at TIMESTAMPTZ, -- Heure de retry calculée (exponential backoff)
|
||||
|
||||
-- Suivi des erreurs
|
||||
last_error TEXT, -- Dernier message d'erreur
|
||||
error_details JSONB, -- Informations d'erreur détaillées
|
||||
|
||||
-- Suivi des références (optionnel)
|
||||
reference_type TEXT, -- ex: 'reminder', 'notification'
|
||||
reference_id TEXT, -- ex: doc_id
|
||||
created_by TEXT -- Utilisateur qui a mis en file l'email
|
||||
);
|
||||
|
||||
-- Index pour traitement efficace de la file
|
||||
CREATE INDEX idx_email_queue_status_scheduled
|
||||
ON email_queue(status, scheduled_for)
|
||||
WHERE status IN ('pending', 'processing');
|
||||
|
||||
CREATE INDEX idx_email_queue_priority_scheduled
|
||||
ON email_queue(priority DESC, scheduled_for ASC)
|
||||
WHERE status = 'pending';
|
||||
|
||||
CREATE INDEX idx_email_queue_retry
|
||||
ON email_queue(next_retry_at)
|
||||
WHERE status = 'processing' AND retry_count < max_retries;
|
||||
|
||||
CREATE INDEX idx_email_queue_reference
|
||||
ON email_queue(reference_type, reference_id);
|
||||
|
||||
CREATE INDEX idx_email_queue_created_at
|
||||
ON email_queue(created_at DESC);
|
||||
```
|
||||
|
||||
**Fonctionnalités** :
|
||||
- **Traitement asynchrone** : Emails traités par worker en arrière-plan
|
||||
- **Mécanisme de retry** : Exponential backoff (1min, 2min, 4min, 8min, 16min, 32min...)
|
||||
- **Support de priorité** : Emails haute priorité traités en premier
|
||||
- **Envoi programmé** : Retarder la livraison d'email avec `scheduled_for`
|
||||
- **Suivi des erreurs** : Logging détaillé des erreurs et historique des retries
|
||||
- **Suivi des références** : Lier les emails aux documents ou autres entités
|
||||
|
||||
**Calcul automatique du retry** :
|
||||
```sql
|
||||
-- Fonction pour calculer le temps de retry suivant avec exponential backoff
|
||||
CREATE OR REPLACE FUNCTION calculate_next_retry_time(retry_count INT)
|
||||
RETURNS TIMESTAMPTZ AS $$
|
||||
BEGIN
|
||||
-- Exponential backoff: 1min, 2min, 4min, 8min, 16min, 32min...
|
||||
RETURN now() + (interval '1 minute' * power(2, retry_count));
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
```
|
||||
|
||||
**Configuration du worker** :
|
||||
- Taille de lot : 10 emails par lot
|
||||
- Intervalle de polling : 5 secondes
|
||||
- Envois concurrents : 5 emails simultanés
|
||||
- Cleanup des anciens emails : Rétention de 7 jours pour emails envoyés/échoués
|
||||
|
||||
## Migrations
|
||||
|
||||
### Gestion des Migrations
|
||||
|
||||
Les migrations sont dans `/backend/migrations/` avec le format :
|
||||
|
||||
```
|
||||
XXXX_description.up.sql # Migration "up"
|
||||
XXXX_description.down.sql # Rollback "down"
|
||||
```
|
||||
|
||||
**Fichiers actuels** :
|
||||
- `0001_init.up.sql` - Table signatures
|
||||
- `0002_expected_signers.up.sql` - Expected signers
|
||||
- `0003_reminder_logs.up.sql` - Reminder logs
|
||||
- `0004_add_name_to_expected_signers.up.sql` - Noms signataires
|
||||
- `0005_create_documents_table.up.sql` - Documents metadata
|
||||
- `0006_create_new_tables.up.sql` - Checksum verifications et email queue
|
||||
- `0007_oauth_sessions.up.sql` - OAuth sessions avec refresh tokens
|
||||
|
||||
### Appliquer les Migrations
|
||||
|
||||
**Via Docker Compose** (automatique) :
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
# Le service ackify-migrate applique les migrations au démarrage
|
||||
```
|
||||
|
||||
**Manuellement** :
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
go run ./cmd/migrate up
|
||||
```
|
||||
|
||||
**Rollback dernière migration** :
|
||||
|
||||
```bash
|
||||
go run ./cmd/migrate down
|
||||
```
|
||||
|
||||
### Migrations Personnalisées
|
||||
|
||||
Pour créer une nouvelle migration :
|
||||
|
||||
1. Créer `XXXX_my_feature.up.sql` :
|
||||
```sql
|
||||
-- Migration up
|
||||
ALTER TABLE signatures ADD COLUMN new_field TEXT;
|
||||
```
|
||||
|
||||
2. Créer `XXXX_my_feature.down.sql` :
|
||||
```sql
|
||||
-- Rollback
|
||||
ALTER TABLE signatures DROP COLUMN new_field;
|
||||
```
|
||||
|
||||
3. Appliquer :
|
||||
```bash
|
||||
go run ./cmd/migrate up
|
||||
```
|
||||
|
||||
## Triggers PostgreSQL
|
||||
|
||||
### Immutabilité de `created_at`
|
||||
|
||||
Trigger qui empêche la modification de `created_at` :
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION prevent_created_at_update()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.created_at <> OLD.created_at THEN
|
||||
RAISE EXCEPTION 'created_at cannot be modified';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER prevent_signatures_created_at_update
|
||||
BEFORE UPDATE ON signatures
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION prevent_created_at_update();
|
||||
```
|
||||
|
||||
**Garantie** : Aucune signature ne peut être backdatée.
|
||||
|
||||
### Auto-update de `updated_at`
|
||||
|
||||
Pour les tables avec `updated_at` :
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER update_documents_updated_at
|
||||
BEFORE UPDATE ON documents
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
```
|
||||
|
||||
## Requêtes Utiles
|
||||
|
||||
### Voir les signatures d'un document
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
user_email,
|
||||
user_name,
|
||||
signed_at,
|
||||
payload_hash,
|
||||
signature
|
||||
FROM signatures
|
||||
WHERE doc_id = 'my_document'
|
||||
ORDER BY signed_at DESC;
|
||||
```
|
||||
|
||||
### Statut de complétion
|
||||
|
||||
```sql
|
||||
WITH expected AS (
|
||||
SELECT COUNT(*) as total
|
||||
FROM expected_signers
|
||||
WHERE doc_id = 'my_document'
|
||||
),
|
||||
signed AS (
|
||||
SELECT COUNT(*) as count
|
||||
FROM signatures s
|
||||
INNER JOIN expected_signers e ON s.user_email = e.email AND s.doc_id = e.doc_id
|
||||
WHERE s.doc_id = 'my_document'
|
||||
)
|
||||
SELECT
|
||||
e.total as expected,
|
||||
s.count as signed,
|
||||
ROUND(100.0 * s.count / NULLIF(e.total, 0), 2) as completion_pct
|
||||
FROM expected e, signed s;
|
||||
```
|
||||
|
||||
### Signataires manquants
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
e.email,
|
||||
e.name,
|
||||
e.added_at
|
||||
FROM expected_signers e
|
||||
LEFT JOIN signatures s ON e.email = s.user_email AND e.doc_id = s.doc_id
|
||||
WHERE e.doc_id = 'my_document' AND s.id IS NULL
|
||||
ORDER BY e.added_at;
|
||||
```
|
||||
|
||||
### Signatures inattendues
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
s.user_email,
|
||||
s.signed_at
|
||||
FROM signatures s
|
||||
LEFT JOIN expected_signers e ON s.user_email = e.email AND s.doc_id = e.doc_id
|
||||
WHERE s.doc_id = 'my_document' AND e.id IS NULL
|
||||
ORDER BY s.signed_at DESC;
|
||||
```
|
||||
|
||||
### Statut de la file d'emails
|
||||
|
||||
```sql
|
||||
-- Voir les emails en attente
|
||||
SELECT
|
||||
id,
|
||||
to_addresses,
|
||||
subject,
|
||||
status,
|
||||
priority,
|
||||
retry_count,
|
||||
scheduled_for,
|
||||
created_at
|
||||
FROM email_queue
|
||||
WHERE status IN ('pending', 'processing')
|
||||
ORDER BY priority DESC, scheduled_for ASC
|
||||
LIMIT 20;
|
||||
|
||||
-- Emails échoués nécessitant attention
|
||||
SELECT
|
||||
id,
|
||||
to_addresses,
|
||||
subject,
|
||||
retry_count,
|
||||
max_retries,
|
||||
last_error,
|
||||
next_retry_at
|
||||
FROM email_queue
|
||||
WHERE status = 'failed'
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- Statistiques des emails par statut
|
||||
SELECT
|
||||
status,
|
||||
COUNT(*) as count,
|
||||
MIN(created_at) as oldest,
|
||||
MAX(created_at) as newest
|
||||
FROM email_queue
|
||||
GROUP BY status
|
||||
ORDER BY status;
|
||||
```
|
||||
|
||||
## Sauvegarde & Restauration
|
||||
|
||||
### Backup PostgreSQL
|
||||
|
||||
```bash
|
||||
# Backup complet
|
||||
docker compose exec ackify-db pg_dump -U ackifyr ackify > backup.sql
|
||||
|
||||
# Backup avec compression
|
||||
docker compose exec ackify-db pg_dump -U ackifyr ackify | gzip > backup.sql.gz
|
||||
```
|
||||
|
||||
### Restore
|
||||
|
||||
```bash
|
||||
# Restore depuis backup
|
||||
cat backup.sql | docker compose exec -T ackify-db psql -U ackifyr ackify
|
||||
|
||||
# Restore depuis backup compressé
|
||||
gunzip -c backup.sql.gz | docker compose exec -T ackify-db psql -U ackifyr ackify
|
||||
```
|
||||
|
||||
### Backup Automatisé
|
||||
|
||||
Exemple de cron pour backup quotidien :
|
||||
|
||||
```bash
|
||||
0 2 * * * docker compose -f /path/to/compose.yml exec -T ackify-db pg_dump -U ackifyr ackify | gzip > /backups/ackify-$(date +\%Y\%m\%d).sql.gz
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
### Index
|
||||
|
||||
Les index sont automatiquement créés pour :
|
||||
- `signatures(doc_id)` - Requêtes par document
|
||||
- `signatures(user_sub)` - Requêtes par utilisateur
|
||||
- `expected_signers(doc_id)` - Tracking complétion
|
||||
- `oauth_sessions(session_id)` - Lookup sessions
|
||||
|
||||
### Connection Pooling
|
||||
|
||||
Le backend Go gère automatiquement le pooling de connexions :
|
||||
- Max open connections : 25
|
||||
- Max idle connections : 5
|
||||
- Connection max lifetime : 5 minutes
|
||||
|
||||
### Vacuum & Analyze
|
||||
|
||||
PostgreSQL gère automatiquement via `autovacuum`. Pour forcer :
|
||||
|
||||
```sql
|
||||
VACUUM ANALYZE signatures;
|
||||
VACUUM ANALYZE documents;
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Taille des tables
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
schemaname,
|
||||
tablename,
|
||||
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size
|
||||
FROM pg_tables
|
||||
WHERE schemaname = 'public'
|
||||
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;
|
||||
```
|
||||
|
||||
### Statistiques
|
||||
|
||||
```sql
|
||||
SELECT * FROM pg_stat_user_tables WHERE schemaname = 'public';
|
||||
```
|
||||
|
||||
### Connexions actives
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
datname,
|
||||
usename,
|
||||
application_name,
|
||||
client_addr,
|
||||
state,
|
||||
query
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = 'ackify';
|
||||
```
|
||||
|
||||
## Sécurité
|
||||
|
||||
### En Production
|
||||
|
||||
- ✅ Utiliser SSL : `?sslmode=require` dans le DSN
|
||||
- ✅ Mot de passe fort pour PostgreSQL
|
||||
- ✅ Restreindre les connexions réseau
|
||||
- ✅ Sauvegardes chiffrées
|
||||
- ✅ Rotation régulière des secrets
|
||||
|
||||
### Configuration SSL
|
||||
|
||||
```bash
|
||||
# Dans .env
|
||||
ACKIFY_DB_DSN=postgres://user:pass@host:5432/ackify?sslmode=require
|
||||
```
|
||||
|
||||
### Audit Trail
|
||||
|
||||
Toutes les opérations importantes sont tracées :
|
||||
- `signatures.created_at` - Horodatage signature
|
||||
- `expected_signers.added_by` - Qui a ajouté
|
||||
- `reminder_logs.sent_by` - Qui a envoyé le rappel
|
||||
- `checksum_verifications.verified_by` - Qui a vérifié
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Migrations bloquées
|
||||
|
||||
```bash
|
||||
# Vérifier le statut
|
||||
docker compose logs ackify-migrate
|
||||
|
||||
# Forcer le rollback
|
||||
docker compose exec ackify-ce /app/migrate down
|
||||
docker compose exec ackify-ce /app/migrate up
|
||||
```
|
||||
|
||||
### Contrainte UNIQUE violée
|
||||
|
||||
Erreur : `duplicate key value violates unique constraint`
|
||||
|
||||
**Cause** : L'utilisateur a déjà signé ce document.
|
||||
|
||||
**Solution** : C'est un comportement normal (une signature par user/doc).
|
||||
|
||||
### Connection refused
|
||||
|
||||
Vérifier que PostgreSQL est démarré :
|
||||
|
||||
```bash
|
||||
docker compose ps ackify-db
|
||||
docker compose logs ackify-db
|
||||
```
|
||||
122
docs/fr/deployment.md
Normal file
122
docs/fr/deployment.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# Deployment
|
||||
|
||||
Guide de déploiement en production avec Docker Compose.
|
||||
|
||||
## Production avec Docker Compose
|
||||
|
||||
### Architecture Recommandée
|
||||
|
||||
```
|
||||
[Internet] → [Reverse Proxy (Traefik/Nginx)] → [Ackify Container]
|
||||
↓
|
||||
[PostgreSQL Container]
|
||||
```
|
||||
|
||||
### compose.yml Production
|
||||
|
||||
Voir le fichier `/compose.yml` à la racine du projet pour la configuration complète.
|
||||
|
||||
**Services inclus** :
|
||||
- `ackify-migrate` - Migrations PostgreSQL (run once)
|
||||
- `ackify-ce` - Application principale
|
||||
- `ackify-db` - PostgreSQL 16
|
||||
|
||||
### Configuration .env Production
|
||||
|
||||
```bash
|
||||
# Application
|
||||
APP_DNS=sign.company.com
|
||||
ACKIFY_BASE_URL=https://sign.company.com
|
||||
ACKIFY_ORGANISATION="ACME Corporation"
|
||||
ACKIFY_LOG_LEVEL=info
|
||||
|
||||
# Base de données (mot de passe fort)
|
||||
POSTGRES_USER=ackifyr
|
||||
POSTGRES_PASSWORD=$(openssl rand -base64 32)
|
||||
POSTGRES_DB=ackify
|
||||
|
||||
# OAuth2
|
||||
ACKIFY_OAUTH_PROVIDER=google
|
||||
ACKIFY_OAUTH_CLIENT_ID=your_client_id
|
||||
ACKIFY_OAUTH_CLIENT_SECRET=your_client_secret
|
||||
ACKIFY_OAUTH_ALLOWED_DOMAIN=@company.com
|
||||
|
||||
# Sécurité (générer avec openssl)
|
||||
ACKIFY_OAUTH_COOKIE_SECRET=$(openssl rand -base64 64)
|
||||
ACKIFY_ED25519_PRIVATE_KEY=$(openssl rand -base64 64)
|
||||
|
||||
# Administration
|
||||
ACKIFY_ADMIN_EMAILS=admin@company.com,cto@company.com
|
||||
```
|
||||
|
||||
## Reverse Proxy
|
||||
|
||||
### Traefik
|
||||
|
||||
Ajouter les labels dans `compose.yml` :
|
||||
|
||||
```yaml
|
||||
services:
|
||||
ackify-ce:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.ackify.rule=Host(`sign.company.com`)"
|
||||
- "traefik.http.routers.ackify.entrypoints=websecure"
|
||||
- "traefik.http.routers.ackify.tls.certresolver=letsencrypt"
|
||||
```
|
||||
|
||||
### Nginx
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name sign.company.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/sign.company.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/sign.company.com/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Checklist Sécurité
|
||||
|
||||
- ✅ HTTPS avec certificat valide
|
||||
- ✅ Secrets forts (64+ bytes)
|
||||
- ✅ PostgreSQL SSL en production
|
||||
- ✅ Domaine OAuth restreint
|
||||
- ✅ Logs en mode info
|
||||
- ✅ Backup automatique
|
||||
- ✅ Monitoring actif
|
||||
|
||||
## Backup
|
||||
|
||||
```bash
|
||||
# Backup quotidien PostgreSQL
|
||||
docker compose exec -T ackify-db pg_dump -U ackifyr ackify | gzip > backup-$(date +%Y%m%d).sql.gz
|
||||
|
||||
# Restauration
|
||||
gunzip -c backup.sql.gz | docker compose exec -T ackify-db psql -U ackifyr ackify
|
||||
```
|
||||
|
||||
## Mise à Jour
|
||||
|
||||
```bash
|
||||
# Pull nouvelle image
|
||||
docker compose pull ackify-ce
|
||||
|
||||
# Redémarrer
|
||||
docker compose up -d
|
||||
|
||||
# Vérifier
|
||||
docker compose logs -f ackify-ce
|
||||
curl https://sign.company.com/api/v1/health
|
||||
```
|
||||
|
||||
Voir [Getting Started](getting-started.md) pour plus de détails.
|
||||
513
docs/fr/development.md
Normal file
513
docs/fr/development.md
Normal file
@@ -0,0 +1,513 @@
|
||||
# Development
|
||||
|
||||
Guide pour contribuer et développer sur Ackify.
|
||||
|
||||
## Setup Développement
|
||||
|
||||
### Prérequis
|
||||
|
||||
- **Go 1.24.5+**
|
||||
- **Node.js 22+** et npm
|
||||
- **PostgreSQL 16+**
|
||||
- **Docker & Docker Compose**
|
||||
- Git
|
||||
|
||||
### Clone & Setup
|
||||
|
||||
```bash
|
||||
# Clone
|
||||
git clone https://github.com/btouchard/ackify-ce.git
|
||||
cd ackify-ce
|
||||
|
||||
# Copier .env
|
||||
cp .env.example .env
|
||||
|
||||
# Éditer .env avec vos credentials OAuth2
|
||||
nano .env
|
||||
```
|
||||
|
||||
## Développement Backend
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
go mod download
|
||||
go build ./cmd/community
|
||||
```
|
||||
|
||||
### Run
|
||||
|
||||
```bash
|
||||
# Démarrer PostgreSQL avec Docker
|
||||
docker compose up -d ackify-db
|
||||
|
||||
# Appliquer les migrations
|
||||
go run ./cmd/migrate up
|
||||
|
||||
# Lancer l'app
|
||||
./community
|
||||
```
|
||||
|
||||
L'API est accessible sur `http://localhost:8080`.
|
||||
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
# Tests unitaires
|
||||
go test -v -short ./...
|
||||
|
||||
# Tests avec coverage
|
||||
go test -coverprofile=coverage.out ./internal/... ./pkg/...
|
||||
|
||||
# Voir le coverage
|
||||
go tool cover -html=coverage.out
|
||||
|
||||
# Tests d'intégration (PostgreSQL requis)
|
||||
docker compose -f ../compose.test.yml up -d
|
||||
INTEGRATION_TESTS=1 go test -tags=integration -v ./internal/infrastructure/database/
|
||||
docker compose -f ../compose.test.yml down
|
||||
```
|
||||
|
||||
### Linting
|
||||
|
||||
```bash
|
||||
# Format
|
||||
go fmt ./...
|
||||
|
||||
# Vet
|
||||
go vet ./...
|
||||
|
||||
# Staticcheck (optionnel)
|
||||
go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||
staticcheck ./...
|
||||
```
|
||||
|
||||
## Développement Frontend
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
cd webapp
|
||||
npm install
|
||||
```
|
||||
|
||||
### Dev Server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Frontend accessible sur `http://localhost:5173` avec Hot Module Replacement.
|
||||
|
||||
### Build Production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
# Output: webapp/dist/
|
||||
```
|
||||
|
||||
### Type Checking
|
||||
|
||||
```bash
|
||||
npm run type-check
|
||||
```
|
||||
|
||||
### i18n Validation
|
||||
|
||||
```bash
|
||||
npm run lint:i18n
|
||||
```
|
||||
|
||||
Vérifie que toutes les traductions sont complètes.
|
||||
|
||||
## Docker Development
|
||||
|
||||
### Build Local
|
||||
|
||||
```bash
|
||||
# Build l'image complète (frontend + backend)
|
||||
docker compose -f compose.local.yml up -d --build
|
||||
|
||||
# Logs
|
||||
docker compose -f compose.local.yml logs -f ackify-ce
|
||||
|
||||
# Rebuild après modifications
|
||||
docker compose -f compose.local.yml up -d --force-recreate ackify-ce --build
|
||||
```
|
||||
|
||||
### Debug
|
||||
|
||||
```bash
|
||||
# Shell dans le container
|
||||
docker compose exec ackify-ce sh
|
||||
|
||||
# PostgreSQL shell
|
||||
docker compose exec ackify-db psql -U ackifyr -d ackify
|
||||
```
|
||||
|
||||
## Structure du Code
|
||||
|
||||
### Backend
|
||||
|
||||
```
|
||||
backend/
|
||||
├── cmd/
|
||||
│ ├── community/ # main.go + injection dépendances
|
||||
│ └── migrate/ # Outil migrations
|
||||
├── internal/
|
||||
│ ├── domain/models/ # Entités (User, Signature, Document)
|
||||
│ ├── application/services/ # Logique métier
|
||||
│ ├── infrastructure/
|
||||
│ │ ├── auth/ # OAuth2
|
||||
│ │ ├── database/ # Repositories
|
||||
│ │ ├── email/ # SMTP
|
||||
│ │ └── config/ # Config
|
||||
│ └── presentation/api/ # HTTP handlers
|
||||
└── pkg/ # Utilities
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```
|
||||
webapp/src/
|
||||
├── components/ # Composants Vue
|
||||
├── pages/ # Pages (router)
|
||||
├── services/ # API client
|
||||
├── stores/ # Pinia stores
|
||||
├── router/ # Vue Router
|
||||
└── locales/ # Traductions
|
||||
```
|
||||
|
||||
## Conventions de Code
|
||||
|
||||
### Go
|
||||
|
||||
**Naming** :
|
||||
- Packages : lowercase, singular (`user`, `signature`)
|
||||
- Interfaces : suffixe `er` ou descriptif (`SignatureRepository`, `EmailSender`)
|
||||
- Constructeurs : `New...()` ou `...From...()`
|
||||
|
||||
**Exemple** :
|
||||
```go
|
||||
// Service
|
||||
type SignatureService struct {
|
||||
repo SignatureRepository
|
||||
crypto CryptoService
|
||||
}
|
||||
|
||||
func NewSignatureService(repo SignatureRepository, crypto CryptoService) *SignatureService {
|
||||
return &SignatureService{repo: repo, crypto: crypto}
|
||||
}
|
||||
|
||||
// Méthode
|
||||
func (s *SignatureService) CreateSignature(ctx context.Context, docID, userSub string) (*models.Signature, error) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Erreurs** :
|
||||
```go
|
||||
// Wrapping
|
||||
return nil, fmt.Errorf("failed to create signature: %w", err)
|
||||
|
||||
// Custom errors
|
||||
var ErrAlreadySigned = errors.New("user has already signed this document")
|
||||
```
|
||||
|
||||
### TypeScript
|
||||
|
||||
**Naming** :
|
||||
- Components : PascalCase (`DocumentCard.vue`)
|
||||
- Composables : camelCase avec `use` prefix (`useAuth.ts`)
|
||||
- Stores : camelCase avec `Store` suffix (`userStore.ts`)
|
||||
|
||||
**Exemple** :
|
||||
```typescript
|
||||
// Composable
|
||||
export function useAuth() {
|
||||
const user = ref<User | null>(null)
|
||||
|
||||
async function login() {
|
||||
// ...
|
||||
}
|
||||
|
||||
return { user, login }
|
||||
}
|
||||
|
||||
// Store
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const currentUser = ref<User | null>(null)
|
||||
|
||||
async function fetchMe() {
|
||||
const { data } = await api.get('/users/me')
|
||||
currentUser.value = data
|
||||
}
|
||||
|
||||
return { currentUser, fetchMe }
|
||||
})
|
||||
```
|
||||
|
||||
## Ajouter une Feature
|
||||
|
||||
### 1. Planifier
|
||||
|
||||
- Définir les endpoints API requis
|
||||
- Schéma SQL si nécessaire
|
||||
- Interface utilisateur
|
||||
|
||||
### 2. Backend
|
||||
|
||||
```bash
|
||||
# 1. Créer migration si besoin
|
||||
touch backend/migrations/XXXX_my_feature.up.sql
|
||||
touch backend/migrations/XXXX_my_feature.down.sql
|
||||
|
||||
# 2. Créer le modèle
|
||||
# backend/internal/domain/models/my_model.go
|
||||
|
||||
# 3. Créer le repository interface
|
||||
# backend/internal/application/services/my_service.go
|
||||
|
||||
# 4. Implémenter le repository
|
||||
# backend/internal/infrastructure/database/my_repository.go
|
||||
|
||||
# 5. Créer le handler API
|
||||
# backend/internal/presentation/api/myfeature/handler.go
|
||||
|
||||
# 6. Enregistrer les routes
|
||||
# backend/internal/presentation/api/router.go
|
||||
```
|
||||
|
||||
### 3. Frontend
|
||||
|
||||
```bash
|
||||
# 1. Créer le service API
|
||||
# webapp/src/services/myFeatureService.ts
|
||||
|
||||
# 2. Créer le store Pinia
|
||||
# webapp/src/stores/myFeatureStore.ts
|
||||
|
||||
# 3. Créer les composants
|
||||
# webapp/src/components/MyFeature.vue
|
||||
|
||||
# 4. Ajouter les traductions
|
||||
# webapp/src/locales/{fr,en,es,de,it}.json
|
||||
|
||||
# 5. Ajouter les routes si nécessaire
|
||||
# webapp/src/router/index.ts
|
||||
```
|
||||
|
||||
### 4. Tests
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
# backend/internal/presentation/api/myfeature/handler_test.go
|
||||
|
||||
# Test
|
||||
go test -v ./internal/presentation/api/myfeature/
|
||||
```
|
||||
|
||||
### 5. Documentation
|
||||
|
||||
Mettre à jour :
|
||||
- `/api/openapi.yaml` - Spécification OpenAPI
|
||||
- `/docs/api.md` - Documentation API
|
||||
- `/docs/features/my-feature.md` - Guide utilisateur
|
||||
|
||||
## Debugging
|
||||
|
||||
### Backend
|
||||
|
||||
```go
|
||||
// Logs structurés
|
||||
logger.Info("signature created",
|
||||
"doc_id", docID,
|
||||
"user_sub", userSub,
|
||||
"signature_id", sig.ID,
|
||||
)
|
||||
|
||||
// Debug via Delve (optionnel)
|
||||
dlv debug ./cmd/community
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```typescript
|
||||
// Vue DevTools (extension Chrome/Firefox)
|
||||
// Inspecter: Components, Pinia stores, Router
|
||||
|
||||
// Console debug
|
||||
console.log('[DEBUG] User:', user.value)
|
||||
|
||||
// Breakpoints via navigateur
|
||||
debugger
|
||||
```
|
||||
|
||||
## Migrations SQL
|
||||
|
||||
### Créer une Migration
|
||||
|
||||
```sql
|
||||
-- XXXX_add_field.up.sql
|
||||
ALTER TABLE signatures ADD COLUMN new_field TEXT;
|
||||
|
||||
-- XXXX_add_field.down.sql
|
||||
ALTER TABLE signatures DROP COLUMN new_field;
|
||||
```
|
||||
|
||||
### Appliquer
|
||||
|
||||
```bash
|
||||
go run ./cmd/migrate up
|
||||
```
|
||||
|
||||
### Rollback
|
||||
|
||||
```bash
|
||||
go run ./cmd/migrate down
|
||||
```
|
||||
|
||||
## Tests d'Intégration
|
||||
|
||||
### Setup PostgreSQL Test
|
||||
|
||||
```bash
|
||||
docker compose -f compose.test.yml up -d
|
||||
```
|
||||
|
||||
### Run Tests
|
||||
|
||||
```bash
|
||||
INTEGRATION_TESTS=1 go test -tags=integration -v ./internal/infrastructure/database/
|
||||
```
|
||||
|
||||
### Cleanup
|
||||
|
||||
```bash
|
||||
docker compose -f compose.test.yml down -v
|
||||
```
|
||||
|
||||
## CI/CD
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
Le projet utilise `.github/workflows/ci.yml` :
|
||||
|
||||
**Jobs** :
|
||||
1. **Lint** - go fmt, go vet, eslint
|
||||
2. **Test Backend** - Unit + integration tests
|
||||
3. **Test Frontend** - Type checking + i18n validation
|
||||
4. **Coverage** - Upload vers Codecov
|
||||
5. **Build** - Vérifier que l'image Docker build
|
||||
|
||||
### Pre-commit Hooks (optionnel)
|
||||
|
||||
```bash
|
||||
# Installer pre-commit
|
||||
pip install pre-commit
|
||||
|
||||
# Setup hooks
|
||||
pre-commit install
|
||||
|
||||
# Run manuellement
|
||||
pre-commit run --all-files
|
||||
```
|
||||
|
||||
## Contribution
|
||||
|
||||
### Workflow Git
|
||||
|
||||
```bash
|
||||
# 1. Créer une branche
|
||||
git checkout -b feature/my-feature
|
||||
|
||||
# 2. Développer + commit
|
||||
git add .
|
||||
git commit -m "feat: add my feature"
|
||||
|
||||
# 3. Push
|
||||
git push origin feature/my-feature
|
||||
|
||||
# 4. Créer une Pull Request sur GitHub
|
||||
```
|
||||
|
||||
### Commit Messages
|
||||
|
||||
Format : `type: description`
|
||||
|
||||
**Types** :
|
||||
- `feat` - Nouvelle feature
|
||||
- `fix` - Bug fix
|
||||
- `docs` - Documentation
|
||||
- `refactor` - Refactoring
|
||||
- `test` - Tests
|
||||
- `chore` - Maintenance
|
||||
|
||||
**Exemples** :
|
||||
```
|
||||
feat: add checksum verification feature
|
||||
fix: resolve OAuth callback redirect loop
|
||||
docs: update API documentation for signatures
|
||||
refactor: simplify signature service logic
|
||||
test: add integration tests for expected signers
|
||||
```
|
||||
|
||||
### Code Review
|
||||
|
||||
**Checklist** :
|
||||
- ✅ Tests passent (CI green)
|
||||
- ✅ Code formaté (`go fmt`, `eslint`)
|
||||
- ✅ Pas de secrets commitées
|
||||
- ✅ Documentation à jour
|
||||
- ✅ Traductions complètes (i18n)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Backend ne démarre pas
|
||||
|
||||
```bash
|
||||
# Vérifier PostgreSQL
|
||||
docker compose ps ackify-db
|
||||
docker compose logs ackify-db
|
||||
|
||||
# Vérifier les variables d'env
|
||||
cat .env
|
||||
|
||||
# Logs détaillés
|
||||
ACKIFY_LOG_LEVEL=debug ./community
|
||||
```
|
||||
|
||||
### Frontend build échoue
|
||||
|
||||
```bash
|
||||
# Nettoyer et réinstaller
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
|
||||
# Vérifier Node version
|
||||
node --version # Doit être 22+
|
||||
```
|
||||
|
||||
### Tests échouent
|
||||
|
||||
```bash
|
||||
# Backend - vérifier PostgreSQL test
|
||||
docker compose -f compose.test.yml ps
|
||||
|
||||
# Frontend - vérifier types
|
||||
npm run type-check
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Go Documentation](https://go.dev/doc/)
|
||||
- [Vue 3 Guide](https://vuejs.org/guide/)
|
||||
- [PostgreSQL Docs](https://www.postgresql.org/docs/)
|
||||
- [Chi Router](https://github.com/go-chi/chi)
|
||||
- [Pinia](https://pinia.vuejs.org/)
|
||||
|
||||
## Support
|
||||
|
||||
- [GitHub Issues](https://github.com/btouchard/ackify-ce/issues)
|
||||
- [GitHub Discussions](https://github.com/btouchard/ackify-ce/discussions)
|
||||
235
docs/fr/features/checksums.md
Normal file
235
docs/fr/features/checksums.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# Checksums
|
||||
|
||||
Vérification d'intégrité des documents avec tracking.
|
||||
|
||||
## Vue d'Ensemble
|
||||
|
||||
Ackify permet de stocker et vérifier les checksums (empreintes) des documents pour garantir leur intégrité.
|
||||
|
||||
**Algorithmes supportés** :
|
||||
- SHA-256 (recommandé)
|
||||
- SHA-512
|
||||
- MD5 (legacy)
|
||||
|
||||
## Calculer un Checksum
|
||||
|
||||
### Ligne de Commande
|
||||
|
||||
```bash
|
||||
# Linux/Mac - SHA-256
|
||||
sha256sum document.pdf
|
||||
# Output: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 document.pdf
|
||||
|
||||
# SHA-512
|
||||
sha512sum document.pdf
|
||||
|
||||
# MD5
|
||||
md5sum document.pdf
|
||||
|
||||
# Windows PowerShell
|
||||
Get-FileHash document.pdf -Algorithm SHA256
|
||||
Get-FileHash document.pdf -Algorithm SHA512
|
||||
Get-FileHash document.pdf -Algorithm MD5
|
||||
```
|
||||
|
||||
### Client-Side (JavaScript)
|
||||
|
||||
Le frontend Vue.js utilise la **Web Crypto API** :
|
||||
|
||||
```javascript
|
||||
async function calculateChecksum(file) {
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
// Utilisation
|
||||
const file = document.querySelector('input[type="file"]').files[0]
|
||||
const checksum = await calculateChecksum(file)
|
||||
console.log('SHA-256:', checksum)
|
||||
```
|
||||
|
||||
## Stocker le Checksum
|
||||
|
||||
### Via le Dashboard Admin
|
||||
|
||||
1. Aller sur `/admin`
|
||||
2. Sélectionner un document
|
||||
3. Cliquer "Edit Metadata"
|
||||
4. Remplir :
|
||||
- **Checksum** : e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
|
||||
- **Algorithm** : SHA-256
|
||||
- **Document URL** : https://docs.company.com/policy.pdf
|
||||
|
||||
### Via l'API
|
||||
|
||||
```http
|
||||
PUT /api/v1/admin/documents/policy_2025/metadata
|
||||
Content-Type: application/json
|
||||
X-CSRF-Token: abc123
|
||||
|
||||
{
|
||||
"title": "Security Policy 2025",
|
||||
"url": "https://docs.company.com/policy.pdf",
|
||||
"checksum": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"checksumAlgorithm": "SHA-256",
|
||||
"description": "Annual security policy"
|
||||
}
|
||||
```
|
||||
|
||||
## Vérification
|
||||
|
||||
### Interface Utilisateur
|
||||
|
||||
Le frontend affiche :
|
||||
```
|
||||
Document: Security Policy 2025
|
||||
Checksum (SHA-256): e3b0c44...52b855 [Copy]
|
||||
URL: https://docs.company.com/policy.pdf [Open]
|
||||
|
||||
[Upload file to verify]
|
||||
```
|
||||
|
||||
**Workflow utilisateur** :
|
||||
1. Télécharge le document depuis l'URL
|
||||
2. Upload dans l'interface de vérification
|
||||
3. Le checksum est calculé client-side
|
||||
4. Comparaison automatique avec le stocké
|
||||
5. ✅ Match ou ❌ Mismatch
|
||||
|
||||
### Vérification Manuelle
|
||||
|
||||
```bash
|
||||
# 1. Télécharger le document
|
||||
wget https://docs.company.com/policy.pdf
|
||||
|
||||
# 2. Calculer le checksum
|
||||
sha256sum policy.pdf
|
||||
# e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
|
||||
|
||||
# 3. Comparer avec la valeur stockée (via API)
|
||||
curl http://localhost:8080/api/v1/documents/policy_2025
|
||||
# "checksum": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
|
||||
# 4. Si identique → Document intègre
|
||||
```
|
||||
|
||||
|
||||
## Cas d'Usage
|
||||
|
||||
### Compliance Documentaire
|
||||
|
||||
```
|
||||
Document: "ISO 27001 Certification"
|
||||
Checksum: SHA-256 du PDF officiel
|
||||
```
|
||||
|
||||
**Workflow** :
|
||||
- Stocker le checksum du document certifié
|
||||
- Chaque reviewer vérifie l'intégrité avant signature
|
||||
- Audit trail de toutes les vérifications
|
||||
|
||||
### Contrat Légal
|
||||
|
||||
```
|
||||
Document: "Service Agreement v2.3"
|
||||
Checksum: SHA-512 pour sécurité maximale
|
||||
URL: https://legal.company.com/contracts/sa-v2.3.pdf
|
||||
```
|
||||
|
||||
**Garanties** :
|
||||
- Le document signé correspond exactement à la version checksum
|
||||
- Détection de toute modification
|
||||
- Traçabilité des vérifications
|
||||
|
||||
### Formation avec Support
|
||||
|
||||
```
|
||||
Document: "GDPR Training Materials"
|
||||
Checksum: SHA-256 du fichier ZIP
|
||||
```
|
||||
|
||||
**Utilisation** :
|
||||
- Participants téléchargent le ZIP
|
||||
- Vérifient le checksum avant de commencer
|
||||
- Signent après complétion
|
||||
|
||||
## Sécurité
|
||||
|
||||
### Choix de l'Algorithme
|
||||
|
||||
| Algorithme | Sécurité | Performance | Recommandation |
|
||||
|------------|----------|-------------|----------------|
|
||||
| SHA-256 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ✅ Recommandé |
|
||||
| SHA-512 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | Maximum security |
|
||||
| MD5 | ⭐⭐ | ⭐⭐⭐⭐⭐ | ❌ Legacy only |
|
||||
|
||||
**Recommandation** : Utiliser **SHA-256** par défaut.
|
||||
|
||||
### Limitations de MD5
|
||||
|
||||
MD5 est **déprécié** pour la sécurité :
|
||||
- Collisions possibles (deux fichiers différents = même hash)
|
||||
- Utilisable uniquement pour compatibilité legacy
|
||||
|
||||
### Web Crypto API
|
||||
|
||||
La vérification client-side utilise l'API native du navigateur :
|
||||
- Pas de dépendance externe
|
||||
- Performance native
|
||||
- Supporté par tous les navigateurs modernes
|
||||
|
||||
## Intégration avec Signatures
|
||||
|
||||
Workflow complet :
|
||||
|
||||
```
|
||||
1. Admin upload document → calcule checksum → stocke metadata
|
||||
2. User télécharge document → vérifie checksum client-side
|
||||
3. Si checksum OK → User signe le document
|
||||
4. Signature liée au doc_id avec checksum stocké
|
||||
```
|
||||
|
||||
**Garantie** : La signature prouve que l'utilisateur a lu **exactement** la version checksum.
|
||||
|
||||
## Bonnes Pratiques
|
||||
|
||||
### Stockage
|
||||
|
||||
- ✅ Toujours stocker le checksum **avant** d'envoyer le lien de signature
|
||||
- ✅ Inclure l'URL du document dans la metadata
|
||||
- ✅ Utiliser SHA-256 minimum
|
||||
- ✅ Documenter l'algorithme utilisé
|
||||
|
||||
### Vérification
|
||||
|
||||
- ✅ Encourager les utilisateurs à vérifier avant de signer
|
||||
- ✅ Afficher le checksum de manière visible (avec bouton Copy)
|
||||
- ✅ Alerter en cas de mismatch
|
||||
|
||||
### Audit
|
||||
|
||||
- ✅ Surveiller l'intégrité des documents
|
||||
- ✅ Vérifier régulièrement les checksums
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Vérification manuelle uniquement** - Les utilisateurs doivent calculer et comparer les checksums manuellement
|
||||
- **Pas d'API de vérification côté serveur** - La vérification des checksums se fait côté client ou manuellement
|
||||
- **Pas d'historique automatisé** - La table `checksum_verifications` existe dans le schéma de base de données mais n'est pas actuellement utilisée par l'API
|
||||
- Pas de signature du checksum (fonctionnalité future : signer le checksum avec Ed25519)
|
||||
- Pas d'intégration avec stockage cloud (S3, GCS) pour récupération automatique
|
||||
|
||||
## Implémentation Actuelle
|
||||
|
||||
Actuellement, Ackify supporte :
|
||||
- ✅ Stockage des checksums dans les métadonnées de document (via dashboard admin ou API)
|
||||
- ✅ Affichage des checksums aux utilisateurs pour vérification manuelle
|
||||
- ✅ Calcul de checksum côté client avec Web Crypto API
|
||||
- ✅ Calcul automatique de checksum pour les URLs distantes (admin uniquement)
|
||||
|
||||
Les fonctionnalités futures pourraient inclure :
|
||||
- Endpoints API pour le suivi des vérifications de checksum
|
||||
- Workflows de vérification automatisés
|
||||
- Intégration avec des services de vérification externes
|
||||
339
docs/fr/features/embedding.md
Normal file
339
docs/fr/features/embedding.md
Normal file
@@ -0,0 +1,339 @@
|
||||
# Embedding & Intégrations
|
||||
|
||||
Intégrer Ackify dans vos outils (Notion, Outline, Google Docs, etc.).
|
||||
|
||||
## Méthodes d'Intégration
|
||||
|
||||
### 1. Lien Direct
|
||||
|
||||
Le plus simple :
|
||||
```
|
||||
https://sign.company.com/?doc=policy_2025
|
||||
```
|
||||
|
||||
**Usage** :
|
||||
- Email
|
||||
- Chat (Slack, Teams)
|
||||
- Wiki
|
||||
- Documentation
|
||||
|
||||
**Comportement** :
|
||||
- L'utilisateur clique → Arrive sur la page de signature
|
||||
- Se connecte via OAuth2
|
||||
- Signe le document
|
||||
|
||||
### 2. iFrame Embed
|
||||
|
||||
Pour intégrer dans une page web :
|
||||
|
||||
```html
|
||||
<iframe src="https://sign.company.com/?doc=policy_2025"
|
||||
width="600"
|
||||
height="200"
|
||||
frameborder="0"
|
||||
style="border: 1px solid #ddd; border-radius: 6px;">
|
||||
</iframe>
|
||||
```
|
||||
|
||||
**Rendu** :
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 📄 Security Policy 2025 │
|
||||
│ 42 confirmations │
|
||||
│ [Sign this document] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3. oEmbed (Auto-discovery)
|
||||
|
||||
Pour les plateformes supportant oEmbed (Notion, Outline, Confluence, etc.).
|
||||
|
||||
#### Comment ça marche
|
||||
|
||||
1. Coller l'URL dans votre éditeur :
|
||||
```
|
||||
https://sign.company.com/?doc=policy_2025
|
||||
```
|
||||
|
||||
2. L'éditeur détecte automatiquement via la balise meta :
|
||||
```html
|
||||
<link rel="alternate" type="application/json+oembed"
|
||||
href="https://sign.company.com/oembed?url=..." />
|
||||
```
|
||||
|
||||
3. L'éditeur appelle `/oembed` et reçoit :
|
||||
```json
|
||||
{
|
||||
"type": "rich",
|
||||
"version": "1.0",
|
||||
"title": "Security Policy 2025 - 42 confirmations",
|
||||
"provider_name": "Ackify",
|
||||
"html": "<iframe src=\"https://sign.company.com/?doc=policy_2025\" ...>",
|
||||
"height": 200
|
||||
}
|
||||
```
|
||||
|
||||
4. L'éditeur affiche l'iframe automatiquement
|
||||
|
||||
#### Plateformes Supportées
|
||||
|
||||
- ✅ **Notion** - Paste URL → Auto-embed
|
||||
- ✅ **Outline** - Paste URL → Auto-embed
|
||||
- ✅ **Confluence** - oEmbed macro
|
||||
- ✅ **AppFlowy** - URL unfurling
|
||||
- ✅ **Slack** - Link unfurling (Open Graph)
|
||||
- ✅ **Microsoft Teams** - Card preview
|
||||
- ✅ **Discord** - Rich embed
|
||||
|
||||
## Open Graph & Twitter Cards
|
||||
|
||||
Ackify génère automatiquement des meta tags pour les previews :
|
||||
|
||||
```html
|
||||
<!-- Auto-généré pour /?doc=policy_2025 -->
|
||||
<meta property="og:title" content="Security Policy 2025 - 42 confirmations" />
|
||||
<meta property="og:description" content="42 personnes ont confirmé avoir lu le document" />
|
||||
<meta property="og:url" content="https://sign.company.com/?doc=policy_2025" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
```
|
||||
|
||||
**Résultat dans Slack/Teams** :
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 🔐 Ackify │
|
||||
│ Security Policy 2025 │
|
||||
│ 42 confirmations │
|
||||
│ sign.company.com │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Intégrations Spécifiques
|
||||
|
||||
### Notion
|
||||
|
||||
1. Coller l'URL dans une page Notion :
|
||||
```
|
||||
https://sign.company.com/?doc=policy_2025
|
||||
```
|
||||
|
||||
2. Notion détecte automatiquement l'oEmbed
|
||||
3. Le widget apparaît avec bouton de signature
|
||||
|
||||
**Alternative** : Créer un embed manuel
|
||||
- `/embed` → Paste URL
|
||||
|
||||
### Outline
|
||||
|
||||
1. Dans un document Outline, coller :
|
||||
```
|
||||
https://sign.company.com/?doc=policy_2025
|
||||
```
|
||||
|
||||
2. Outline charge automatiquement le widget
|
||||
|
||||
### Google Docs
|
||||
|
||||
Google Docs ne supporte pas les iframes directement, mais :
|
||||
|
||||
1. **Option 1 - Lien** :
|
||||
```
|
||||
Veuillez signer : https://sign.company.com/?doc=policy_2025
|
||||
```
|
||||
|
||||
2. **Option 2 - Image Badge** :
|
||||
```
|
||||

|
||||
```
|
||||
|
||||
3. **Option 3 - Google Sites** :
|
||||
- Créer une page Google Sites
|
||||
- Insérer l'iframe
|
||||
- Lier depuis Google Docs
|
||||
|
||||
Voir [docs/integrations/google-doc/](../integrations/google-doc/) pour plus de détails.
|
||||
|
||||
### Confluence
|
||||
|
||||
1. Éditer une page Confluence
|
||||
2. Insérer macro "oEmbed" ou "HTML Embed"
|
||||
3. Coller :
|
||||
```
|
||||
https://sign.company.com/?doc=policy_2025
|
||||
```
|
||||
|
||||
### Slack
|
||||
|
||||
**Link Unfurling** :
|
||||
|
||||
1. Poster l'URL dans un channel :
|
||||
```
|
||||
Hey team, please sign: https://sign.company.com/?doc=policy_2025
|
||||
```
|
||||
|
||||
2. Slack affiche automatiquement une preview (Open Graph)
|
||||
|
||||
**Slash Command** (futur) :
|
||||
```
|
||||
/ackify sign policy_2025
|
||||
```
|
||||
|
||||
### Microsoft Teams
|
||||
|
||||
1. Poster l'URL dans une conversation
|
||||
2. Teams affiche une card preview (Open Graph)
|
||||
|
||||
## Badge PNG
|
||||
|
||||
Générer un badge visuel pour README, wiki, etc.
|
||||
|
||||
### URL du Badge
|
||||
|
||||
```
|
||||
https://sign.company.com/badge/policy_2025.png
|
||||
```
|
||||
|
||||
**Rendu** :
|
||||
|
||||

|
||||
|
||||
### Markdown
|
||||
|
||||
```markdown
|
||||
[](https://sign.company.com/?doc=policy_2025)
|
||||
```
|
||||
|
||||
### HTML
|
||||
|
||||
```html
|
||||
<a href="https://sign.company.com/?doc=policy_2025">
|
||||
<img src="https://sign.company.com/badge/policy_2025.png" alt="Signature status">
|
||||
</a>
|
||||
```
|
||||
|
||||
## API oEmbed
|
||||
|
||||
### Endpoint
|
||||
|
||||
```http
|
||||
GET /oembed?url=https://sign.company.com/?doc=policy_2025
|
||||
```
|
||||
|
||||
**Response** :
|
||||
```json
|
||||
{
|
||||
"type": "rich",
|
||||
"version": "1.0",
|
||||
"title": "Document policy_2025 - 42 confirmations",
|
||||
"provider_name": "Ackify",
|
||||
"provider_url": "https://sign.company.com",
|
||||
"html": "<iframe src=\"https://sign.company.com/?doc=policy_2025\" width=\"100%\" height=\"200\" frameborder=\"0\" style=\"border: 1px solid #ddd; border-radius: 6px;\" allowtransparency=\"true\"></iframe>",
|
||||
"width": null,
|
||||
"height": 200
|
||||
}
|
||||
```
|
||||
|
||||
### Paramètres
|
||||
|
||||
| Paramètre | Description | Exemple |
|
||||
|-----------|-------------|---------|
|
||||
| `url` | URL du document (obligatoire) | `?url=https://...` |
|
||||
| `maxwidth` | Largeur max (optionnel) | `?maxwidth=800` |
|
||||
| `maxheight` | Hauteur max (optionnel) | `?maxheight=300` |
|
||||
|
||||
### Discovery
|
||||
|
||||
Toutes les pages incluent la balise de discovery :
|
||||
|
||||
```html
|
||||
<link rel="alternate"
|
||||
type="application/json+oembed"
|
||||
href="https://sign.company.com/oembed?url=..."
|
||||
title="Document title" />
|
||||
```
|
||||
|
||||
## Personnalisation
|
||||
|
||||
### Thème Dark Mode
|
||||
|
||||
Le widget détecte automatiquement le dark mode du navigateur :
|
||||
|
||||
```css
|
||||
@media (prefers-color-scheme: dark) {
|
||||
/* Thème sombre automatique */
|
||||
}
|
||||
```
|
||||
|
||||
### Taille Personnalisée
|
||||
|
||||
```html
|
||||
<iframe src="https://sign.company.com/?doc=policy_2025"
|
||||
width="800"
|
||||
height="300"
|
||||
frameborder="0">
|
||||
</iframe>
|
||||
```
|
||||
|
||||
### Langue
|
||||
|
||||
Le widget détecte automatiquement la langue du navigateur :
|
||||
- `fr` - Français
|
||||
- `en` - English
|
||||
- `es` - Español
|
||||
- `de` - Deutsch
|
||||
- `it` - Italiano
|
||||
|
||||
## Sécurité
|
||||
|
||||
### iFrame Sandboxing
|
||||
|
||||
Par défaut, les iframes Ackify autorisent :
|
||||
- `allow-same-origin` - Cookies OAuth2
|
||||
- `allow-scripts` - Fonctionnalités Vue.js
|
||||
- `allow-forms` - Soumission de signatures
|
||||
- `allow-popups` - OAuth redirect
|
||||
|
||||
### CORS
|
||||
|
||||
Ackify configure automatiquement CORS pour :
|
||||
- Toutes les origines (lecture publique)
|
||||
- Credentials via `Access-Control-Allow-Credentials`
|
||||
|
||||
### CSP
|
||||
|
||||
Content Security Policy headers configurés pour permettre l'embedding :
|
||||
|
||||
```
|
||||
X-Frame-Options: SAMEORIGIN
|
||||
Content-Security-Policy: frame-ancestors 'self' https://notion.so https://outline.com
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### L'iframe ne s'affiche pas
|
||||
|
||||
Vérifier :
|
||||
- HTTPS activé (required pour OAuth)
|
||||
- CSP headers permettent l'embedding
|
||||
- Pas de bloqueur de contenu (uBlock, Privacy Badger)
|
||||
|
||||
### oEmbed non détecté
|
||||
|
||||
Vérifier :
|
||||
- La balise `<link rel="alternate" type="application/json+oembed">` est présente
|
||||
- L'URL est exacte (avec `?doc=...`)
|
||||
- La plateforme supporte oEmbed discovery
|
||||
|
||||
### Preview Slack vide
|
||||
|
||||
Vérifier :
|
||||
- Open Graph meta tags présents
|
||||
- URL publiquement accessible
|
||||
- Pas de redirect infini
|
||||
|
||||
## Exemples Complets
|
||||
|
||||
Voir :
|
||||
- [docs/integrations/google-doc/](../integrations/google-doc/) - Intégration Google Workspace
|
||||
- Plus d'exemples à venir...
|
||||
328
docs/fr/features/expected-signers.md
Normal file
328
docs/fr/features/expected-signers.md
Normal file
@@ -0,0 +1,328 @@
|
||||
# Expected Signers
|
||||
|
||||
Tracking des signataires attendus avec rappels email.
|
||||
|
||||
## Vue d'Ensemble
|
||||
|
||||
La feature "Expected Signers" permet de :
|
||||
- Définir qui doit signer un document
|
||||
- Tracker le taux de complétion
|
||||
- Envoyer des rappels email automatiques
|
||||
- Détecter les signatures inattendues
|
||||
|
||||
## Ajouter des Signataires
|
||||
|
||||
### Via le Dashboard Admin
|
||||
|
||||
1. Aller sur `/admin`
|
||||
2. Sélectionner un document
|
||||
3. Cliquer sur "Expected Signers"
|
||||
4. Coller la liste d'emails :
|
||||
|
||||
```
|
||||
Alice Smith <alice@company.com>
|
||||
bob@company.com
|
||||
charlie@company.com
|
||||
```
|
||||
|
||||
**Formats supportés** :
|
||||
- Un email par ligne
|
||||
- Emails séparés par virgules
|
||||
- Emails séparés par points-virgules
|
||||
- Format avec nom : `Alice Smith <alice@company.com>`
|
||||
|
||||
### Via l'API
|
||||
|
||||
```http
|
||||
POST /api/v1/admin/documents/policy_2025/signers
|
||||
Content-Type: application/json
|
||||
X-CSRF-Token: abc123
|
||||
|
||||
{
|
||||
"email": "alice@company.com",
|
||||
"name": "Alice Smith",
|
||||
"notes": "Engineering team lead"
|
||||
}
|
||||
```
|
||||
|
||||
### Ajout en Batch
|
||||
|
||||
```bash
|
||||
# Liste d'emails dans un fichier
|
||||
cat emails.txt | while read email; do
|
||||
curl -X POST http://localhost:8080/api/v1/admin/documents/policy_2025/signers \
|
||||
-b cookies.txt \
|
||||
-H "X-CSRF-Token: $CSRF_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"email\": \"$email\"}"
|
||||
done
|
||||
```
|
||||
|
||||
## Tracking de Complétion
|
||||
|
||||
### Dashboard Admin
|
||||
|
||||
Affiche :
|
||||
- **Barre de progression** - Visuelle avec pourcentage
|
||||
- **Liste des signataires** :
|
||||
- ✓ Email (signé le DD/MM/YYYY HH:MM)
|
||||
- ⏳ Email (en attente)
|
||||
- **Statistiques** :
|
||||
- Expected: 50
|
||||
- Signed: 42
|
||||
- Pending: 8
|
||||
- Completion: 84%
|
||||
|
||||
### Via l'API
|
||||
|
||||
```http
|
||||
GET /api/v1/documents/policy_2025/expected-signers
|
||||
```
|
||||
|
||||
**Response** :
|
||||
```json
|
||||
{
|
||||
"docId": "policy_2025",
|
||||
"expectedSigners": [
|
||||
{
|
||||
"email": "alice@company.com",
|
||||
"name": "Alice Smith",
|
||||
"addedAt": "2025-01-15T10:00:00Z",
|
||||
"hasSigned": true,
|
||||
"signedAt": "2025-01-15T14:30:00Z"
|
||||
},
|
||||
{
|
||||
"email": "bob@company.com",
|
||||
"name": "Bob Jones",
|
||||
"addedAt": "2025-01-15T10:00:00Z",
|
||||
"hasSigned": false
|
||||
}
|
||||
],
|
||||
"completionStats": {
|
||||
"expected": 50,
|
||||
"signed": 42,
|
||||
"pending": 8,
|
||||
"completionPercentage": 84.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Rappels Email
|
||||
|
||||
### Envoyer des Rappels
|
||||
|
||||
**Via le Dashboard** :
|
||||
1. Sélectionner les destinataires (ou "Select all pending")
|
||||
2. Choisir la langue (fr, en, es, de, it)
|
||||
3. Cliquer "Send Reminders"
|
||||
|
||||
**Via l'API** :
|
||||
```http
|
||||
POST /api/v1/admin/documents/policy_2025/reminders
|
||||
Content-Type: application/json
|
||||
X-CSRF-Token: abc123
|
||||
|
||||
{
|
||||
"emails": ["bob@company.com", "charlie@company.com"],
|
||||
"locale": "fr"
|
||||
}
|
||||
```
|
||||
|
||||
**Response** :
|
||||
```json
|
||||
{
|
||||
"sent": 2,
|
||||
"failed": 0,
|
||||
"errors": []
|
||||
}
|
||||
```
|
||||
|
||||
### Contenu de l'Email
|
||||
|
||||
Les templates sont dans `/backend/templates/emails/{locale}/reminder.html` :
|
||||
|
||||
```html
|
||||
Bonjour {{.RecipientName}},
|
||||
|
||||
Vous êtes attendu(e) pour signer le document "{{.DocumentTitle}}".
|
||||
|
||||
[Bouton: Signer maintenant] → {{.SignURL}}
|
||||
|
||||
Document disponible ici : {{.DocumentURL}}
|
||||
|
||||
Cordialement,
|
||||
{{.OrganisationName}}
|
||||
```
|
||||
|
||||
**Variables disponibles** :
|
||||
- `RecipientName` - Nom du destinataire
|
||||
- `DocumentTitle` - Titre du document
|
||||
- `DocumentURL` - URL du document (metadata)
|
||||
- `SignURL` - Lien direct vers la page de signature
|
||||
- `OrganisationName` - Nom de votre organisation
|
||||
|
||||
### Historique des Rappels
|
||||
|
||||
```http
|
||||
GET /api/v1/admin/documents/policy_2025/reminders
|
||||
```
|
||||
|
||||
**Response** :
|
||||
```json
|
||||
{
|
||||
"reminders": [
|
||||
{
|
||||
"recipientEmail": "bob@company.com",
|
||||
"sentAt": "2025-01-15T15:00:00Z",
|
||||
"sentBy": "admin@company.com",
|
||||
"status": "sent",
|
||||
"templateUsed": "reminder"
|
||||
},
|
||||
{
|
||||
"recipientEmail": "charlie@company.com",
|
||||
"sentAt": "2025-01-15T15:00:05Z",
|
||||
"sentBy": "admin@company.com",
|
||||
"status": "failed",
|
||||
"errorMessage": "SMTP timeout"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Statuts** :
|
||||
- `sent` - Envoyé avec succès
|
||||
- `failed` - Échec d'envoi
|
||||
- `bounced` - Email invalide (bounce)
|
||||
|
||||
## Signatures Inattendues
|
||||
|
||||
Détecte automatiquement les utilisateurs qui ont signé **sans être attendus**.
|
||||
|
||||
### Via le Dashboard
|
||||
|
||||
Section "Unexpected Signatures" affiche :
|
||||
```
|
||||
⚠️ 3 signatures inattendues détectées
|
||||
- stranger@external.com (signé le 15/01/2025)
|
||||
- unknown@gmail.com (signé le 16/01/2025)
|
||||
```
|
||||
|
||||
### Via l'API
|
||||
|
||||
Requête SQL pour détecter :
|
||||
```sql
|
||||
SELECT s.user_email, s.signed_at
|
||||
FROM signatures s
|
||||
LEFT JOIN expected_signers e ON s.user_email = e.email AND s.doc_id = e.doc_id
|
||||
WHERE s.doc_id = 'policy_2025' AND e.id IS NULL;
|
||||
```
|
||||
|
||||
## Cas d'Usage
|
||||
|
||||
### Formation Obligatoire
|
||||
|
||||
```
|
||||
Document: "GDPR Training 2025"
|
||||
Expected: Tous les employés (CSV import)
|
||||
```
|
||||
|
||||
**Workflow** :
|
||||
1. Import CSV avec emails employés
|
||||
2. Envoi du lien de signature à tous
|
||||
3. Rappel automatique J+7 aux non-signants
|
||||
4. Export final pour RH
|
||||
|
||||
### Politique de Sécurité
|
||||
|
||||
```
|
||||
Document: "Security Policy v3"
|
||||
Expected: Engineers + DevOps (50 personnes)
|
||||
```
|
||||
|
||||
**Features utilisées** :
|
||||
- Tracking temps réel (tableau de bord)
|
||||
- Rappels sélectifs (seulement certains)
|
||||
- Métadonnées document (URL + checksum)
|
||||
|
||||
### Contractuel
|
||||
|
||||
```
|
||||
Document: "NDA 2025"
|
||||
Expected: Prestataires externes (liste manuelle)
|
||||
```
|
||||
|
||||
**Particularité** :
|
||||
- Domaine OAuth restreint désactivé
|
||||
- Permet aux emails externes de signer
|
||||
- Detection des signatures inattendues cruciale
|
||||
|
||||
## Retirer un Signataire
|
||||
|
||||
```http
|
||||
DELETE /api/v1/admin/documents/policy_2025/signers/alice@company.com
|
||||
X-CSRF-Token: abc123
|
||||
```
|
||||
|
||||
**Comportement** :
|
||||
- Retire de la liste expected_signers
|
||||
- La signature (si existante) reste en base
|
||||
- Le taux de complétion est recalculé
|
||||
|
||||
## Configuration Email
|
||||
|
||||
Pour que les rappels fonctionnent, configurer SMTP :
|
||||
|
||||
```bash
|
||||
ACKIFY_MAIL_HOST=smtp.gmail.com
|
||||
ACKIFY_MAIL_PORT=587
|
||||
ACKIFY_MAIL_USERNAME=noreply@company.com
|
||||
ACKIFY_MAIL_PASSWORD=app_password
|
||||
ACKIFY_MAIL_FROM=noreply@company.com
|
||||
```
|
||||
|
||||
Voir [Email Setup](../configuration/email-setup.md) pour plus de détails.
|
||||
|
||||
## Bonnes Pratiques
|
||||
|
||||
### Import CSV
|
||||
|
||||
Pour importer massivement :
|
||||
|
||||
```python
|
||||
import csv
|
||||
import requests
|
||||
|
||||
with open('employees.csv') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
requests.post(
|
||||
'http://localhost:8080/api/v1/admin/documents/policy_2025/signers',
|
||||
json={'email': row['email'], 'name': row['name']},
|
||||
headers={'X-CSRF-Token': csrf_token},
|
||||
cookies=cookies
|
||||
)
|
||||
```
|
||||
|
||||
### Personnalisation
|
||||
|
||||
Pour des rappels plus personnalisés :
|
||||
1. Modifier les templates dans `/backend/templates/emails/`
|
||||
2. Ajouter des variables custom dans le service email
|
||||
3. Rebuild l'image Docker
|
||||
|
||||
### Monitoring
|
||||
|
||||
Surveiller les `reminder_logs` pour détecter :
|
||||
- Taux de bounce élevé (emails invalides)
|
||||
- Échecs SMTP répétés
|
||||
- Efficacité des rappels (taux de conversion)
|
||||
|
||||
## Limitations
|
||||
|
||||
- Maximum **1000 expected signers** par document (soft limit)
|
||||
- Rappels envoyés **synchrones** (pas de queue)
|
||||
- Pas de rappels automatiques planifiés (manuel uniquement)
|
||||
|
||||
## API Reference
|
||||
|
||||
Voir [API Documentation](../api.md#expected-signers-admin) pour tous les endpoints.
|
||||
320
docs/fr/features/i18n.md
Normal file
320
docs/fr/features/i18n.md
Normal file
@@ -0,0 +1,320 @@
|
||||
# Internationalisation (i18n)
|
||||
|
||||
Support multilingue complet du frontend Ackify.
|
||||
|
||||
## Langues Supportées
|
||||
|
||||
- 🇫🇷 **Français** (par défaut)
|
||||
- 🇬🇧 **English** (fallback)
|
||||
- 🇪🇸 **Español**
|
||||
- 🇩🇪 **Deutsch**
|
||||
- 🇮🇹 **Italiano**
|
||||
|
||||
## Frontend (Vue.js)
|
||||
|
||||
### Sélection de Langue
|
||||
|
||||
Le frontend détecte automatiquement la langue via :
|
||||
|
||||
1. **localStorage** - Choix utilisateur sauvegardé
|
||||
2. **navigator.language** - Langue du navigateur
|
||||
3. **Fallback** - English si non supportée
|
||||
|
||||
### Switcher de Langue
|
||||
|
||||
Interface utilisateur avec drapeaux Unicode :
|
||||
|
||||
```
|
||||
🇫🇷 FR | 🇬🇧 EN | 🇪🇸 ES | 🇩🇪 DE | 🇮🇹 IT
|
||||
```
|
||||
|
||||
Clic → Langue change + sauvegarde dans localStorage
|
||||
|
||||
### Fichiers de Traduction
|
||||
|
||||
Localisés dans `/webapp/src/locales/` :
|
||||
|
||||
```
|
||||
locales/
|
||||
├── fr.json # Français
|
||||
├── en.json # English
|
||||
├── es.json # Español
|
||||
├── de.json # Deutsch
|
||||
└── it.json # Italiano
|
||||
```
|
||||
|
||||
### Structure JSON
|
||||
|
||||
```json
|
||||
{
|
||||
"home": {
|
||||
"title": "Ackify - Proof of Read",
|
||||
"subtitle": "Signatures cryptographiques de lecture"
|
||||
},
|
||||
"document": {
|
||||
"sign": "Signer ce document",
|
||||
"signed": "Document signé",
|
||||
"signatures": "{count} confirmation | {count} confirmations"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pluralisation** :
|
||||
```json
|
||||
"signatures": "{count} confirmation | {count} confirmations"
|
||||
```
|
||||
|
||||
Usage :
|
||||
```vue
|
||||
{{ $t('document.signatures', { count: 42 }) }}
|
||||
// → "42 confirmations"
|
||||
```
|
||||
|
||||
## Backend (Go)
|
||||
|
||||
### Templates Email
|
||||
|
||||
Les emails utilisent des templates multilingues dans `/backend/templates/emails/` :
|
||||
|
||||
```
|
||||
templates/emails/
|
||||
├── fr/
|
||||
│ ├── reminder.html
|
||||
│ └── reminder.txt
|
||||
├── en/
|
||||
│ ├── reminder.html
|
||||
│ └── reminder.txt
|
||||
├── es/...
|
||||
├── de/...
|
||||
└── it/...
|
||||
```
|
||||
|
||||
### Envoi avec Locale
|
||||
|
||||
```http
|
||||
POST /api/v1/admin/documents/doc_id/reminders
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"emails": ["user@company.com"],
|
||||
"locale": "fr"
|
||||
}
|
||||
```
|
||||
|
||||
Le backend charge le template `fr/reminder.html`.
|
||||
|
||||
### Configuration
|
||||
|
||||
```bash
|
||||
# Langue par défaut pour les emails (défaut: en)
|
||||
ACKIFY_MAIL_DEFAULT_LOCALE=fr
|
||||
```
|
||||
|
||||
## Ajouter une Langue
|
||||
|
||||
### Frontend
|
||||
|
||||
1. **Créer le fichier de traduction** :
|
||||
|
||||
```bash
|
||||
cd webapp/src/locales
|
||||
cp en.json pt.json # Portugais
|
||||
```
|
||||
|
||||
2. **Traduire** :
|
||||
|
||||
```json
|
||||
{
|
||||
"home": {
|
||||
"title": "Ackify - Prova de Leitura",
|
||||
"subtitle": "Assinaturas criptográficas de leitura"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Enregistrer dans i18n** :
|
||||
|
||||
```typescript
|
||||
// webapp/src/i18n.ts
|
||||
import pt from './locales/pt.json'
|
||||
|
||||
const i18n = createI18n({
|
||||
locale: 'fr',
|
||||
fallbackLocale: 'en',
|
||||
messages: {
|
||||
fr, en, es, de, it,
|
||||
pt // Ajouter ici
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
4. **Ajouter au sélecteur** :
|
||||
|
||||
```vue
|
||||
<!-- components/LanguageSwitcher.vue -->
|
||||
<button @click="changeLocale('pt')">🇵🇹 PT</button>
|
||||
```
|
||||
|
||||
### Backend
|
||||
|
||||
1. **Créer le répertoire** :
|
||||
|
||||
```bash
|
||||
mkdir -p backend/templates/emails/pt
|
||||
```
|
||||
|
||||
2. **Créer les templates** :
|
||||
|
||||
```bash
|
||||
cp backend/templates/emails/en/reminder.html backend/templates/emails/pt/
|
||||
cp backend/templates/emails/en/reminder.txt backend/templates/emails/pt/
|
||||
```
|
||||
|
||||
3. **Traduire les templates**
|
||||
|
||||
4. **Rebuild** :
|
||||
|
||||
```bash
|
||||
docker compose up -d --force-recreate ackify-ce --build
|
||||
```
|
||||
|
||||
## Vérification i18n
|
||||
|
||||
### Script de Validation
|
||||
|
||||
Le projet inclut un script pour vérifier la complétude des traductions :
|
||||
|
||||
```bash
|
||||
cd webapp
|
||||
npm run lint:i18n
|
||||
```
|
||||
|
||||
**Output** :
|
||||
```
|
||||
✅ fr.json - 156 keys
|
||||
✅ en.json - 156 keys
|
||||
✅ es.json - 156 keys
|
||||
✅ de.json - 156 keys
|
||||
✅ it.json - 156 keys
|
||||
|
||||
All translations are complete!
|
||||
```
|
||||
|
||||
### CI/CD
|
||||
|
||||
Le script s'exécute automatiquement dans GitHub Actions pour bloquer les PRs avec traductions manquantes.
|
||||
|
||||
## Bonnes Pratiques
|
||||
|
||||
### Clés de Traduction
|
||||
|
||||
- ✅ Utiliser des clés structurées : `feature.action.label`
|
||||
- ✅ Grouper par page/composant
|
||||
- ✅ Éviter les clés trop génériques (`button`, `title`)
|
||||
- ✅ Utiliser des placeholders : `{count}`, `{name}`
|
||||
|
||||
**Exemple** :
|
||||
```json
|
||||
{
|
||||
"admin": {
|
||||
"documents": {
|
||||
"list": {
|
||||
"title": "Liste des documents",
|
||||
"count": "{count} document | {count} documents"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Synchronisation
|
||||
|
||||
Lors de l'ajout de nouvelles clés en français :
|
||||
|
||||
```bash
|
||||
# Sync script (à créer)
|
||||
node scripts/sync-i18n-from-fr.js
|
||||
```
|
||||
|
||||
Copie automatiquement les nouvelles clés vers les autres langues avec `[TODO]`.
|
||||
|
||||
### Textes Longs
|
||||
|
||||
Pour les textes longs, utiliser des arrays :
|
||||
|
||||
```json
|
||||
{
|
||||
"help": {
|
||||
"intro": [
|
||||
"Ackify permet de créer des signatures cryptographiques.",
|
||||
"Chaque signature est horodatée et non-répudiable.",
|
||||
"Les données sont stockées de manière immuable."
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Usage :
|
||||
```vue
|
||||
<p v-for="line in $tm('help.intro')" :key="line">
|
||||
{{ line }}
|
||||
</p>
|
||||
```
|
||||
|
||||
## Formats Spécifiques
|
||||
|
||||
### Dates
|
||||
|
||||
```typescript
|
||||
// Formater avec la locale courante
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { locale } = useI18n()
|
||||
const formatted = new Date().toLocaleDateString(locale.value, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
// fr: "15 janvier 2025"
|
||||
// en: "January 15, 2025"
|
||||
```
|
||||
|
||||
### Nombres
|
||||
|
||||
```typescript
|
||||
const formatted = (42000).toLocaleString(locale.value)
|
||||
// fr: "42 000"
|
||||
// en: "42,000"
|
||||
```
|
||||
|
||||
## SEO & Meta Tags
|
||||
|
||||
Les meta tags sont traduits dynamiquement :
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useHead } from '@vueuse/head'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
useHead({
|
||||
title: t('home.title'),
|
||||
meta: [
|
||||
{ name: 'description', content: t('home.description') }
|
||||
]
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Documentation Complète
|
||||
|
||||
Pour plus de détails sur l'implémentation i18n du frontend, voir :
|
||||
|
||||
**[webapp/I18N.md](../../webapp/I18N.md)**
|
||||
|
||||
Ce fichier contient :
|
||||
- Architecture complète vue-i18n
|
||||
- Guide de contribution
|
||||
- Scripts de synchronisation
|
||||
- Exemples avancés
|
||||
235
docs/fr/features/signatures.md
Normal file
235
docs/fr/features/signatures.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# Signatures Cryptographiques
|
||||
|
||||
Flow complet de signature avec Ed25519 et garanties de sécurité.
|
||||
|
||||
## Principe
|
||||
|
||||
Ackify utilise **Ed25519** (courbe elliptique) pour créer des signatures cryptographiques non-répudiables.
|
||||
|
||||
**Garanties** :
|
||||
- ✅ **Non-répudiation** - La signature prouve l'identité du signataire
|
||||
- ✅ **Intégrité** - Le hash SHA-256 détecte toute modification
|
||||
- ✅ **Horodatage immutable** - Triggers PostgreSQL empêchent la backdating
|
||||
- ✅ **Unicité** - Une seule signature par utilisateur/document
|
||||
|
||||
## Flow de Signature
|
||||
|
||||
### 1. Utilisateur accède au document
|
||||
|
||||
```
|
||||
https://sign.company.com/?doc=policy_2025
|
||||
```
|
||||
|
||||
Le frontend Vue.js charge et affiche :
|
||||
- Titre du document (si metadata existe)
|
||||
- Nombre de signatures existantes
|
||||
- Bouton "Sign this document"
|
||||
|
||||
### 2. Vérification de session
|
||||
|
||||
Le frontend appelle :
|
||||
```http
|
||||
GET /api/v1/users/me
|
||||
```
|
||||
|
||||
**Si non connecté** → Redirection OAuth2
|
||||
**Si connecté** → Affichage du bouton de signature
|
||||
|
||||
### 3. Signature
|
||||
|
||||
Au clic sur "Sign", le frontend :
|
||||
|
||||
1. Obtient un token CSRF :
|
||||
```http
|
||||
GET /api/v1/csrf
|
||||
```
|
||||
|
||||
2. Envoie la signature :
|
||||
```http
|
||||
POST /api/v1/signatures
|
||||
Content-Type: application/json
|
||||
X-CSRF-Token: abc123
|
||||
|
||||
{
|
||||
"doc_id": "policy_2025"
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Backend Processing
|
||||
|
||||
Le backend (Go) :
|
||||
|
||||
1. **Vérifie la session** - Utilisateur authentifié
|
||||
2. **Génère la signature Ed25519** :
|
||||
```go
|
||||
payload := fmt.Sprintf("%s:%s:%s:%s", docID, userSub, userEmail, timestamp)
|
||||
hash := sha256.Sum256([]byte(payload))
|
||||
signature := ed25519.Sign(privateKey, hash[:])
|
||||
```
|
||||
3. **Calcule prev_hash** - Hash de la dernière signature (chaînage)
|
||||
4. **Insère en base** :
|
||||
```sql
|
||||
INSERT INTO signatures (doc_id, user_sub, user_email, signed_at, payload_hash, signature, nonce, prev_hash)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
```
|
||||
5. **Retourne la signature** au frontend
|
||||
|
||||
### 5. Confirmation
|
||||
|
||||
Le frontend affiche :
|
||||
- ✅ Signature confirmée
|
||||
- Horodatage
|
||||
- Lien vers la liste des signatures
|
||||
|
||||
## Structure de la Signature
|
||||
|
||||
```json
|
||||
{
|
||||
"docId": "policy_2025",
|
||||
"userEmail": "alice@company.com",
|
||||
"userName": "Alice Smith",
|
||||
"signedAt": "2025-01-15T14:30:00Z",
|
||||
"payloadHash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"signature": "ed25519:3045022100...",
|
||||
"nonce": "abc123xyz",
|
||||
"prevHash": "sha256:prev..."
|
||||
}
|
||||
```
|
||||
|
||||
**Champs** :
|
||||
- `payloadHash` - SHA-256 du payload (doc_id:user_sub:email:timestamp)
|
||||
- `signature` - Signature Ed25519 en base64
|
||||
- `nonce` - Protection anti-replay
|
||||
- `prevHash` - Hash de la signature précédente (blockchain-like)
|
||||
|
||||
## Vérification de Signature
|
||||
|
||||
### Manuelle (via API)
|
||||
|
||||
```http
|
||||
GET /api/v1/documents/policy_2025/signatures
|
||||
```
|
||||
|
||||
Retourne toutes les signatures avec :
|
||||
- Email signataire
|
||||
- Horodatage
|
||||
- Hash + signature
|
||||
|
||||
### Programmation (Go)
|
||||
|
||||
```go
|
||||
import "crypto/ed25519"
|
||||
|
||||
func VerifySignature(publicKey ed25519.PublicKey, payload, signature []byte) bool {
|
||||
hash := sha256.Sum256(payload)
|
||||
return ed25519.Verify(publicKey, hash[:], signature)
|
||||
}
|
||||
```
|
||||
|
||||
## Contraintes PostgreSQL
|
||||
|
||||
### Une signature par user/document
|
||||
|
||||
```sql
|
||||
UNIQUE (doc_id, user_sub)
|
||||
```
|
||||
|
||||
**Comportement** :
|
||||
- Si l'utilisateur tente de signer 2 fois → Erreur 409 Conflict
|
||||
- Le frontend détecte cela et affiche "Already signed"
|
||||
|
||||
### Immutabilité de `created_at`
|
||||
|
||||
Trigger PostgreSQL :
|
||||
```sql
|
||||
CREATE TRIGGER prevent_signatures_created_at_update
|
||||
BEFORE UPDATE ON signatures
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION prevent_created_at_update();
|
||||
```
|
||||
|
||||
**Garantie** : Impossible de backdater une signature.
|
||||
|
||||
## Chaînage (Blockchain-like)
|
||||
|
||||
Chaque signature référence la précédente via `prev_hash` :
|
||||
|
||||
```
|
||||
Signature 1 → hash1
|
||||
Signature 2 → hash2 (prev_hash = hash1)
|
||||
Signature 3 → hash3 (prev_hash = hash2)
|
||||
```
|
||||
|
||||
**Détection de tampering** :
|
||||
- Si une signature est modifiée, le `prev_hash` de la suivante ne correspond plus
|
||||
- Permet de détecter toute modification de l'historique
|
||||
|
||||
## Sécurité
|
||||
|
||||
### Clé Privée Ed25519
|
||||
|
||||
Générée automatiquement au premier démarrage ou via :
|
||||
|
||||
```bash
|
||||
ACKIFY_ED25519_PRIVATE_KEY=$(openssl rand -base64 64)
|
||||
```
|
||||
|
||||
**Important** :
|
||||
- La clé privée ne quitte jamais le serveur
|
||||
- Stockée en mémoire uniquement (pas en base)
|
||||
- Backup requis si vous voulez garder la même clé après redéploiement
|
||||
|
||||
### Protection Anti-Replay
|
||||
|
||||
Le `nonce` unique empêche la réutilisation d'une signature :
|
||||
```go
|
||||
nonce := fmt.Sprintf("%s-%d", userSub, time.Now().UnixNano())
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
Les signatures sont limitées à **100 requêtes/minute** par IP.
|
||||
|
||||
## Cas d'Usage
|
||||
|
||||
### Validation de lecture de politique
|
||||
|
||||
```
|
||||
Document: "Security Policy 2025"
|
||||
URL: https://sign.company.com/?doc=security_policy_2025
|
||||
```
|
||||
|
||||
**Workflow** :
|
||||
1. Admin envoie le lien aux employés
|
||||
2. Chaque employé clique, lit, et signe
|
||||
3. Admin voit la completion dans `/admin`
|
||||
|
||||
### Accusé de réception formation
|
||||
|
||||
```
|
||||
Document: "GDPR Training 2025"
|
||||
Expected signers: 50 employés
|
||||
```
|
||||
|
||||
**Features** :
|
||||
- Tracking de complétion (42/50 = 84%)
|
||||
- Rappels email automatiques
|
||||
- Export des signatures
|
||||
|
||||
### Acknowledgment contractuel
|
||||
|
||||
```
|
||||
Document: "Terms of Service v3"
|
||||
Checksum: SHA-256 du PDF
|
||||
```
|
||||
|
||||
**Vérification** :
|
||||
- Utilisateur calcule le checksum du PDF
|
||||
- Compare avec la metadata stockée
|
||||
- Signe si identique
|
||||
|
||||
Voir [Checksums](checksums.md) pour plus de détails.
|
||||
|
||||
## API Reference
|
||||
|
||||
Voir [API Documentation](../api.md) pour tous les endpoints liés aux signatures.
|
||||
208
docs/fr/getting-started.md
Normal file
208
docs/fr/getting-started.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# Getting Started
|
||||
|
||||
Guide d'installation et de configuration d'Ackify avec Docker Compose.
|
||||
|
||||
## Prérequis
|
||||
|
||||
- Docker et Docker Compose installés
|
||||
- Un domaine (ou localhost pour les tests)
|
||||
- Credentials OAuth2 (Google, GitHub, GitLab, ou custom)
|
||||
|
||||
## Installation Rapide
|
||||
|
||||
### 1. Cloner le dépôt
|
||||
|
||||
```bash
|
||||
git clone https://github.com/btouchard/ackify-ce.git
|
||||
cd ackify-ce
|
||||
```
|
||||
|
||||
### 2. Configuration
|
||||
|
||||
Copier le fichier d'exemple et l'éditer :
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
**Variables obligatoires minimales** :
|
||||
|
||||
```bash
|
||||
# Domaine public de votre instance
|
||||
APP_DNS=sign.your-domain.com
|
||||
ACKIFY_BASE_URL=https://sign.your-domain.com
|
||||
ACKIFY_ORGANISATION="Your Organization Name"
|
||||
|
||||
# Base de données PostgreSQL
|
||||
POSTGRES_USER=ackifyr
|
||||
POSTGRES_PASSWORD=your_secure_password_here
|
||||
POSTGRES_DB=ackify
|
||||
|
||||
# OAuth2 (exemple avec Google)
|
||||
ACKIFY_OAUTH_PROVIDER=google
|
||||
ACKIFY_OAUTH_CLIENT_ID=your_google_client_id
|
||||
ACKIFY_OAUTH_CLIENT_SECRET=your_google_client_secret
|
||||
|
||||
# Sécurité - générer avec: openssl rand -base64 32
|
||||
ACKIFY_OAUTH_COOKIE_SECRET=your_base64_encoded_secret_key
|
||||
```
|
||||
|
||||
### 3. Démarrage
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Cette commande va :
|
||||
- Télécharger les images Docker nécessaires
|
||||
- Démarrer PostgreSQL avec healthcheck
|
||||
- Appliquer les migrations de base de données
|
||||
- Lancer l'application Ackify
|
||||
|
||||
### 4. Vérification
|
||||
|
||||
```bash
|
||||
# Voir les logs
|
||||
docker compose logs -f ackify-ce
|
||||
|
||||
# Vérifier le health check
|
||||
curl http://localhost:8080/api/v1/health
|
||||
# Attendu: {"status":"healthy","database":"connected"}
|
||||
```
|
||||
|
||||
### 5. Accès à l'interface
|
||||
|
||||
Ouvrir votre navigateur :
|
||||
- **Interface publique** : http://localhost:8080
|
||||
- **Admin dashboard** : http://localhost:8080/admin (nécessite email dans ACKIFY_ADMIN_EMAILS)
|
||||
|
||||
## Configuration OAuth2
|
||||
|
||||
Avant de pouvoir utiliser Ackify, configurez votre provider OAuth2.
|
||||
|
||||
### Google OAuth2
|
||||
|
||||
1. Aller sur [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. Créer un nouveau projet ou sélectionner un projet existant
|
||||
3. Activer l'API "Google+ API"
|
||||
4. Créer des credentials OAuth 2.0 :
|
||||
- Type : Web application
|
||||
- Authorized redirect URIs : `https://sign.your-domain.com/api/v1/auth/callback`
|
||||
5. Copier le Client ID et Client Secret dans `.env`
|
||||
|
||||
```bash
|
||||
ACKIFY_OAUTH_PROVIDER=google
|
||||
ACKIFY_OAUTH_CLIENT_ID=123456789-abc.apps.googleusercontent.com
|
||||
ACKIFY_OAUTH_CLIENT_SECRET=GOCSPX-xyz...
|
||||
```
|
||||
|
||||
### GitHub OAuth2
|
||||
|
||||
1. Aller sur [GitHub Developer Settings](https://github.com/settings/developers)
|
||||
2. Créer une nouvelle OAuth App
|
||||
3. Configuration :
|
||||
- Homepage URL : `https://sign.your-domain.com`
|
||||
- Callback URL : `https://sign.your-domain.com/api/v1/auth/callback`
|
||||
4. Générer un client secret
|
||||
|
||||
```bash
|
||||
ACKIFY_OAUTH_PROVIDER=github
|
||||
ACKIFY_OAUTH_CLIENT_ID=Iv1.abc123
|
||||
ACKIFY_OAUTH_CLIENT_SECRET=ghp_xyz...
|
||||
```
|
||||
|
||||
Voir [OAuth Providers](configuration/oauth-providers.md) pour GitLab et custom providers.
|
||||
|
||||
## Génération des Secrets
|
||||
|
||||
```bash
|
||||
# Cookie secret (obligatoire)
|
||||
openssl rand -base64 32
|
||||
|
||||
# Ed25519 private key (optionnel, auto-généré si absent)
|
||||
openssl rand -base64 64
|
||||
```
|
||||
|
||||
## Premiers Pas
|
||||
|
||||
### Créer votre première signature
|
||||
|
||||
1. Accéder à `http://localhost:8080/?doc=test_document`
|
||||
2. Cliquer sur "Sign this document"
|
||||
3. Se connecter via OAuth2
|
||||
4. Valider la signature
|
||||
|
||||
### Accéder au dashboard admin
|
||||
|
||||
1. Ajouter votre email dans `.env` :
|
||||
```bash
|
||||
ACKIFY_ADMIN_EMAILS=admin@company.com
|
||||
```
|
||||
2. Redémarrer :
|
||||
```bash
|
||||
docker compose restart ackify-ce
|
||||
```
|
||||
3. Se connecter puis accéder à `/admin`
|
||||
|
||||
### Intégrer dans une page
|
||||
|
||||
```html
|
||||
<!-- Widget embeddable -->
|
||||
<iframe src="https://sign.your-domain.com/?doc=test_document"
|
||||
width="600" height="200"
|
||||
frameborder="0"
|
||||
style="border: 1px solid #ddd; border-radius: 6px;"></iframe>
|
||||
```
|
||||
|
||||
## Commandes Utiles
|
||||
|
||||
```bash
|
||||
# Voir les logs
|
||||
docker compose logs -f ackify-ce
|
||||
|
||||
# Redémarrer
|
||||
docker compose restart ackify-ce
|
||||
|
||||
# Arrêter
|
||||
docker compose down
|
||||
|
||||
# Reconstruire après modifications
|
||||
docker compose up -d --force-recreate ackify-ce --build
|
||||
|
||||
# Accéder à la base de données
|
||||
docker compose exec ackify-db psql -U ackifyr -d ackify
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### L'application ne démarre pas
|
||||
|
||||
```bash
|
||||
# Vérifier les logs
|
||||
docker compose logs ackify-ce
|
||||
|
||||
# Vérifier la santé de PostgreSQL
|
||||
docker compose ps ackify-db
|
||||
```
|
||||
|
||||
### Erreur de migration
|
||||
|
||||
```bash
|
||||
# Relancer les migrations manuellement
|
||||
docker compose up ackify-migrate
|
||||
```
|
||||
|
||||
### OAuth callback error
|
||||
|
||||
Vérifier que :
|
||||
- `ACKIFY_BASE_URL` correspond exactement à votre domaine
|
||||
- La callback URL dans le provider OAuth2 est correcte
|
||||
- Le cookie secret est bien configuré
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Configuration complète](configuration.md)
|
||||
- [Déploiement en production](deployment.md)
|
||||
- [Configuration des fonctionnalités](features/)
|
||||
- [API Reference](api.md)
|
||||
@@ -1,6 +1,4 @@
|
||||
# Application Configuration
|
||||
APP_NAME=ackify-ce
|
||||
APP_DNS=your-domain.com
|
||||
APP_BASE_URL=https://your-domain.com
|
||||
APP_ORGANISATION="Your Organization Name"
|
||||
|
||||
|
||||
@@ -2,23 +2,51 @@
|
||||
name: ackify-ce
|
||||
|
||||
services:
|
||||
ackify-migrate:
|
||||
image: btouchard/ackify-ce:latest
|
||||
container_name: ackify-migrate
|
||||
environment:
|
||||
ACKIFY_DB_DSN: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@ackify-db:5432/${POSTGRES_DB}?sslmode=disable"
|
||||
depends_on:
|
||||
ackify-db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- internal
|
||||
command: ["/app/migrate", "up"]
|
||||
entrypoint: []
|
||||
restart: "no"
|
||||
|
||||
ackify-ce:
|
||||
image: btouchard/ackify-ce:latest
|
||||
container_name: ackify-ce
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
ACKIFY_BASE_URL: "https://${APP_DNS}"
|
||||
ACKIFY_LOG_LEVEL: "${ACKIFY_LOG_LEVEL}"
|
||||
ACKIFY_BASE_URL: "${ACKIFY_BASE_URL}"
|
||||
ACKIFY_ORGANISATION: "${ACKIFY_ORGANISATION}"
|
||||
ACKIFY_ADMIN_EMAILS: "${ACKIFY_ADMIN_EMAILS}"
|
||||
ACKIFY_LISTEN_ADDR: ":8080"
|
||||
ACKIFY_DB_DSN: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@ackify-db:5432/${POSTGRES_DB}?sslmode=disable"
|
||||
ACKIFY_OAUTH_PROVIDER: "${ACKIFY_OAUTH_PROVIDER}"
|
||||
ACKIFY_OAUTH_CLIENT_ID: "${ACKIFY_OAUTH_CLIENT_ID}"
|
||||
ACKIFY_OAUTH_CLIENT_SECRET: "${ACKIFY_OAUTH_CLIENT_SECRET}"
|
||||
ACKIFY_OAUTH_ALLOWED_DOMAIN: "${ACKIFY_OAUTH_ALLOWED_DOMAIN}"
|
||||
ACKIFY_OAUTH_AUTH_URL: "${ACKIFY_OAUTH_AUTH_URL:-}"
|
||||
ACKIFY_OAUTH_TOKEN_URL: "${ACKIFY_OAUTH_TOKEN_URL:-}"
|
||||
ACKIFY_OAUTH_USERINFO_URL: "${ACKIFY_OAUTH_USERINFO_URL:-}"
|
||||
ACKIFY_OAUTH_LOGOUT_URL: "${ACKIFY_OAUTH_LOGOUT_URL:-}"
|
||||
ACKIFY_OAUTH_ALLOWED_DOMAIN: "${ACKIFY_OAUTH_ALLOWED_DOMAIN:-}"
|
||||
ACKIFY_OAUTH_COOKIE_SECRET: "${ACKIFY_OAUTH_COOKIE_SECRET}"
|
||||
ACKIFY_DB_DSN: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@ackify_db:5432/${POSTGRES_DB}?sslmode=disable"
|
||||
ACKIFY_ED25519_PRIVATE_KEY: "${ACKIFY_ED25519_PRIVATE_KEY}"
|
||||
ACKIFY_LISTEN_ADDR: ":8080"
|
||||
ACKIFY_MAIL_HOST: "${ACKIFY_MAIL_HOST:-}"
|
||||
ACKIFY_MAIL_PORT: "${ACKIFY_MAIL_PORT:-}"
|
||||
ACKIFY_MAIL_TLS: "false"
|
||||
ACKIFY_MAIL_STARTTLS: "false"
|
||||
ACKIFY_MAIL_FROM: "${ACKIFY_MAIL_FROM:-}"
|
||||
ACKIFY_MAIL_FROM_NAME: "${ACKIFY_MAIL_FROM_NAME:-}"
|
||||
depends_on:
|
||||
ackify_db:
|
||||
ackify-migrate:
|
||||
condition: service_completed_successfully
|
||||
ackify-db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- internal
|
||||
@@ -282,6 +282,24 @@
|
||||
"home": "Zurück zur Startseite"
|
||||
},
|
||||
"footer": {
|
||||
"description": "Open-Source-Lösung für kryptographische Dokumentenlesebestätigung mit unwiderruflichen Ed25519-Signaturen.",
|
||||
"navigation": {
|
||||
"title": "Navigation"
|
||||
},
|
||||
"resources": {
|
||||
"title": "Ressourcen",
|
||||
"documentation": "Dokumentation",
|
||||
"apiReference": "API-Referenz",
|
||||
"support": "Support"
|
||||
},
|
||||
"legal": {
|
||||
"title": "Rechtliches",
|
||||
"terms": "Nutzungsbedingungen",
|
||||
"privacy": "Datenschutzerklärung",
|
||||
"contact": "Kontakt"
|
||||
},
|
||||
"copyright": "Alle Rechte vorbehalten.",
|
||||
"license": "Lizenziert unter AGPL-3.0-or-later",
|
||||
"madeWith": "Erstellt mit",
|
||||
"by": "von",
|
||||
"links": {
|
||||
|
||||
@@ -282,6 +282,24 @@
|
||||
"home": "Back to home"
|
||||
},
|
||||
"footer": {
|
||||
"description": "Open-source cryptographic document reading confirmation solution with non-repudiable Ed25519 signatures.",
|
||||
"navigation": {
|
||||
"title": "Navigation"
|
||||
},
|
||||
"resources": {
|
||||
"title": "Resources",
|
||||
"documentation": "Documentation",
|
||||
"apiReference": "API Reference",
|
||||
"support": "Support"
|
||||
},
|
||||
"legal": {
|
||||
"title": "Legal",
|
||||
"terms": "Terms of Service",
|
||||
"privacy": "Privacy Policy",
|
||||
"contact": "Contact"
|
||||
},
|
||||
"copyright": "All rights reserved.",
|
||||
"license": "Licensed under AGPL-3.0-or-later",
|
||||
"madeWith": "Made with",
|
||||
"by": "by",
|
||||
"links": {
|
||||
|
||||
@@ -282,6 +282,24 @@
|
||||
"home": "Volver al inicio"
|
||||
},
|
||||
"footer": {
|
||||
"description": "Solución de código abierto de confirmación criptográfica de lectura de documentos con firmas Ed25519 no repudiables.",
|
||||
"navigation": {
|
||||
"title": "Navegación"
|
||||
},
|
||||
"resources": {
|
||||
"title": "Recursos",
|
||||
"documentation": "Documentación",
|
||||
"apiReference": "Referencia API",
|
||||
"support": "Soporte"
|
||||
},
|
||||
"legal": {
|
||||
"title": "Legal",
|
||||
"terms": "Términos de uso",
|
||||
"privacy": "Política de privacidad",
|
||||
"contact": "Contacto"
|
||||
},
|
||||
"copyright": "Todos los derechos reservados.",
|
||||
"license": "Licencia AGPL-3.0-or-later",
|
||||
"madeWith": "Hecho con",
|
||||
"by": "por",
|
||||
"links": {
|
||||
|
||||
@@ -282,6 +282,24 @@
|
||||
"home": "Retour à l'accueil"
|
||||
},
|
||||
"footer": {
|
||||
"description": "Solution open-source de confirmation cryptographique de lecture de documents avec signatures Ed25519 non répudiables.",
|
||||
"navigation": {
|
||||
"title": "Navigation"
|
||||
},
|
||||
"resources": {
|
||||
"title": "Ressources",
|
||||
"documentation": "Documentation",
|
||||
"apiReference": "Référence API",
|
||||
"support": "Support"
|
||||
},
|
||||
"legal": {
|
||||
"title": "Légal",
|
||||
"terms": "Conditions d'utilisation",
|
||||
"privacy": "Politique de confidentialité",
|
||||
"contact": "Contact"
|
||||
},
|
||||
"copyright": "Tous droits réservés.",
|
||||
"license": "Licence AGPL-3.0-or-later",
|
||||
"madeWith": "Fait avec",
|
||||
"by": "par",
|
||||
"links": {
|
||||
|
||||
@@ -282,6 +282,24 @@
|
||||
"home": "Torna alla home"
|
||||
},
|
||||
"footer": {
|
||||
"description": "Soluzione open-source di conferma crittografica di lettura di documenti con firme Ed25519 non ripudiabili.",
|
||||
"navigation": {
|
||||
"title": "Navigazione"
|
||||
},
|
||||
"resources": {
|
||||
"title": "Risorse",
|
||||
"documentation": "Documentazione",
|
||||
"apiReference": "Riferimento API",
|
||||
"support": "Supporto"
|
||||
},
|
||||
"legal": {
|
||||
"title": "Legale",
|
||||
"terms": "Termini di utilizzo",
|
||||
"privacy": "Politica sulla privacy",
|
||||
"contact": "Contatto"
|
||||
},
|
||||
"copyright": "Tutti i diritti riservati.",
|
||||
"license": "Licenza AGPL-3.0-or-later",
|
||||
"madeWith": "Fatto con",
|
||||
"by": "da",
|
||||
"links": {
|
||||
|
||||
Reference in New Issue
Block a user