add gitlab ci

This commit is contained in:
m
2026-09-16 14:47:10 +02:00
parent 906d48702c
commit cd4f0179fc
32 changed files with 1462 additions and 40 deletions
+8
View File
@@ -0,0 +1,8 @@
bin/
uploads/*
!uploads/.gitkeep
.env
.git
*.md
.vscode/
.idea/
+59
View File
@@ -0,0 +1,59 @@
# syntax=docker/dockerfile:1
ARG VERSION=dev
ARG BUILD_DATE=unknown
# --- builder: compile statique du binaire Go (CGO off, musl) ---
FROM golang:1.26-alpine AS builder
ENV GOTOOLCHAIN=auto
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags "-s -w -X main.version=${VERSION}" \
-o /out/vaultdrop-server \
./cmd/server/main.go
# --- runtime: mince, Tesseract (fra+eng) embarqué pour l'OCR ---
FROM alpine:3.20
RUN apk add --no-cache \
ca-certificates \
tzdata \
tesseract-ocr \
tesseract-ocr-data-eng \
tesseract-ocr-data-fra
WORKDIR /app
ARG VERSION
ARG BUILD_DATE
LABEL org.opencontainers.image.title="vaultdrop-server" \
org.opencontainers.image.description="VaultDrop backend API (Go/Gin + PostgreSQL) — local-first document vault" \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.licenses="AGPL-3.0-only" \
org.opencontainers.image.source="https://gitlab.com/thirdshop-org/dot" \
org.opencontainers.image.url="https://thirdshop.fr/en/applications/vault/" \
org.opencontainers.image.created="${BUILD_DATE}" \
org.opencontainers.image.revision=""
COPY --from=builder /out/vaultdrop-server /usr/local/bin/vaultdrop-server
# UPLOAD_DIR persistant, user non-root
RUN mkdir -p /app/uploads \
&& adduser -D -H -u 10001 vaultdrop \
&& chown vaultdrop:vaultdrop /app/uploads
VOLUME /app/uploads
ENV PORT=8080 \
UPLOAD_DIR=/app/uploads
USER vaultdrop
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD wget -q -O - http://127.0.0.1:8080/api/v1/health || exit 1
ENTRYPOINT ["vaultdrop-server"]
+1
View File
@@ -14,6 +14,7 @@ var expectedRoutes = []string{
"GET /api/v1/files",
"GET /api/v1/files/:id",
"GET /api/v1/files/:id/ocr",
"DELETE /api/v1/files/:id",
"GET /api/v1/files/search",
"GET /api/v1/files/folders",
@@ -0,0 +1 @@
ALTER TABLE resources DROP COLUMN ocr_text;
@@ -0,0 +1,5 @@
-- Texte OCR porté sur la ressource (docs/api-v1.md §5) : le worker OCR écrit
-- l'extrait (`resources.ocr_text`, updated_at bumpé) pour qu'il soit
-- consultable par tous les devices du user ET par les grantees
-- (GET /files/:id/ocr, visibilité viewer+).
ALTER TABLE resources ADD COLUMN ocr_text TEXT;
+23
View File
@@ -59,6 +59,29 @@ func FilesGet(c *gin.Context) {
api.OK(c, file)
}
// FilesGetOcr expose l'extrait OCR server-side d'un fichier visible (viewer+,
// own ou shared) : GET /files/:id/ocr. Réponse { text } ("" si pas encore
// extrait) + updatedAt. Permet à un second device de récupérer le texte déjà
// calculé sans re-téléverser les octets (cf. docs/api-v1.md §5).
func FilesGetOcr(c *gin.Context) {
if Store == nil {
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
return
}
userID := c.GetString(UserIDKey)
id := c.Param("id")
if !deviceIDPattern.MatchString(id) {
api.Error(c, http.StatusNotFound, "NOT_FOUND", "file not found")
return
}
dto, err := Store.GetFileOcr(userID, id)
if err != nil {
writeError(c, err)
return
}
api.OK(c, dto)
}
func FilesDelete(c *gin.Context) {
if Store == nil {
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
+56
View File
@@ -39,6 +39,11 @@ type fileDTO struct {
CreatedAt string `json:"createdAt"`
}
type fileOcrDTO struct {
Text string `json:"text"`
UpdatedAt string `json:"updatedAt"`
}
type apiError struct {
Error struct {
Code string `json:"code"`
@@ -577,3 +582,54 @@ func TestFilesGetDeleteRejectInvalidID(t *testing.T) {
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files/"+fileID, otherToken, nil, "")
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "get-cross-user")
}
func TestFilesGetOcrOwnerShareAndScoping(t *testing.T) {
r, _, repo := setup(t)
ownerDevice := repository.NewID()
ownerToken, owner := registerAndLogin(t, r, repo, testUserUsername(ownerDevice, "oc"), "ocr-get-test-password", ownerDevice)
granteeDevice := repository.NewID()
granteeToken, grantee := registerAndLogin(t, r, repo, testUserUsername(granteeDevice, "og"), "ocr-get-test-password-b", granteeDevice)
outsiderDevice := repository.NewID()
outsiderToken, _ := registerAndLogin(t, r, repo, testUserUsername(outsiderDevice, "oh"), "ocr-get-test-password-c", outsiderDevice)
fileID := repository.NewID()
if err := repo.Resources.InsertFile(owner, fileID, "scan.png", "", 128, nil, nil); err != nil {
t.Fatalf("insert file: %v", err)
}
if err := repo.Resources.UpdateOcrText(owner, fileID, "extrait du scan"); err != nil {
t.Fatalf("write ocr_text: %v", err)
}
// Owner : texte + updatedAt.
rec, _ := doRequest(t, r, http.MethodGet, "/api/v1/files/"+fileID+"/ocr", ownerToken, nil, "")
envOcr := expectOK(t, rec, "ocr-owner")
var ownerOcr fileOcrDTO
if err := json.Unmarshal(envOcr.Data, &ownerOcr); err != nil {
t.Fatalf("ocr-owner: unmarshal: %v", err)
}
if ownerOcr.Text != "extrait du scan" || ownerOcr.UpdatedAt == "" {
t.Errorf("ocr owner = %+v, attendu texte+updatedAt", ownerOcr)
}
// Grantée viewer+ : même texte (partage rend l'OCR lisible).
if err := repo.Shares.Upsert(fileID, grantee, "viewer", false, nil, owner); err != nil {
t.Fatalf("share: %v", err)
}
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files/"+fileID+"/ocr", granteeToken, nil, "")
envOcr = expectOK(t, rec, "ocr-grantee")
var granteeOcr fileOcrDTO
if err := json.Unmarshal(envOcr.Data, &granteeOcr); err != nil {
t.Fatalf("ocr-grantee: unmarshal: %v", err)
}
if granteeOcr.Text != "extrait du scan" {
t.Errorf("ocr grantee = %+v, attendu texte partagé", granteeOcr)
}
// Outsider : invisible (404).
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files/"+fileID+"/ocr", outsiderToken, nil, "")
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "ocr-outsider")
// ID invalide → 404.
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files/not-hex/ocr", ownerToken, nil, "")
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "ocr-invalid-id")
}
+1
View File
@@ -27,6 +27,7 @@ func RegisterRoutes(r *gin.Engine) {
protected.GET("/files", FilesList)
protected.GET("/files/search", FilesSearch)
protected.GET("/files/:id", FilesGet)
protected.GET("/files/:id/ocr", FilesGetOcr)
protected.DELETE("/files/:id", FilesDelete)
protected.GET("/files/folders", FoldersList)
protected.POST("/files/upload", FilesUpload)
+26 -2
View File
@@ -41,6 +41,7 @@ type FileRow struct {
FolderID string
CreatedAt time.Time
UpdatedAt time.Time
OcrText string
}
type FolderRow struct {
@@ -156,11 +157,11 @@ func (r *Resources) InsertFolder(ownerID, id, name, parentResourceID string) err
return r.insert(ownerID, id, name, parentResourceID, "folder", 0, nil, nil)
}
const fileColumns = `resource_id, name, size_bytes, COALESCE(mime_type, ''), COALESCE(parent_id, ''), created_at, updated_at`
const fileColumns = `resource_id, name, size_bytes, COALESCE(mime_type, ''), COALESCE(parent_id, ''), created_at, updated_at, COALESCE(ocr_text, '')`
func (r *Resources) scanFile(scan func(...any) error) (FileRow, error) {
var row FileRow
err := scan(&row.ID, &row.Name, &row.Size, &row.MimeType, &row.FolderID, &row.CreatedAt, &row.UpdatedAt)
err := scan(&row.ID, &row.Name, &row.Size, &row.MimeType, &row.FolderID, &row.CreatedAt, &row.UpdatedAt, &row.OcrText)
return row, err
}
@@ -471,6 +472,29 @@ func (r *Resources) UpdatePhysical(ownerID, resourceID string, size int64, mimeT
return nil
}
// UpdateOcrText writes the server-computed OCR extract onto the resource
// (owner-scoped, bumping updated_at so the change flows through the sync
// snapshot). The text is then readable by every device of the user AND by
// grantees via GET /files/:id/ocr (viewer+, cf. docs/api-v1.md §5).
func (r *Resources) UpdateOcrText(ownerID, resourceID, text string) error {
result, err := r.DB.Exec(
`UPDATE resources SET ocr_text = $3, updated_at = NOW()
WHERE resource_id = $1 AND user_id = $2 AND type = 'file' AND deleted_at IS NULL`,
resourceID, ownerID, text,
)
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return ErrNotFound
}
return nil
}
// SyncDelete soft-deletes a resource; absence is NOT an error (idempotent
// terminal state for the outbox).
func (r *Resources) SyncDelete(ownerID, resourceID string) error {
+51
View File
@@ -232,3 +232,54 @@ func TestListOwnedDelta(t *testing.T) {
t.Errorf("delta attendu uniquement fresh.txt, got %+v", delta)
}
}
func TestUpdateOcrTextAndReadBack(t *testing.T) {
repo := newTestResources(t)
owner := NewID()
other := NewID()
mustInsertUser(t, repo, owner)
mustInsertUser(t, repo, other)
fileID := NewID()
if err := repo.InsertFile(owner, fileID, "scan.png", "", 128, nil, nil); err != nil {
t.Fatalf("insert file: %v", err)
}
// Vide par défaut.
before := time.Now()
row, err := repo.GetFile(owner, fileID)
if err != nil {
t.Fatalf("get file: %v", err)
}
if row.OcrText != "" {
t.Errorf("ocr_text initial = %q, attendu vide", row.OcrText)
}
if err := repo.UpdateOcrText(owner, fileID, "extrait du scan"); err != nil {
t.Fatalf("update ocr_text: %v", err)
}
// L'exécution bump updated_at (→ delta outbox/snapshot).
time.Sleep(5 * time.Millisecond)
after, err := repo.GetFile(owner, fileID)
if err != nil {
t.Fatalf("get file après : %v", err)
}
if after.OcrText != "extrait du scan" {
t.Errorf("ocr_text = %q, attendu %q", after.OcrText, "extrait du scan")
}
if !after.UpdatedAt.After(before) {
t.Errorf("updated_at non bumpé: before=%v after=%v", before, after.UpdatedAt)
}
if after.UpdatedAt.Before(time.Now().Add(-time.Minute)) {
t.Errorf("updated_at incohérent: %v", after.UpdatedAt)
}
// Non-owner / ressource inconnue → ErrNotFound.
if err := repo.UpdateOcrText(other, fileID, "volé"); !errors.Is(err, ErrNotFound) {
t.Errorf("autre user : attendu ErrNotFound, got %v", err)
}
if err := repo.UpdateOcrText(owner, NewID(), "x"); !errors.Is(err, ErrNotFound) {
t.Errorf("inconnue : attendu ErrNotFound, got %v", err)
}
}
+8
View File
@@ -127,6 +127,14 @@ func (o *Ocr) process(ctx context.Context, row repository.OcrJobRow) {
return
}
_ = o.Repository.OcrJobs.Complete(row.DeviceID, row.ID, text)
// Le texte atterrit sur la ressource (updated_at bumpé) : consultable par
// tous les devices du user et les grantees via GET /files/:id/ocr. SILENCIEUX
// (hôte déjà supprimé ou lock d'écriture) : le job reste source d'historique.
if trimmed := strings.TrimSpace(text); trimmed != "" {
if err := o.Repository.Resources.UpdateOcrText(userID, row.FileID, trimmed); err != nil {
log.Printf("ocr: persiste ocr_text de %s: %v", row.FileID, err)
}
}
}
// physicalPath résout UPLOAD_DIR/<user_id>/<resource_id>.<ext> — l'ext est
+53
View File
@@ -110,6 +110,59 @@ func TestOcrJobFailsWhenPhysicalFileMissing(t *testing.T) {
}
}
func TestOcrJobPersistsTextOnResourceAndGrantsRead(t *testing.T) {
uploadDir := t.TempDir()
o, ownerID, deviceID, _ := newTestOcr(t, uploadDir, stubEngine{text: " extrait du scan "})
granteeID := mustCreateUser(t, o.Repository, "ocr-grantee")
outsiderID := mustCreateUser(t, o.Repository, "ocr-outsider")
mustRegisterDevice(t, o.Repository, shareTestDeviceID)
fileID := repository.NewID()
insertOcrFile(t, o, ownerID, fileID)
if err := os.MkdirAll(filepath.Join(uploadDir, ownerID), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(uploadDir, ownerID, fileID+".png"), []byte("img"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
job, err := o.Create(ownerID, deviceID, fileID)
if err != nil {
t.Fatalf("create: %v", err)
}
row := waitTillTerminal(t, o, deviceID, job.ID)
if row.Status != "done" {
t.Fatalf("job %s: attendu done", row.Status)
}
// Le texte (trimé) a atterri sur la ressource, lisible par l'owner.
s := NewResources(o.Repository, uploadDir, 100)
owned, err := s.GetFileOcr(ownerID, fileID)
if err != nil {
t.Fatalf("get ocr owner: %v", err)
}
if owned.Text != "extrait du scan" {
t.Errorf("ocr_text = %q, attendu %q", owned.Text, "extrait du scan")
}
// ... et par un grantee viewer+ (docs §5 : partage rend le texte lisible).
if err := o.Repository.Shares.Upsert(fileID, granteeID, "viewer", false, nil, ownerID); err != nil {
t.Fatalf("share: %v", err)
}
shared, err := s.GetFileOcr(granteeID, fileID)
if err != nil {
t.Fatalf("get ocr grantee: %v", err)
}
if shared.Text != "extrait du scan" {
t.Errorf("ocr_text grantee = %q, attendu %q", shared.Text, "extrait du scan")
}
// Un tiers n'y a pas accès.
if _, err := s.GetFileOcr(outsiderID, fileID); !errors.Is(err, repository.ErrNotFound) {
t.Errorf("tiers : attendu ErrNotFound, got %v", err)
}
}
func TestOcrJobFailsOnEngineError(t *testing.T) {
uploadDir := t.TempDir()
o, userID, deviceID, _ := newTestOcr(t, uploadDir, stubEngine{err: errors.New("tesseract boom")})
+20
View File
@@ -73,6 +73,26 @@ func (s *Resources) GetFile(ownerID, id string) (FileDTO, error) {
return toFileDTO(row), nil
}
// FileOcrDTO is the payload of GET /files/:id/ocr: the server-computed OCR
// extract of the resource, readable by its owner and by grantees (viewer+).
type FileOcrDTO struct {
Text string `json:"text"`
UpdatedAt string `json:"updatedAt,omitempty"`
}
// GetFileOcr returns the OCR text of a file the user can see (viewer+, own or
// shared). Absent text → Text empty ("") with 200 (the caller decides).
func (s *Resources) GetFileOcr(ownerID, id string) (FileOcrDTO, error) {
row, err := s.Repo.GetFileVisible(ownerID, id)
if err != nil {
return FileOcrDTO{}, err
}
return FileOcrDTO{
Text: row.OcrText,
UpdatedAt: row.UpdatedAt.UTC().Format(time.RFC3339),
}, nil
}
func (s *Resources) DeleteFile(ownerID, id string) (string, error) {
return s.Repo.DeleteFile(ownerID, id)
}