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:
parent
b5e9ef60ef
commit
3cd52accfb
11 changed files with 243 additions and 28 deletions
|
|
@ -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
|
||||
48
backend/internal/api/controllers/storage_controller.go
Normal file
48
backend/internal/api/controllers/storage_controller.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
12
backend/migrations/032_update_superadmin_lol.sql
Normal file
12
backend/migrations/032_update_superadmin_lol.sql
Normal 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';
|
||||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue