feat: overhaul drive UI and previews
This commit is contained in:
@@ -122,6 +122,7 @@ Important values:
|
|||||||
- `CORS_ALLOWED_ORIGIN`
|
- `CORS_ALLOWED_ORIGIN`
|
||||||
- `APP_DOMAIN`
|
- `APP_DOMAIN`
|
||||||
- `MAX_BODY_MB`
|
- `MAX_BODY_MB`
|
||||||
|
- `OCR_LANGS` (default: `eng+osd`, example: `eng+osd+rus`)
|
||||||
- `RATE_LIMIT_PER_MIN`
|
- `RATE_LIMIT_PER_MIN`
|
||||||
- `AUTH_RATE_LIMIT_PER_MIN`
|
- `AUTH_RATE_LIMIT_PER_MIN`
|
||||||
- `GOOGLE_AUTH_ENABLED`
|
- `GOOGLE_AUTH_ENABLED`
|
||||||
|
|||||||
@@ -61,7 +61,14 @@ func makeTestServer(t *testing.T, mutate func(*Config)) *Server {
|
|||||||
t.Fatalf("newORMRepo failed: %v", err)
|
t.Fatalf("newORMRepo failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Server{db: db, orm: orm, config: cfg, storage: storage, limiter: newRateLimiter()}
|
return &Server{
|
||||||
|
db: db,
|
||||||
|
orm: orm,
|
||||||
|
config: cfg,
|
||||||
|
storage: storage,
|
||||||
|
limiter: newRateLimiter(),
|
||||||
|
searchContent: newSearchContentCache(256),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeJSONBody[T any](t *testing.T, res *http.Response, out *T) {
|
func decodeJSONBody[T any](t *testing.T, res *http.Response, out *T) {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type Config struct {
|
|||||||
CORSOrigin string
|
CORSOrigin string
|
||||||
CookieSecure bool
|
CookieSecure bool
|
||||||
MaxBodyBytes int64
|
MaxBodyBytes int64
|
||||||
|
OCRLangs string
|
||||||
|
|
||||||
GoogleAuthEnabled bool
|
GoogleAuthEnabled bool
|
||||||
GoogleClientID string
|
GoogleClientID string
|
||||||
@@ -85,6 +86,7 @@ func loadConfig() Config {
|
|||||||
CORSOrigin: getEnv("CORS_ALLOWED_ORIGIN", ""),
|
CORSOrigin: getEnv("CORS_ALLOWED_ORIGIN", ""),
|
||||||
CookieSecure: getEnv("COOKIE_SECURE", "false") == "true",
|
CookieSecure: getEnv("COOKIE_SECURE", "false") == "true",
|
||||||
MaxBodyBytes: int64(getEnvInt("MAX_BODY_MB", 8)) * 1024 * 1024,
|
MaxBodyBytes: int64(getEnvInt("MAX_BODY_MB", 8)) * 1024 * 1024,
|
||||||
|
OCRLangs: normalizeOCRLangs(getEnv("OCR_LANGS", "eng+osd")),
|
||||||
GoogleAuthEnabled: getEnv("GOOGLE_AUTH_ENABLED", "false") == "true",
|
GoogleAuthEnabled: getEnv("GOOGLE_AUTH_ENABLED", "false") == "true",
|
||||||
GoogleClientID: getEnv("GOOGLE_CLIENT_ID", ""),
|
GoogleClientID: getEnv("GOOGLE_CLIENT_ID", ""),
|
||||||
GoogleClientSecret: getEnv("GOOGLE_CLIENT_SECRET", ""),
|
GoogleClientSecret: getEnv("GOOGLE_CLIENT_SECRET", ""),
|
||||||
@@ -177,6 +179,43 @@ func loadConfig() Config {
|
|||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeOCRLangs(v string) string {
|
||||||
|
fields := strings.FieldsFunc(strings.TrimSpace(strings.ToLower(v)), func(r rune) bool {
|
||||||
|
return r == '+' || r == ',' || r == ';' || r == '|' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
|
||||||
|
})
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return "eng+osd"
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]struct{}, len(fields))
|
||||||
|
out := make([]string, 0, len(fields))
|
||||||
|
for _, field := range fields {
|
||||||
|
field = strings.TrimSpace(field)
|
||||||
|
if field == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
valid := true
|
||||||
|
for _, ch := range field {
|
||||||
|
if (ch < 'a' || ch > 'z') && (ch < '0' || ch > '9') && ch != '_' {
|
||||||
|
valid = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[field]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[field] = struct{}{}
|
||||||
|
out = append(out, field)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return "eng+osd"
|
||||||
|
}
|
||||||
|
return strings.Join(out, "+")
|
||||||
|
}
|
||||||
|
|
||||||
func applyFTPSLetsEncryptDefaults(cfg *Config) {
|
func applyFTPSLetsEncryptDefaults(cfg *Config) {
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -31,3 +31,29 @@ func TestApplyFTPSLetsEncryptDefaultsCustomDirAndPreserveManual(t *testing.T) {
|
|||||||
t.Fatalf("unexpected key path: %q", cfg.FTPSKeyFile)
|
t.Fatalf("unexpected key path: %q", cfg.FTPSKeyFile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeOCRLangs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "default on empty", in: "", want: "eng+osd"},
|
||||||
|
{name: "comma separated", in: "eng, rus, deu", want: "eng+rus+deu"},
|
||||||
|
{name: "plus separated", in: "eng+osd+rus", want: "eng+osd+rus"},
|
||||||
|
{name: "dedupe and lowercase", in: "ENG + rus + eng", want: "eng+rus"},
|
||||||
|
{name: "drops invalid tokens", in: "eng+ru-RU+osd", want: "eng+osd"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if got := normalizeOCRLangs(tc.in); got != tc.want {
|
||||||
|
t.Fatalf("normalizeOCRLangs(%q) = %q, want %q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
1712
backend/main.go
1712
backend/main.go
File diff suppressed because it is too large
Load Diff
487
backend/search_share_test.go
Normal file
487
backend/search_share_test.go
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAPISearchFilesFuzzyAcrossAllFiles(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := makeTestServer(t, nil)
|
||||||
|
user, err := s.createUser("alice", "password123", "dracula", "auto")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createUser failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.storage.Mkdir(user.ID, "/docs"); err != nil {
|
||||||
|
t.Fatalf("mkdir failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.storage.SaveBytes(user.ID, "/docs/project-plan.md", []byte("# project plan")); err != nil {
|
||||||
|
t.Fatalf("save bytes failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.storage.SaveBytes(user.ID, "/notes.txt", []byte("notes")); err != nil {
|
||||||
|
t.Fatalf("save bytes failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loginReq := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"username":"alice","password":"password123"}`))
|
||||||
|
loginReq.Header.Set("Content-Type", "application/json")
|
||||||
|
loginRec := httptest.NewRecorder()
|
||||||
|
s.handleLogin(loginRec, loginReq)
|
||||||
|
if loginRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login status = %d", loginRec.Code)
|
||||||
|
}
|
||||||
|
access := cookieByName(loginRec.Result().Cookies(), "access_token")
|
||||||
|
if access == nil {
|
||||||
|
t.Fatal("missing access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/files/search?q=prjpln&limit=10", nil)
|
||||||
|
req.AddCookie(access)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.authMiddleware(http.HandlerFunc(s.handleSearchFiles)).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("search status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Entries []FileEntry `json:"entries"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil {
|
||||||
|
t.Fatalf("decode search response failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(out.Entries) == 0 {
|
||||||
|
t.Fatal("expected at least one search result")
|
||||||
|
}
|
||||||
|
if out.Entries[0].Path != "/docs/project-plan.md" {
|
||||||
|
t.Fatalf("top search result = %q, want %q", out.Entries[0].Path, "/docs/project-plan.md")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSharePageReturnsPublicURLAndDiscordMeta(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := makeTestServer(t, nil)
|
||||||
|
user, err := s.createUser("alice", "password123", "dracula", "auto")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createUser failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.storage.SaveBytes(user.ID, "/cover.png", []byte("png-data")); err != nil {
|
||||||
|
t.Fatalf("save bytes failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loginReq := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"username":"alice","password":"password123"}`))
|
||||||
|
loginReq.Header.Set("Content-Type", "application/json")
|
||||||
|
loginRec := httptest.NewRecorder()
|
||||||
|
s.handleLogin(loginRec, loginReq)
|
||||||
|
if loginRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login status = %d", loginRec.Code)
|
||||||
|
}
|
||||||
|
access := cookieByName(loginRec.Result().Cookies(), "access_token")
|
||||||
|
if access == nil {
|
||||||
|
t.Fatal("missing access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
createReq := httptest.NewRequest(http.MethodPost, "/api/files/share", bytes.NewBufferString(`{"path":"/cover.png","expiresMinutes":60}`))
|
||||||
|
createReq.Header.Set("Content-Type", "application/json")
|
||||||
|
createReq.AddCookie(access)
|
||||||
|
createRec := httptest.NewRecorder()
|
||||||
|
s.authMiddleware(http.HandlerFunc(s.handleCreateShareLink)).ServeHTTP(createRec, createReq)
|
||||||
|
if createRec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create share status = %d, want %d", createRec.Code, http.StatusCreated)
|
||||||
|
}
|
||||||
|
|
||||||
|
var shareResp struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(createRec.Body).Decode(&shareResp); err != nil {
|
||||||
|
t.Fatalf("decode share response failed: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(shareResp.URL, "/share/") {
|
||||||
|
t.Fatalf("share url = %q, want public /share/ URL", shareResp.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
router := mux.NewRouter()
|
||||||
|
router.HandleFunc("/share/{token}", s.handleSharedPage).Methods(http.MethodGet)
|
||||||
|
pageReq := httptest.NewRequest(http.MethodGet, "/share/"+shareResp.Token, nil)
|
||||||
|
pageRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(pageRec, pageReq)
|
||||||
|
|
||||||
|
if pageRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("share page status = %d, want %d", pageRec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
body := pageRec.Body.String()
|
||||||
|
if !strings.Contains(body, `property="og:title"`) {
|
||||||
|
t.Fatal("share page is missing og:title metadata")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "/api/share/"+shareResp.Token+"/preview") {
|
||||||
|
t.Fatal("share page is missing preview metadata URL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPISearchFilesFindsPlainTextContent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := makeTestServer(t, nil)
|
||||||
|
user, err := s.createUser("alice", "password123", "dracula", "auto")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createUser failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.storage.SaveBytes(user.ID, "/docs/meeting-notes.txt", []byte("Budget approval is scheduled for Monday morning.")); err != nil {
|
||||||
|
t.Fatalf("save bytes failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
access := loginAccessToken(t, s, "alice", "password123")
|
||||||
|
entries := runSearchRequest(t, s, access, "budget approval")
|
||||||
|
if len(entries) == 0 {
|
||||||
|
t.Fatal("expected at least one search result")
|
||||||
|
}
|
||||||
|
if entries[0].Path != "/docs/meeting-notes.txt" {
|
||||||
|
t.Fatalf("top search result = %q, want %q", entries[0].Path, "/docs/meeting-notes.txt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPISearchFilesFindsDocxContent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := makeTestServer(t, nil)
|
||||||
|
user, err := s.createUser("alice", "password123", "dracula", "auto")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createUser failed: %v", err)
|
||||||
|
}
|
||||||
|
docPath := filepath.Join(t.TempDir(), "proposal.docx")
|
||||||
|
writeDocxFixture(t, docPath, "Quarterly roadmap milestone")
|
||||||
|
data, err := os.ReadFile(docPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read docx failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.storage.SaveBytes(user.ID, "/docs/proposal.docx", data); err != nil {
|
||||||
|
t.Fatalf("save bytes failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
access := loginAccessToken(t, s, "alice", "password123")
|
||||||
|
entries := runSearchRequest(t, s, access, "roadmap milestone")
|
||||||
|
if len(entries) == 0 {
|
||||||
|
t.Fatal("expected at least one search result")
|
||||||
|
}
|
||||||
|
if entries[0].Path != "/docs/proposal.docx" {
|
||||||
|
t.Fatalf("top search result = %q, want %q", entries[0].Path, "/docs/proposal.docx")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPISearchFilesFindsImageOCRContent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if _, err := exec.LookPath("tesseract"); err != nil {
|
||||||
|
t.Skip("tesseract not installed")
|
||||||
|
}
|
||||||
|
convertBinary, err := exec.LookPath("convert")
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("ImageMagick convert not installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
s := makeTestServer(t, nil)
|
||||||
|
user, err := s.createUser("alice", "password123", "dracula", "auto")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createUser failed: %v", err)
|
||||||
|
}
|
||||||
|
imagePath := filepath.Join(t.TempDir(), "searchable.png")
|
||||||
|
cmd := exec.Command(convertBinary,
|
||||||
|
"-background", "white",
|
||||||
|
"-fill", "black",
|
||||||
|
"-font", "DejaVu-Sans-Bold",
|
||||||
|
"-pointsize", "96",
|
||||||
|
"-size", "1400x280",
|
||||||
|
"-gravity", "center",
|
||||||
|
"label:SEARCHABLE",
|
||||||
|
imagePath,
|
||||||
|
)
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("convert failed: %v (%s)", err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(imagePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read image failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.storage.SaveBytes(user.ID, "/images/searchable.png", data); err != nil {
|
||||||
|
t.Fatalf("save bytes failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
access := loginAccessToken(t, s, "alice", "password123")
|
||||||
|
entries := runSearchRequest(t, s, access, "searchable")
|
||||||
|
if len(entries) == 0 {
|
||||||
|
t.Fatal("expected at least one search result")
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.Path == "/images/searchable.png" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("expected OCR result for %q in search results", "/images/searchable.png")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPISearchFilesUsesPersistedOCRCache(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := makeTestServer(t, nil)
|
||||||
|
user, err := s.createUser("alice", "password123", "dracula", "auto")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createUser failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.storage.SaveBytes(user.ID, "/images/cached.png", []byte("not-a-real-image")); err != nil {
|
||||||
|
t.Fatalf("save bytes failed: %v", err)
|
||||||
|
}
|
||||||
|
meta, err := s.storage.Stat(user.ID, "/images/cached.png")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stat failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.db.Exec(
|
||||||
|
`INSERT INTO search_content_cache(user_id, rel_path, extractor, file_size, mod_time_ns, content) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
user.ID,
|
||||||
|
"/images/cached.png",
|
||||||
|
"ocr",
|
||||||
|
meta.Size,
|
||||||
|
meta.ModTime.UTC().UnixNano(),
|
||||||
|
"vault phrase",
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("insert search cache failed: %v", err)
|
||||||
|
}
|
||||||
|
s.searchContent = newSearchContentCache(256)
|
||||||
|
|
||||||
|
access := loginAccessToken(t, s, "alice", "password123")
|
||||||
|
entries := runSearchRequest(t, s, access, "vault phrase")
|
||||||
|
if len(entries) == 0 {
|
||||||
|
t.Fatal("expected persisted OCR cache search result")
|
||||||
|
}
|
||||||
|
if entries[0].Path != "/images/cached.png" {
|
||||||
|
t.Fatalf("top search result = %q, want %q", entries[0].Path, "/images/cached.png")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIContentPreviewReturnsDocxText(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := makeTestServer(t, nil)
|
||||||
|
user, err := s.createUser("alice", "password123", "dracula", "auto")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createUser failed: %v", err)
|
||||||
|
}
|
||||||
|
docPath := filepath.Join(t.TempDir(), "preview.docx")
|
||||||
|
writeDocxFixture(t, docPath, "Quarterly preview memo")
|
||||||
|
data, err := os.ReadFile(docPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read docx failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.storage.SaveBytes(user.ID, "/docs/preview.docx", data); err != nil {
|
||||||
|
t.Fatalf("save bytes failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
access := loginAccessToken(t, s, "alice", "password123")
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/files/content-preview?path="+url.QueryEscape("/docs/preview.docx"), nil)
|
||||||
|
req.AddCookie(access)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.authMiddleware(http.HandlerFunc(s.handleContentPreview)).ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("preview status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil {
|
||||||
|
t.Fatalf("decode preview response failed: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.Content, "Quarterly preview memo") {
|
||||||
|
t.Fatalf("preview content = %q, want extracted doc text", out.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIThumbnailReturnsDocxPreviewImage(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("soffice"); err != nil {
|
||||||
|
t.Skip("soffice not installed")
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("pdftoppm"); err != nil {
|
||||||
|
t.Skip("pdftoppm not installed")
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("convert"); err != nil {
|
||||||
|
t.Skip("ImageMagick convert not installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
s := makeTestServer(t, nil)
|
||||||
|
user, err := s.createUser("alice", "password123", "dracula", "auto")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createUser failed: %v", err)
|
||||||
|
}
|
||||||
|
docPath := filepath.Join(t.TempDir(), "thumbnail.docx")
|
||||||
|
writeDocxFixture(t, docPath, "Quarterly thumbnail memo")
|
||||||
|
data, err := os.ReadFile(docPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read docx failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.storage.SaveBytes(user.ID, "/docs/thumbnail.docx", data); err != nil {
|
||||||
|
t.Fatalf("save bytes failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
access := loginAccessToken(t, s, "alice", "password123")
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/files/thumbnail?path="+url.QueryEscape("/docs/thumbnail.docx"), nil)
|
||||||
|
req.AddCookie(access)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.authMiddleware(http.HandlerFunc(s.handleThumbnail)).ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("thumbnail status = %d, want %d (%s)", rec.Code, http.StatusOK, strings.TrimSpace(rec.Body.String()))
|
||||||
|
}
|
||||||
|
if ctype := rec.Header().Get("Content-Type"); !strings.HasPrefix(ctype, "image/png") {
|
||||||
|
t.Fatalf("thumbnail content-type = %q, want image/png", ctype)
|
||||||
|
}
|
||||||
|
if rec.Body.Len() == 0 {
|
||||||
|
t.Fatal("expected non-empty thumbnail image")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIThumbnailUsesPersistedCache(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := makeTestServer(t, nil)
|
||||||
|
user, err := s.createUser("alice", "password123", "dracula", "auto")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createUser failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.storage.SaveBytes(user.ID, "/docs/cached.docx", []byte("not-a-real-docx")); err != nil {
|
||||||
|
t.Fatalf("save bytes failed: %v", err)
|
||||||
|
}
|
||||||
|
meta, err := s.storage.Stat(user.ID, "/docs/cached.docx")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stat failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedImage := []byte{
|
||||||
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||||
|
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||||
|
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||||
|
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
|
||||||
|
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41,
|
||||||
|
0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
|
||||||
|
0x00, 0x03, 0x01, 0x01, 0x00, 0xc9, 0xfe, 0x92,
|
||||||
|
0xef, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e,
|
||||||
|
0x44, 0xae, 0x42, 0x60, 0x82,
|
||||||
|
}
|
||||||
|
if _, err := s.db.Exec(
|
||||||
|
`INSERT INTO preview_thumbnail_cache(user_id, rel_path, renderer, file_size, mod_time_ns, content_type, image) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
user.ID,
|
||||||
|
"/docs/cached.docx",
|
||||||
|
cacheableThumbnailRenderer(FileEntry{Name: "cached.docx"}),
|
||||||
|
meta.Size,
|
||||||
|
meta.ModTime.UTC().UnixNano(),
|
||||||
|
"image/png",
|
||||||
|
cachedImage,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("insert thumbnail cache failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
access := loginAccessToken(t, s, "alice", "password123")
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/files/thumbnail?path="+url.QueryEscape("/docs/cached.docx"), nil)
|
||||||
|
req.AddCookie(access)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.authMiddleware(http.HandlerFunc(s.handleThumbnail)).ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("thumbnail status = %d, want %d (%s)", rec.Code, http.StatusOK, strings.TrimSpace(rec.Body.String()))
|
||||||
|
}
|
||||||
|
if !bytes.Equal(rec.Body.Bytes(), cachedImage) {
|
||||||
|
t.Fatal("thumbnail response did not use persisted cache bytes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginAccessToken(t *testing.T, s *Server, username, password string) *http.Cookie {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
loginReq := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"username":"`+username+`","password":"`+password+`"}`))
|
||||||
|
loginReq.Header.Set("Content-Type", "application/json")
|
||||||
|
loginRec := httptest.NewRecorder()
|
||||||
|
s.handleLogin(loginRec, loginReq)
|
||||||
|
if loginRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login status = %d", loginRec.Code)
|
||||||
|
}
|
||||||
|
access := cookieByName(loginRec.Result().Cookies(), "access_token")
|
||||||
|
if access == nil {
|
||||||
|
t.Fatal("missing access token")
|
||||||
|
}
|
||||||
|
return access
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSearchRequest(t *testing.T, s *Server, access *http.Cookie, query string) []FileEntry {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/files/search?q="+url.QueryEscape(query)+"&limit=20", nil)
|
||||||
|
req.AddCookie(access)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.authMiddleware(http.HandlerFunc(s.handleSearchFiles)).ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("search status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out struct {
|
||||||
|
Entries []FileEntry `json:"entries"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil {
|
||||||
|
t.Fatalf("decode search response failed: %v", err)
|
||||||
|
}
|
||||||
|
return out.Entries
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeDocxFixture(t *testing.T, filePath, text string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
f, err := os.Create(filePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create docx failed: %v", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
zw := zip.NewWriter(f)
|
||||||
|
writeZipFixtureFile(t, zw, "[Content_Types].xml", `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||||
|
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||||
|
<Default Extension="xml" ContentType="application/xml"/>
|
||||||
|
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
||||||
|
</Types>`)
|
||||||
|
writeZipFixtureFile(t, zw, "_rels/.rels", `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||||
|
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
||||||
|
</Relationships>`)
|
||||||
|
writeZipFixtureFile(t, zw, "word/document.xml", `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||||
|
<w:body>
|
||||||
|
<w:p><w:r><w:t>`+text+`</w:t></w:r></w:p>
|
||||||
|
</w:body>
|
||||||
|
</w:document>`)
|
||||||
|
if err := zw.Close(); err != nil {
|
||||||
|
t.Fatalf("close docx failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeZipFixtureFile(t *testing.T, zw *zip.Writer, name, content string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
w, err := zw.Create(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create zip entry %q failed: %v", name, err)
|
||||||
|
}
|
||||||
|
if _, err := w.Write([]byte(content)); err != nil {
|
||||||
|
t.Fatalf("write zip entry %q failed: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
1064
frontend/src/App.tsx
1064
frontend/src/App.tsx
File diff suppressed because it is too large
Load Diff
@@ -49,7 +49,6 @@ export default function TransferSection(props: Props) {
|
|||||||
onDownloadSelected,
|
onDownloadSelected,
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
const pathLabel = path === '/' ? t('root') : path
|
|
||||||
const viewButton = (active: boolean) =>
|
const viewButton = (active: boolean) =>
|
||||||
cn(
|
cn(
|
||||||
'border-[3px] px-4 py-3 text-left text-xs font-black uppercase tracking-[0.14em] transition-[transform,box-shadow,background-color] duration-150',
|
'border-[3px] px-4 py-3 text-left text-xs font-black uppercase tracking-[0.14em] transition-[transform,box-shadow,background-color] duration-150',
|
||||||
@@ -60,15 +59,9 @@ export default function TransferSection(props: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<CardContent className="space-y-5 p-5 md:p-6">
|
<CardContent className="space-y-5 p-5 md:p-6">
|
||||||
<div className="brutal-block space-y-4 p-5">
|
<div className="brutal-block p-5">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<div>
|
<p className="font-display text-4xl">{username}</p>
|
||||||
<p className="text-[10px] font-black uppercase tracking-[0.22em] text-muted-foreground">{t('accountSubtitle')}</p>
|
|
||||||
<p className="font-display text-4xl">{username}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<span className="brutal-chip">{pathLabel}</span>
|
|
||||||
{selectedCount > 0 ? <span className="brutal-chip">{selectedCount} {t('selected')}</span> : null}
|
{selectedCount > 0 ? <span className="brutal-chip">{selectedCount} {t('selected')}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default {
|
|||||||
},
|
},
|
||||||
extend: {
|
extend: {
|
||||||
screens: {
|
screens: {
|
||||||
lg: '600px',
|
wide: '1400px',
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
background: 'hsl(var(--background))',
|
background: 'hsl(var(--background))',
|
||||||
|
|||||||
Reference in New Issue
Block a user