feat: infrastructure updates, storage verification, and superadmin reset

1. Auth: Implemented forced password reset for SuperAdmin and updated login logic.

2. Infra: Switched backend to internal Postgres and updated .drone.yml.

3. Storage: Added Test Connection endpoint and UI in Backoffice.

4. CI/CD: Updated Forgejo deploy pipeline to include Seeder and use Internal Registry.
This commit is contained in:
Yamamoto 2026-01-02 16:36:31 -03:00
parent b5e9ef60ef
commit 3cd52accfb
11 changed files with 243 additions and 28 deletions

View file

@ -10,11 +10,11 @@ on:
- 'frontend/**'
env:
REGISTRY: rg.fr-par.scw.cloud/funcscwinfrastructureascodehdz4uzhb
NAMESPACE: a5034510-9763-40e8-ac7e-1836e7a61460
REGISTRY: in.gohorsejobs.com
NAMESPACE: gohorsejobsdev
jobs:
# Job: Deploy no Servidor (Pull das imagens do Scaleway)
# Job: Deploy no Servidor (Pull das imagens do Forgejo)
deploy-dev:
runs-on: docker
steps:
@ -41,6 +41,11 @@ jobs:
else
echo "backoffice=false" >> $GITHUB_OUTPUT
fi
if git diff --name-only HEAD~1 HEAD | grep -q "^seeder-api/"; then
echo "seeder=true" >> $GITHUB_OUTPUT
else
echo "seeder=false" >> $GITHUB_OUTPUT
fi
- name: Deploy via SSH
uses: https://github.com/appleboy/ssh-action@v1.0.3
@ -50,33 +55,45 @@ jobs:
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.PORT || 22 }}
script: |
# Login no Scaleway Registry
echo "${{ secrets.SCW_SECRET_KEY }}" | podman login ${{ env.REGISTRY }} -u nologin --password-stdin
# Login no Forgejo Registry (usando segredos do Drone/Forgejo)
echo "${{ secrets.HARBOR_PASSWORD }}" | podman login ${{ env.REGISTRY }} -u ${{ secrets.HARBOR_USERNAME }} --password-stdin
# --- DEPLOY DO BACKEND ---
if [ "${{ steps.check.outputs.backend }}" == "true" ]; then
echo "Pulling e reiniciando Backend..."
podman pull ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/backend:dev-latest
podman tag ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/backend:dev-latest localhost/gohorsejobs-backend-dev:latest
# Nome da imagem no Drone: gohorsejobs-backend
podman pull ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/gohorsejobs-backend:latest
podman tag ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/gohorsejobs-backend:latest localhost/gohorsejobs-backend-dev:latest
sudo systemctl restart gohorsejobs-backend-dev
fi
# --- DEPLOY DO FRONTEND ---
if [ "${{ steps.check.outputs.frontend }}" == "true" ]; then
echo "Pulling e reiniciando Frontend..."
podman pull ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/frontend:dev-latest
podman tag ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/frontend:dev-latest localhost/gohorsejobs-frontend-dev:latest
# Assumindo gohorsejobs-frontend no mesmo namespace
podman pull ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/gohorsejobs-frontend:latest
podman tag ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/gohorsejobs-frontend:latest localhost/gohorsejobs-frontend-dev:latest
sudo systemctl restart gohorsejobs-frontend-dev
fi
# --- DEPLOY DO BACKOFFICE ---
if [ "${{ steps.check.outputs.backoffice }}" == "true" ]; then
echo "Pulling e reiniciando Backoffice..."
podman pull ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/backoffice:dev-latest
podman tag ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/backoffice:dev-latest localhost/gohorsejobs-backoffice-dev:latest
# Nome no Drone: backoffice
podman pull ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/backoffice:latest
podman tag ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/backoffice:latest localhost/gohorsejobs-backoffice-dev:latest
sudo systemctl restart gohorsejobs-backoffice-dev
fi
# --- DEPLOY DO SEEDER ---
if [ "${{ steps.check.outputs.seeder }}" == "true" ]; then
echo "Pulling e reiniciando Seeder..."
# Assumindo gohorsejobs-seeder
podman pull ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/gohorsejobs-seeder:latest
podman tag ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/gohorsejobs-seeder:latest localhost/gohorsejobs-seeder-dev:latest
sudo systemctl restart gohorsejobs-seeder-dev
fi
# --- LIMPEZA ---
echo "Limpando imagens antigas..."
podman image prune -f || true

View file

@ -0,0 +1,48 @@
package controllers
import (
"encoding/json"
"net/http"
"github.com/rede5/gohorsejobs/backend/internal/services"
)
type StorageController struct {
storageService *services.StorageService
}
func NewStorageController(storageService *services.StorageService) *StorageController {
return &StorageController{storageService: storageService}
}
// TestConnection verifies if the configured Object Storage is accessible
// @Summary Test Object Storage Connection
// @Description Checks if the current configuration (DB or Env) allows access to the S3 bucket.
// @Tags admin
// @Accept json
// @Produce json
// @Success 200 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /admin/storage/test-connection [post]
func (c *StorageController) TestConnection(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
err := c.storageService.TestConnection(ctx)
if err != nil {
sendError(w, http.StatusInternalServerError, "Connection failed: "+err.Error())
return
}
sendSuccess(w, map[string]string{"message": "Connection successful"})
}
func sendError(w http.ResponseWriter, code int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": message})
}
func sendSuccess(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(data)
}

View file

@ -16,8 +16,9 @@ const (
RoleCandidate = "candidate"
// User Status
UserStatusActive = "active"
UserStatusInactive = "inactive"
UserStatusActive = "active"
UserStatusInactive = "inactive"
UserStatusForceChangePassword = "force_change_password"
)
// User represents a user within a specific Tenant (Company).

View file

@ -8,8 +8,9 @@ type LoginRequest struct {
}
type AuthResponse struct {
Token string `json:"token"`
User UserResponse `json:"user"`
Token string `json:"token"`
User UserResponse `json:"user"`
MustChangePassword bool `json:"mustChangePassword"`
}
type CreateUserRequest struct {

View file

@ -48,11 +48,15 @@ func (uc *LoginUseCase) Execute(ctx context.Context, input dto.LoginRequest) (*d
fmt.Printf("[LOGIN DEBUG] Password verification PASSED\n")
// 3. Check Status
if !strings.EqualFold(user.Status, entity.UserStatusActive) {
fmt.Printf("[LOGIN DEBUG] Status check FAILED: Expected %s, got '%s'\n", entity.UserStatusActive, user.Status)
isActive := strings.EqualFold(user.Status, entity.UserStatusActive)
isForceChange := strings.EqualFold(user.Status, entity.UserStatusForceChangePassword)
if !isActive && !isForceChange {
fmt.Printf("[LOGIN DEBUG] Status check FAILED: Expected %s or %s, got '%s'\n",
entity.UserStatusActive, entity.UserStatusForceChangePassword, user.Status)
return nil, errors.New("account inactive")
}
fmt.Printf("[LOGIN DEBUG] Status check PASSED\n")
fmt.Printf("[LOGIN DEBUG] Status check PASSED (Active: %v, ForceChange: %v)\n", isActive, isForceChange)
// 4. Generate Token
roles := make([]string, len(user.Roles))
@ -68,7 +72,8 @@ func (uc *LoginUseCase) Execute(ctx context.Context, input dto.LoginRequest) (*d
// 5. Return Response
return &dto.AuthResponse{
Token: token,
Token: token,
MustChangePassword: isForceChange,
User: dto.UserResponse{
ID: user.ID,
Name: user.Name,

View file

@ -0,0 +1,52 @@
package auth_test
import (
"context"
"testing"
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
"github.com/rede5/gohorsejobs/backend/internal/core/dto"
"github.com/rede5/gohorsejobs/backend/internal/core/usecases/auth"
)
func TestLoginUseCase_Execute_ForceChangePassword(t *testing.T) {
// Setup Mocks (Reusing from register_candidate_test.go)
authSvc := &MockAuthService{
GenerateTokenFunc: func(userID, tenantID string, roles []string) (string, error) {
return "mock-token", nil
},
}
userRepo := &MockUserRepo{
FindByEmailFunc: func(ctx context.Context, email string) (*entity.User, error) {
return &entity.User{
ID: "lol-user",
Email: email,
PasswordHash: "hashed_pass",
Status: entity.UserStatusForceChangePassword,
Roles: []entity.Role{},
}, nil
},
}
uc := auth.NewLoginUseCase(userRepo, authSvc)
input := dto.LoginRequest{
Email: "lol@gohorsejobs.com",
Password: "password",
}
resp, err := uc.Execute(context.Background(), input)
if err != nil {
t.Fatalf("Expected no error for force_change_password status, got %v", err)
}
if resp == nil {
t.Fatal("Expected response, got nil")
}
if !resp.MustChangePassword {
t.Error("Expected MustChangePassword to be true, got false")
}
}

View file

@ -6,6 +6,7 @@ import (
"os"
"time"
// Added this import
"github.com/rede5/gohorsejobs/backend/internal/api/middleware"
"github.com/rede5/gohorsejobs/backend/internal/database"
"github.com/rede5/gohorsejobs/backend/internal/handlers"
@ -276,7 +277,7 @@ func NewRouter() http.Handler {
mux.HandleFunc("/docs/", httpSwagger.WrapHandler)
// Apply middleware chain: Security Headers -> Rate Limiting -> CORS -> Router
// Order matters: outer middleware runs first
// Order matters: outer middleware
var handler http.Handler = mux
handler = middleware.CORSMiddleware(handler)
handler = legacyMiddleware.SanitizeMiddleware(handler) // Sanitize XSS from JSON bodies

View file

@ -89,3 +89,54 @@ func (s *StorageService) GetPresignedUploadURL(ctx context.Context, key string,
return req.URL, nil
}
// TestConnection checks if the creds are valid and bucket is accessible
func (s *StorageService) TestConnection(ctx context.Context) error {
psClient, bucket, err := s.getClient(ctx)
if err != nil {
return fmt.Errorf("failed to get client: %w", err)
}
// Note: PresignClient doesn't strictly validate creds against the cloud until used,
// checking existence via HeadBucket or ListBuckets using a real S3 client would be better.
// But getClient returns a PresignClient.
// We need a standard client to Verify.
// Re-instantiating logic or Refactoring `getClient` to return `*s3.Client` is best.
// For now, let's refactor `getClient` slightly to expose specific logic or just create a one-off checker here.
// Refetch raw creds to make a standard client
payload, err := s.credentialsService.GetDecryptedKey(ctx, "storage")
if err != nil {
return fmt.Errorf("failed to get storage credentials: %w", err)
}
var uCfg UploadConfig
if err := json.Unmarshal([]byte(payload), &uCfg); err != nil {
return fmt.Errorf("failed to parse storage credentials: %w", err)
}
cfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion(uCfg.Region),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(uCfg.AccessKey, uCfg.SecretKey, "")),
)
if err != nil {
return err
}
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(uCfg.Endpoint)
o.UsePathStyle = true
})
// Try HeadBucket
_, err = client.HeadBucket(ctx, &s3.HeadBucketInput{
Bucket: aws.String(uCfg.Bucket),
})
if err != nil {
return fmt.Errorf("connection failed: %w", err)
}
// Just to be sure, presign client creation (original logic)
_ = psClient
_ = bucket
return nil
}

View file

@ -0,0 +1,12 @@
-- Migration: Update Super Admin to 'lol' and force password reset
-- Description: Updates the superadmin identifier, email, name, and sets status to enforce password change.
UPDATE users
SET
identifier = 'lol',
email = 'lol@gohorsejobs.com',
full_name = 'Dr. Horse Expert',
name = 'Dr. Horse Expert',
status = 'force_change_password',
updated_at = CURRENT_TIMESTAMP
WHERE identifier = 'superadmin' OR email = 'admin@gohorsejobs.com';

View file

@ -6,7 +6,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { settingsApi, credentialsApi, ConfiguredService } from "@/lib/api"
import { settingsApi, credentialsApi, ConfiguredService, storageApi } from "@/lib/api"
import { toast } from "sonner"
import { Loader2, Check, Key, Trash2, Eye, EyeOff } from "lucide-react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
@ -46,6 +46,20 @@ export default function SettingsPage() {
const [credentialPayload, setCredentialPayload] = useState<any>({})
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [testingConnection, setTestingConnection] = useState(false)
const handleTestStorageConnection = async () => {
setTestingConnection(true)
try {
const res = await storageApi.testConnection()
toast.success(res.message || "Connection successful")
} catch (error: any) {
console.error("Storage connection test failed", error)
toast.error(error.message || "Connection failed")
} finally {
setTestingConnection(false)
}
}
const fetchSettings = async () => {
try {
@ -303,15 +317,22 @@ export default function SettingsPage() {
</p>
</div>
<div className="flex items-center gap-2 mt-auto">
<Button variant="outline" size="sm" className="w-full" onClick={() => handleOpenCredentialDialog(svc.service_name)}>
<Key className="w-3 h-3 mr-2" />
{svc.is_configured ? "Edit" : "Setup"}
</Button>
{svc.is_configured && (
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:bg-destructive/10" onClick={() => handleDeleteCredential(svc.service_name)}>
<Trash2 className="w-4 h-4" />
{svc.service_name === 'storage' && svc.is_configured && (
<Button variant="secondary" size="sm" className="w-full mb-2" onClick={handleTestStorageConnection} disabled={testingConnection}>
{testingConnection ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : "Test Connection"}
</Button>
)}
<div className="flex w-full gap-2">
<Button variant="outline" size="sm" className="w-full" onClick={() => handleOpenCredentialDialog(svc.service_name)}>
<Key className="w-3 h-3 mr-2" />
{svc.is_configured ? "Edit" : "Setup"}
</Button>
{svc.is_configured && (
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:bg-destructive/10" onClick={() => handleDeleteCredential(svc.service_name)}>
<Trash2 className="w-4 h-4" />
</Button>
)}
</div>
</div>
</div>
))}

View file

@ -718,6 +718,12 @@ export const credentialsApi = {
}),
};
export const storageApi = {
testConnection: () => apiRequest<{ message: string }>("/api/v1/admin/storage/test-connection", {
method: "POST"
}),
};
// --- Email Templates & Settings ---
export interface EmailTemplate {
id: string;