generate thumbnails
This commit is contained in:
+2
-2
@@ -11,14 +11,14 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o /bin/server ./cmd/server
|
|||||||
|
|
||||||
FROM alpine:3.21
|
FROM alpine:3.21
|
||||||
|
|
||||||
RUN apk add --no-cache ca-certificates curl
|
RUN apk add --no-cache ca-certificates curl libreoffice-core poppler-utils
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /bin/server .
|
COPY --from=builder /bin/server .
|
||||||
COPY internal/db/migrations ./internal/db/migrations
|
COPY internal/db/migrations ./internal/db/migrations
|
||||||
|
|
||||||
RUN mkdir -p /app/uploads /data
|
RUN mkdir -p /app/uploads /app/uploads/thumbnails /data
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ func main() {
|
|||||||
|
|
||||||
fileSvc := service.NewFileService(queries, cfg)
|
fileSvc := service.NewFileService(queries, cfg)
|
||||||
ocrSvc := service.NewOCRService(cfg, fileSvc)
|
ocrSvc := service.NewOCRService(cfg, fileSvc)
|
||||||
|
conversionSvc := service.NewConversionService(queries, cfg)
|
||||||
urlSvc := service.NewURLService(cfg.HMACSecret, cfg.ServerHost)
|
urlSvc := service.NewURLService(cfg.HMACSecret, cfg.ServerHost)
|
||||||
authSvc, err := auth.NewAuthService(queries, cfg)
|
authSvc, err := auth.NewAuthService(queries, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -41,7 +42,10 @@ func main() {
|
|||||||
ocrSvc.Start()
|
ocrSvc.Start()
|
||||||
defer ocrSvc.Stop()
|
defer ocrSvc.Stop()
|
||||||
|
|
||||||
h := handler.New(fileSvc, ocrSvc, urlSvc, auth.NewAuthHandler(authSvc))
|
conversionSvc.Start()
|
||||||
|
defer conversionSvc.Stop()
|
||||||
|
|
||||||
|
h := handler.New(fileSvc, ocrSvc, urlSvc, auth.NewAuthHandler(authSvc), conversionSvc)
|
||||||
|
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
handler.SetupRoutes(r, h, authSvc)
|
handler.SetupRoutes(r, h, authSvc)
|
||||||
|
|||||||
@@ -3,24 +3,30 @@ package config
|
|||||||
import "os"
|
import "os"
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Port string
|
Port string
|
||||||
DatabaseURL string
|
DatabaseURL string
|
||||||
OCREndpoint string
|
OCREndpoint string
|
||||||
UploadDir string
|
UploadDir string
|
||||||
HMACSecret string
|
HMACSecret string
|
||||||
ServerHost string
|
ServerHost string
|
||||||
PASETOKey string
|
PASETOKey string
|
||||||
|
LibreOfficePath string
|
||||||
|
PdftoppmPath string
|
||||||
|
ThumbnailDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
Port: envOr("PORT", "8080"),
|
Port: envOr("PORT", "8080"),
|
||||||
DatabaseURL: envOr("DATABASE_URL", "postgres://localhost:5432/vaultdrop?sslmode=disable"),
|
DatabaseURL: envOr("DATABASE_URL", "postgres://localhost:5432/vaultdrop?sslmode=disable"),
|
||||||
OCREndpoint: envOr("OCR_ENDPOINT", "http://localhost:9090"),
|
OCREndpoint: envOr("OCR_ENDPOINT", "http://localhost:9090"),
|
||||||
UploadDir: envOr("UPLOAD_DIR", "./uploads"),
|
UploadDir: envOr("UPLOAD_DIR", "./uploads"),
|
||||||
HMACSecret: envOr("HMAC_SECRET", "thisismyrandomstring"),
|
HMACSecret: envOr("HMAC_SECRET", "thisismyrandomstring"),
|
||||||
ServerHost: envOr("SERVER_HOST", "http://192.168.1.17:8080"),
|
ServerHost: envOr("SERVER_HOST", "http://192.168.1.17:8080"),
|
||||||
PASETOKey: envOr("PASETO_KEY", "01234567890123456789012345678901234567890123456789012345678901234"),
|
PASETOKey: envOr("PASETO_KEY", "01234567890123456789012345678901234567890123456789012345678901234"),
|
||||||
|
LibreOfficePath: envOr("LIBREOFFICE_PATH", "/usr/bin/libreoffice"),
|
||||||
|
PdftoppmPath: envOr("PDFTOPPM_PATH", "/usr/bin/pdftoppm"),
|
||||||
|
ThumbnailDir: envOr("THUMBNAIL_DIR", "./uploads/thumbnails"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS thumbnails;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE thumbnails (
|
||||||
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
||||||
|
page_number INTEGER NOT NULL,
|
||||||
|
resolution_label TEXT NOT NULL,
|
||||||
|
width INTEGER NOT NULL,
|
||||||
|
height INTEGER NOT NULL,
|
||||||
|
storage_key TEXT NOT NULL,
|
||||||
|
mime_type TEXT NOT NULL DEFAULT 'image/jpeg',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_thumbnails_file_id ON thumbnails(file_id);
|
||||||
|
CREATE UNIQUE INDEX idx_thumbnails_unique ON thumbnails(file_id, page_number, resolution_label);
|
||||||
@@ -47,6 +47,18 @@ type Tag struct {
|
|||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Thumbnail struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
FileID string `json:"file_id"`
|
||||||
|
PageNumber int32 `json:"page_number"`
|
||||||
|
ResolutionLabel string `json:"resolution_label"`
|
||||||
|
Width int32 `json:"width"`
|
||||||
|
Height int32 `json:"height"`
|
||||||
|
StorageKey string `json:"storage_key"`
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- name: CreateThumbnail :one
|
||||||
|
INSERT INTO thumbnails (file_id, page_number, resolution_label, width, height, storage_key, mime_type)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: GetThumbnailsByFileID :many
|
||||||
|
SELECT * FROM thumbnails
|
||||||
|
WHERE file_id = $1
|
||||||
|
ORDER BY page_number ASC, resolution_label ASC;
|
||||||
|
|
||||||
|
-- name: GetThumbnailByID :one
|
||||||
|
SELECT * FROM thumbnails
|
||||||
|
WHERE id = $1 LIMIT 1;
|
||||||
|
|
||||||
|
-- name: DeleteThumbnailsByFileID :exec
|
||||||
|
DELETE FROM thumbnails WHERE file_id = $1;
|
||||||
|
|
||||||
|
-- name: GetThumbnailByFilePageResolution :one
|
||||||
|
SELECT * FROM thumbnails
|
||||||
|
WHERE file_id = $1 AND page_number = $2 AND resolution_label = $3
|
||||||
|
LIMIT 1;
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: thumbnails.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createThumbnail = `-- name: CreateThumbnail :one
|
||||||
|
INSERT INTO thumbnails (file_id, page_number, resolution_label, width, height, storage_key, mime_type)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id, file_id, page_number, resolution_label, width, height, storage_key, mime_type, created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateThumbnailParams struct {
|
||||||
|
FileID string `json:"file_id"`
|
||||||
|
PageNumber int32 `json:"page_number"`
|
||||||
|
ResolutionLabel string `json:"resolution_label"`
|
||||||
|
Width int32 `json:"width"`
|
||||||
|
Height int32 `json:"height"`
|
||||||
|
StorageKey string `json:"storage_key"`
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateThumbnail(ctx context.Context, arg CreateThumbnailParams) (Thumbnail, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, createThumbnail,
|
||||||
|
arg.FileID,
|
||||||
|
arg.PageNumber,
|
||||||
|
arg.ResolutionLabel,
|
||||||
|
arg.Width,
|
||||||
|
arg.Height,
|
||||||
|
arg.StorageKey,
|
||||||
|
arg.MimeType,
|
||||||
|
)
|
||||||
|
var i Thumbnail
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.FileID,
|
||||||
|
&i.PageNumber,
|
||||||
|
&i.ResolutionLabel,
|
||||||
|
&i.Width,
|
||||||
|
&i.Height,
|
||||||
|
&i.StorageKey,
|
||||||
|
&i.MimeType,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteThumbnailsByFileID = `-- name: DeleteThumbnailsByFileID :exec
|
||||||
|
DELETE FROM thumbnails WHERE file_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteThumbnailsByFileID(ctx context.Context, fileID string) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, deleteThumbnailsByFileID, fileID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getThumbnailByFilePageResolution = `-- name: GetThumbnailByFilePageResolution :one
|
||||||
|
SELECT id, file_id, page_number, resolution_label, width, height, storage_key, mime_type, created_at FROM thumbnails
|
||||||
|
WHERE file_id = $1 AND page_number = $2 AND resolution_label = $3
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetThumbnailByFilePageResolutionParams struct {
|
||||||
|
FileID string `json:"file_id"`
|
||||||
|
PageNumber int32 `json:"page_number"`
|
||||||
|
ResolutionLabel string `json:"resolution_label"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetThumbnailByFilePageResolution(ctx context.Context, arg GetThumbnailByFilePageResolutionParams) (Thumbnail, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getThumbnailByFilePageResolution, arg.FileID, arg.PageNumber, arg.ResolutionLabel)
|
||||||
|
var i Thumbnail
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.FileID,
|
||||||
|
&i.PageNumber,
|
||||||
|
&i.ResolutionLabel,
|
||||||
|
&i.Width,
|
||||||
|
&i.Height,
|
||||||
|
&i.StorageKey,
|
||||||
|
&i.MimeType,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getThumbnailByID = `-- name: GetThumbnailByID :one
|
||||||
|
SELECT id, file_id, page_number, resolution_label, width, height, storage_key, mime_type, created_at FROM thumbnails
|
||||||
|
WHERE id = $1 LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetThumbnailByID(ctx context.Context, id string) (Thumbnail, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getThumbnailByID, id)
|
||||||
|
var i Thumbnail
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.FileID,
|
||||||
|
&i.PageNumber,
|
||||||
|
&i.ResolutionLabel,
|
||||||
|
&i.Width,
|
||||||
|
&i.Height,
|
||||||
|
&i.StorageKey,
|
||||||
|
&i.MimeType,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getThumbnailsByFileID = `-- name: GetThumbnailsByFileID :many
|
||||||
|
SELECT id, file_id, page_number, resolution_label, width, height, storage_key, mime_type, created_at FROM thumbnails
|
||||||
|
WHERE file_id = $1
|
||||||
|
ORDER BY page_number ASC, resolution_label ASC
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetThumbnailsByFileID(ctx context.Context, fileID string) ([]Thumbnail, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, getThumbnailsByFileID, fileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []Thumbnail
|
||||||
|
for rows.Next() {
|
||||||
|
var i Thumbnail
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.FileID,
|
||||||
|
&i.PageNumber,
|
||||||
|
&i.ResolutionLabel,
|
||||||
|
&i.Width,
|
||||||
|
&i.Height,
|
||||||
|
&i.StorageKey,
|
||||||
|
&i.MimeType,
|
||||||
|
&i.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
@@ -12,9 +12,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type FileHandler struct {
|
type FileHandler struct {
|
||||||
files *service.FileService
|
files *service.FileService
|
||||||
urls *service.URLService
|
urls *service.URLService
|
||||||
ocr *service.OCRService
|
ocr *service.OCRService
|
||||||
|
conversion *service.ConversionService
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *FileHandler) Upload(c *gin.Context) {
|
func (h *FileHandler) Upload(c *gin.Context) {
|
||||||
@@ -40,6 +41,10 @@ func (h *FileHandler) Upload(c *gin.Context) {
|
|||||||
|
|
||||||
h.ocr.Enqueue(result.ID, result.Path)
|
h.ocr.Enqueue(result.ID, result.Path)
|
||||||
|
|
||||||
|
if service.IsConvertible(result.MimeType) {
|
||||||
|
h.conversion.Enqueue(result.ID, result.Path, result.MimeType)
|
||||||
|
}
|
||||||
|
|
||||||
results = append(results, gin.H{
|
results = append(results, gin.H{
|
||||||
"id": result.ID,
|
"id": result.ID,
|
||||||
"name": result.Name,
|
"name": result.Name,
|
||||||
@@ -252,3 +257,56 @@ func (h *FileHandler) ListFilesByParent(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
api.Success(c, files)
|
api.Success(c, files)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *FileHandler) GetThumbnails(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
thumbnails, err := h.files.GetThumbnailsByFileID(id)
|
||||||
|
if err != nil {
|
||||||
|
api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to fetch thumbnails")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type thumbnailResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
PageNumber int `json:"pageNumber"`
|
||||||
|
ResolutionLabel string `json:"resolutionLabel"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
MimeType string `json:"mimeType"`
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := make([]thumbnailResponse, len(thumbnails))
|
||||||
|
for i, t := range thumbnails {
|
||||||
|
resp[i] = thumbnailResponse{
|
||||||
|
ID: t.ID,
|
||||||
|
PageNumber: t.PageNumber,
|
||||||
|
ResolutionLabel: t.ResolutionLabel,
|
||||||
|
Width: t.Width,
|
||||||
|
Height: t.Height,
|
||||||
|
URL: h.urls.GenerateThumbnailURL(t.ID),
|
||||||
|
MimeType: t.MimeType,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api.Success(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *FileHandler) ServeThumbnail(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
exp, _ := strconv.ParseInt(c.Query("expires"), 10, 64)
|
||||||
|
sig := c.Query("sig")
|
||||||
|
|
||||||
|
if !h.urls.Validate(id, sig, exp) {
|
||||||
|
api.Error(c, http.StatusForbidden, "FORBIDDEN", "Invalid or expired link")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
storagePath, err := h.files.GetThumbnailStoragePath(id)
|
||||||
|
if err != nil {
|
||||||
|
api.Error(c, http.StatusNotFound, "THUMBNAIL_NOT_FOUND", "Thumbnail not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.File(path.Clean(storagePath))
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ type Handler struct {
|
|||||||
Auth *auth.AuthHandler
|
Auth *auth.AuthHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(fileSvc *service.FileService, ocrSvc *service.OCRService, urlSvc *service.URLService, authHandler *auth.AuthHandler) *Handler {
|
func New(fileSvc *service.FileService, ocrSvc *service.OCRService, urlSvc *service.URLService, authHandler *auth.AuthHandler, conversionSvc *service.ConversionService) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
File: &FileHandler{files: fileSvc, urls: urlSvc, ocr: ocrSvc},
|
File: &FileHandler{files: fileSvc, urls: urlSvc, ocr: ocrSvc, conversion: conversionSvc},
|
||||||
OCR: &OCRHandler{ocr: ocrSvc, files: fileSvc},
|
OCR: &OCRHandler{ocr: ocrSvc, files: fileSvc},
|
||||||
Health: &HealthHandler{ocr: ocrSvc},
|
Health: &HealthHandler{ocr: ocrSvc},
|
||||||
Auth: authHandler,
|
Auth: authHandler,
|
||||||
|
|||||||
@@ -34,4 +34,8 @@ func SetupRoutes(r *gin.Engine, h *Handler, authMiddleware *auth.AuthService) {
|
|||||||
|
|
||||||
protected.POST("/ocr/jobs", h.OCR.CreateJob)
|
protected.POST("/ocr/jobs", h.OCR.CreateJob)
|
||||||
protected.GET("/ocr/jobs/:id", h.OCR.GetJobStatus)
|
protected.GET("/ocr/jobs/:id", h.OCR.GetJobStatus)
|
||||||
|
|
||||||
|
protected.GET("/files/:id/thumbnails", h.File.GetThumbnails)
|
||||||
|
|
||||||
|
api.GET("/thumbnails/:id", h.File.ServeThumbnail)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ type File struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UploadResult struct {
|
type UploadResult struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
|
MimeType string `json:"mimeType"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
type Thumbnail struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
FileID string `json:"fileId"`
|
||||||
|
PageNumber int `json:"pageNumber"`
|
||||||
|
ResolutionLabel string `json:"resolutionLabel"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
StorageKey string `json:"-"`
|
||||||
|
MimeType string `json:"mimeType"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
_ "image/jpeg"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/vaultdrop/backend/internal/config"
|
||||||
|
"github.com/vaultdrop/backend/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ConversionJob struct {
|
||||||
|
FileID string
|
||||||
|
FilePath string
|
||||||
|
MimeType string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConversionService struct {
|
||||||
|
queries *db.Queries
|
||||||
|
cfg *config.Config
|
||||||
|
jobs chan ConversionJob
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConversionService(queries *db.Queries, cfg *config.Config) *ConversionService {
|
||||||
|
return &ConversionService{
|
||||||
|
queries: queries,
|
||||||
|
cfg: cfg,
|
||||||
|
jobs: make(chan ConversionJob, 100),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConversionService) Start() {
|
||||||
|
go s.worker()
|
||||||
|
log.Println("[Conversion] Worker started")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConversionService) Stop() {
|
||||||
|
close(s.jobs)
|
||||||
|
log.Println("[Conversion] Worker stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConversionService) Enqueue(fileID, filePath, mimeType string) {
|
||||||
|
s.jobs <- ConversionJob{FileID: fileID, FilePath: filePath, MimeType: mimeType}
|
||||||
|
log.Printf("[Conversion] Enqueued file %s", fileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConversionService) worker() {
|
||||||
|
for job := range s.jobs {
|
||||||
|
s.process(job)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConversionService) process(job ConversionJob) {
|
||||||
|
log.Printf("[Conversion] Processing file %s (mime: %s)", job.FileID, job.MimeType)
|
||||||
|
|
||||||
|
pdfPath := job.FilePath
|
||||||
|
tmpDir := ""
|
||||||
|
|
||||||
|
if isOfficeDocument(job.MimeType) {
|
||||||
|
var err error
|
||||||
|
pdfPath, tmpDir, err = s.convertToPDF(job.FilePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Conversion] Failed to convert file %s to PDF: %v", job.FileID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
} else if !isPDF(job.MimeType) {
|
||||||
|
log.Printf("[Conversion] Skipping file %s: unsupported mime type %s", job.FileID, job.MimeType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
thumbDir := filepath.Join(s.cfg.ThumbnailDir, job.FileID)
|
||||||
|
if err := os.MkdirAll(thumbDir, 0o755); err != nil {
|
||||||
|
log.Printf("[Conversion] Failed to create thumbnail dir for %s: %v", job.FileID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resolutions := []struct {
|
||||||
|
label string
|
||||||
|
dpi int
|
||||||
|
}{
|
||||||
|
{"thumbnail", 21},
|
||||||
|
{"full", 200},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, res := range resolutions {
|
||||||
|
pages, err := s.convertPDFToImages(pdfPath, thumbDir, res.dpi)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Conversion] Failed to convert file %s to images (res=%s): %v", job.FileID, res.label, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, page := range pages {
|
||||||
|
width, height, err := getImageDimensions(page.path)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Conversion] Failed to get dimensions for %s: %v", page.path, err)
|
||||||
|
width, height = 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
thumbUUID := uuid.New().String()
|
||||||
|
dstPath := filepath.Join(thumbDir, thumbUUID+".jpg")
|
||||||
|
if err := os.Rename(page.path, dstPath); err != nil {
|
||||||
|
log.Printf("[Conversion] Failed to move %s to %s: %v", page.path, dstPath, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.queries.CreateThumbnail(context.Background(), db.CreateThumbnailParams{
|
||||||
|
FileID: job.FileID,
|
||||||
|
PageNumber: int32(page.number),
|
||||||
|
ResolutionLabel: res.label,
|
||||||
|
Width: int32(width),
|
||||||
|
Height: int32(height),
|
||||||
|
StorageKey: dstPath,
|
||||||
|
MimeType: "image/jpeg",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Conversion] Failed to create thumbnail record for file %s page %d: %v", job.FileID, page.number, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[Conversion] Generated %d %s images for file %s", len(pages), res.label, job.FileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[Conversion] Completed file %s", job.FileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConversionService) convertToPDF(inputPath string) (string, string, error) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "conversion-*")
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("create temp dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(s.cfg.LibreOfficePath,
|
||||||
|
"--headless",
|
||||||
|
"--convert-to", "pdf",
|
||||||
|
"--outdir", tmpDir,
|
||||||
|
inputPath,
|
||||||
|
)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
os.RemoveAll(tmpDir)
|
||||||
|
return "", "", fmt.Errorf("libreoffice conversion failed: %s: %w", string(output), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseName := filepath.Base(inputPath)
|
||||||
|
pdfName := strings.TrimSuffix(baseName, filepath.Ext(baseName)) + ".pdf"
|
||||||
|
pdfPath := filepath.Join(tmpDir, pdfName)
|
||||||
|
|
||||||
|
if _, err := os.Stat(pdfPath); os.IsNotExist(err) {
|
||||||
|
os.RemoveAll(tmpDir)
|
||||||
|
return "", "", fmt.Errorf("PDF not found at %s", pdfPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pdfPath, tmpDir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type imagePage struct {
|
||||||
|
number int
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConversionService) convertPDFToImages(pdfPath, outputDir string, dpi int) ([]imagePage, error) {
|
||||||
|
prefix := filepath.Join(outputDir, fmt.Sprintf("tmp_%d_", dpi))
|
||||||
|
|
||||||
|
cmd := exec.Command(s.cfg.PdftoppmPath,
|
||||||
|
"-jpeg",
|
||||||
|
"-r", fmt.Sprintf("%d", dpi),
|
||||||
|
pdfPath,
|
||||||
|
prefix,
|
||||||
|
)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("pdftoppm failed: %s: %w", string(output), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(outputDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read output dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var pages []imagePage
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := entry.Name()
|
||||||
|
if !strings.HasPrefix(name, fmt.Sprintf("tmp_%d_", dpi)) || !strings.HasSuffix(name, ".jpg") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var num int
|
||||||
|
if _, err := fmt.Sscanf(strings.TrimPrefix(name, fmt.Sprintf("tmp_%d_", dpi)), "%d", &num); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pages = append(pages, imagePage{
|
||||||
|
number: num,
|
||||||
|
path: filepath.Join(outputDir, name),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPDF(mimeType string) bool {
|
||||||
|
return strings.Contains(mimeType, "pdf")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isOfficeDocument(mimeType string) bool {
|
||||||
|
officeTypes := []string{
|
||||||
|
"application/vnd.openxmlformats-officedocument",
|
||||||
|
"application/vnd.ms-excel",
|
||||||
|
"application/msword",
|
||||||
|
"application/vnd.ms-powerpoint",
|
||||||
|
"application/vnd.oasis.opendocument",
|
||||||
|
"application/x-doc",
|
||||||
|
"application/x-xls",
|
||||||
|
"application/x-ppt",
|
||||||
|
}
|
||||||
|
for _, t := range officeTypes {
|
||||||
|
if strings.Contains(mimeType, t) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsConvertible(mimeType string) bool {
|
||||||
|
return isPDF(mimeType) || isOfficeDocument(mimeType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getImageDimensions(path string) (int, int, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
cfg, _, err := image.DecodeConfig(f)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg.Width, cfg.Height, nil
|
||||||
|
}
|
||||||
@@ -59,9 +59,10 @@ func (s *FileService) Upload(file *multipart.FileHeader) (*model.UploadResult, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &model.UploadResult{
|
return &model.UploadResult{
|
||||||
ID: dbFile.ID,
|
ID: dbFile.ID,
|
||||||
Name: dbFile.Name,
|
Name: dbFile.Name,
|
||||||
Path: dst,
|
Path: dst,
|
||||||
|
MimeType: dbFile.MimeType,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +207,37 @@ func (s *FileService) ListFilesByParentID(parentID string) ([]model.File, error)
|
|||||||
return files, nil
|
return files, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *FileService) GetThumbnailsByFileID(fileID string) ([]model.Thumbnail, error) {
|
||||||
|
dbThumbnails, err := s.queries.GetThumbnailsByFileID(context.Background(), fileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get thumbnails: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
thumbnails := make([]model.Thumbnail, len(dbThumbnails))
|
||||||
|
for i, t := range dbThumbnails {
|
||||||
|
thumbnails[i] = model.Thumbnail{
|
||||||
|
ID: t.ID,
|
||||||
|
FileID: t.FileID,
|
||||||
|
PageNumber: int(t.PageNumber),
|
||||||
|
ResolutionLabel: t.ResolutionLabel,
|
||||||
|
Width: int(t.Width),
|
||||||
|
Height: int(t.Height),
|
||||||
|
StorageKey: t.StorageKey,
|
||||||
|
MimeType: t.MimeType,
|
||||||
|
CreatedAt: t.CreatedAt.String(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return thumbnails, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FileService) GetThumbnailStoragePath(id string) (string, error) {
|
||||||
|
t, err := s.queries.GetThumbnailByID(context.Background(), id)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("get thumbnail: %w", err)
|
||||||
|
}
|
||||||
|
return t.StorageKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
func dbToModel(f db.File, dbTags []db.Tag) model.File {
|
func dbToModel(f db.File, dbTags []db.Tag) model.File {
|
||||||
tags := make([]model.Tag, len(dbTags))
|
tags := make([]model.Tag, len(dbTags))
|
||||||
for i, t := range dbTags {
|
for i, t := range dbTags {
|
||||||
|
|||||||
@@ -37,6 +37,19 @@ func (s *URLService) GenerateDownloadURL(fileUUID string) string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *URLService) GenerateThumbnailURL(thumbUUID string) string {
|
||||||
|
expires := time.Now().Add(10 * time.Minute).Unix()
|
||||||
|
sig := s.sign(thumbUUID, expires)
|
||||||
|
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"%s/api/v1/thumbnails/%s?expires=%d&sig=%s",
|
||||||
|
s.serverHost,
|
||||||
|
thumbUUID,
|
||||||
|
expires,
|
||||||
|
sig,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *URLService) Validate(fileID, sig string, expires int64) bool {
|
func (s *URLService) Validate(fileID, sig string, expires int64) bool {
|
||||||
if time.Now().Unix() > expires {
|
if time.Now().Unix() > expires {
|
||||||
return false
|
return false
|
||||||
|
|||||||
Reference in New Issue
Block a user