feat: overhaul drive UI and previews
This commit is contained in:
@@ -122,6 +122,7 @@ Important values:
|
||||
- `CORS_ALLOWED_ORIGIN`
|
||||
- `APP_DOMAIN`
|
||||
- `MAX_BODY_MB`
|
||||
- `OCR_LANGS` (default: `eng+osd`, example: `eng+osd+rus`)
|
||||
- `RATE_LIMIT_PER_MIN`
|
||||
- `AUTH_RATE_LIMIT_PER_MIN`
|
||||
- `GOOGLE_AUTH_ENABLED`
|
||||
|
||||
@@ -61,7 +61,14 @@ func makeTestServer(t *testing.T, mutate func(*Config)) *Server {
|
||||
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) {
|
||||
|
||||
@@ -20,6 +20,7 @@ type Config struct {
|
||||
CORSOrigin string
|
||||
CookieSecure bool
|
||||
MaxBodyBytes int64
|
||||
OCRLangs string
|
||||
|
||||
GoogleAuthEnabled bool
|
||||
GoogleClientID string
|
||||
@@ -85,6 +86,7 @@ func loadConfig() Config {
|
||||
CORSOrigin: getEnv("CORS_ALLOWED_ORIGIN", ""),
|
||||
CookieSecure: getEnv("COOKIE_SECURE", "false") == "true",
|
||||
MaxBodyBytes: int64(getEnvInt("MAX_BODY_MB", 8)) * 1024 * 1024,
|
||||
OCRLangs: normalizeOCRLangs(getEnv("OCR_LANGS", "eng+osd")),
|
||||
GoogleAuthEnabled: getEnv("GOOGLE_AUTH_ENABLED", "false") == "true",
|
||||
GoogleClientID: getEnv("GOOGLE_CLIENT_ID", ""),
|
||||
GoogleClientSecret: getEnv("GOOGLE_CLIENT_SECRET", ""),
|
||||
@@ -177,6 +179,43 @@ func loadConfig() Config {
|
||||
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) {
|
||||
if cfg == nil {
|
||||
return
|
||||
|
||||
@@ -31,3 +31,29 @@ func TestApplyFTPSLetsEncryptDefaultsCustomDirAndPreserveManual(t *testing.T) {
|
||||
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,
|
||||
} = props
|
||||
|
||||
const pathLabel = path === '/' ? t('root') : path
|
||||
const viewButton = (active: boolean) =>
|
||||
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',
|
||||
@@ -60,15 +59,9 @@ export default function TransferSection(props: Props) {
|
||||
|
||||
return (
|
||||
<CardContent className="space-y-5 p-5 md:p-6">
|
||||
<div className="brutal-block space-y-4 p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<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>
|
||||
<div className="brutal-block p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<p className="font-display text-4xl">{username}</p>
|
||||
{selectedCount > 0 ? <span className="brutal-chip">{selectedCount} {t('selected')}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ export default {
|
||||
},
|
||||
extend: {
|
||||
screens: {
|
||||
lg: '600px',
|
||||
wide: '1400px',
|
||||
},
|
||||
colors: {
|
||||
background: 'hsl(var(--background))',
|
||||
|
||||
Reference in New Issue
Block a user