From dd89a5cf2d3c5195ae791d844993f94c9ffe7570 Mon Sep 17 00:00:00 2001 From: mixa Date: Fri, 6 Mar 2026 21:32:58 +0300 Subject: [PATCH] Autocommit test+ui polish --- Makefile | 9 +- README.md | 2 +- backend/main.go | 1 + backend/oauth_google.go | 134 ++- backend/oauth_google_test.go | 83 +- frontend/src/App.tsx | 883 +++++++++++-------- frontend/src/components/ui/badge.tsx | 8 +- frontend/src/components/ui/button.tsx | 18 +- frontend/src/components/ui/card.tsx | 6 +- frontend/src/components/ui/context-menu.tsx | 6 +- frontend/src/components/ui/dialog.tsx | 14 +- frontend/src/components/ui/dropdown-menu.tsx | 6 +- frontend/src/components/ui/input.tsx | 2 +- frontend/src/components/ui/select.tsx | 6 +- frontend/src/components/ui/tabs.tsx | 4 +- frontend/src/components/ui/textarea.tsx | 2 +- frontend/src/index.css | 338 ++++--- frontend/src/lazy/AdminPanel.tsx | 80 +- frontend/src/lazy/TransferSection.tsx | 66 +- 19 files changed, 1115 insertions(+), 553 deletions(-) diff --git a/Makefile b/Makefile index d75e59b..2bb90f3 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build-all frontend-build sync-web backend-build run-all up down run-local run-backend run-frontend clean +.PHONY: build-all frontend-build sync-web backend-build run-all up down run-local run-backend run-frontend auto-commit push clean build-all: frontend-build sync-web backend-build @@ -33,5 +33,12 @@ run-backend: run-frontend: bun --cwd frontend dev +auto-commit: + git add -A + git commit -m "$(if $(MSG),$(MSG),chore: auto commit $$(date '+%Y-%m-%d %H:%M:%S'))" + +push: + git push $(if $(REMOTE),$(REMOTE),origin) $(if $(BRANCH),$(BRANCH),$$(git branch --show-current)) + clean: rm -rf dist backend/web/dist frontend/dist diff --git a/README.md b/README.md index 98c7761..32dab4d 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Drive-like app with Go backend + Bun/React frontend. - UI uses Radix-based components (shadcn-style wrappers) - Auto language detection with English/Russian translations - Optional per-user FTP and FTPS access (same FileZ usernames/passwords) -- Optional Google OAuth login (auto-provisions user on first sign-in) +- Optional Google OAuth login (auto-provisions user on first sign-in or links to existing signed-in user) ## Task-style commands diff --git a/backend/main.go b/backend/main.go index 27006b3..745a2e1 100644 --- a/backend/main.go +++ b/backend/main.go @@ -203,6 +203,7 @@ func main() { protected.Use(s.authMiddleware) protected.HandleFunc("/auth/me", s.handleMe).Methods(http.MethodGet) protected.HandleFunc("/user/preferences", s.handleSetPreferences).Methods(http.MethodPost) + protected.HandleFunc("/user/google/link/start", s.handleGoogleLinkStart).Methods(http.MethodGet) protected.HandleFunc("/user/protocols", s.handleUserProtocols).Methods(http.MethodGet) protected.HandleFunc("/files", s.handleListFiles).Methods(http.MethodGet) protected.HandleFunc("/files/upload", s.handleUpload).Methods(http.MethodPost) diff --git a/backend/oauth_google.go b/backend/oauth_google.go index 900f02a..e315524 100644 --- a/backend/oauth_google.go +++ b/backend/oauth_google.go @@ -8,9 +8,14 @@ import ( "net/http" "net/url" "strings" + + "github.com/golang-jwt/jwt/v5" ) -const googleOAuthStateCookie = "google_oauth_state" +const ( + googleOAuthLoginStateCookie = "google_oauth_state" + googleOAuthLinkStateCookie = "google_oauth_link_state" +) type googleTokenResponse struct { AccessToken string `json:"access_token"` @@ -33,7 +38,39 @@ func (s *Server) handleGoogleAuthStart(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusInternalServerError, "failed to initialize oauth") return } - setCookie(w, googleOAuthStateCookie, state, 600, s.config.CookieSecure) + setCookie(w, googleOAuthLoginStateCookie, state, 600, s.config.CookieSecure) + clearCookie(w, googleOAuthLinkStateCookie, s.config.CookieSecure) + + u, err := url.Parse(s.config.GoogleAuthURL) + if err != nil { + writeErr(w, http.StatusInternalServerError, "invalid google auth config") + return + } + q := u.Query() + q.Set("client_id", strings.TrimSpace(s.config.GoogleClientID)) + q.Set("redirect_uri", s.googleRedirectURL(r)) + q.Set("response_type", "code") + q.Set("scope", "openid email profile") + q.Set("state", state) + q.Set("prompt", "select_account") + u.RawQuery = q.Encode() + + http.Redirect(w, r, u.String(), http.StatusFound) +} + +func (s *Server) handleGoogleLinkStart(w http.ResponseWriter, r *http.Request) { + if !s.config.GoogleAuthEnabled { + writeErr(w, http.StatusNotFound, "google auth is disabled") + return + } + + state, err := randomToken() + if err != nil { + writeErr(w, http.StatusInternalServerError, "failed to initialize oauth") + return + } + setCookie(w, googleOAuthLinkStateCookie, state, 600, s.config.CookieSecure) + clearCookie(w, googleOAuthLoginStateCookie, s.config.CookieSecure) u, err := url.Parse(s.config.GoogleAuthURL) if err != nil { @@ -70,12 +107,11 @@ func (s *Server) handleGoogleAuthCallback(w http.ResponseWriter, r *http.Request } state := strings.TrimSpace(r.URL.Query().Get("state")) - stateCookie, err := r.Cookie(googleOAuthStateCookie) - if err != nil || stateCookie == nil || stateCookie.Value == "" || subtleConstantTimeEq(stateCookie.Value, state) == 0 { + flow, uid, ok := s.resolveGoogleOAuthFlow(w, r, state) + if !ok { writeErr(w, http.StatusUnauthorized, "invalid oauth state") return } - clearCookie(w, googleOAuthStateCookie, s.config.CookieSecure) token, err := s.exchangeGoogleCode(r.Context(), code, s.googleRedirectURL(r)) if err != nil { @@ -91,6 +127,17 @@ func (s *Server) handleGoogleAuthCallback(w http.ResponseWriter, r *http.Request return } + if flow == "link" { + if err := s.linkGoogleSubToUser(uid, info.Sub); err != nil { + log.Printf("auth.google.failed ip=%q reason=%q", clientIP(r), "user_link_failed") + writeErr(w, http.StatusUnauthorized, "google account link failed") + return + } + log.Printf("auth.google.linked user_id=%d ip=%q", uid, clientIP(r)) + http.Redirect(w, r, "/drive", http.StatusFound) + return + } + user, err := s.findOrCreateGoogleUser(info.Sub, info.Email) if err != nil { log.Printf("auth.google.failed ip=%q reason=%q", clientIP(r), "user_provision_failed") @@ -108,6 +155,83 @@ func (s *Server) handleGoogleAuthCallback(w http.ResponseWriter, r *http.Request http.Redirect(w, r, "/drive", http.StatusFound) } +func (s *Server) resolveGoogleOAuthFlow(w http.ResponseWriter, r *http.Request, state string) (string, int64, bool) { + state = strings.TrimSpace(state) + if state == "" { + return "", 0, false + } + + if linkCookie, err := r.Cookie(googleOAuthLinkStateCookie); err == nil && linkCookie != nil && linkCookie.Value != "" { + if subtleConstantTimeEq(linkCookie.Value, state) == 1 { + clearCookie(w, googleOAuthLinkStateCookie, s.config.CookieSecure) + uid, uidErr := s.userIDFromAccessCookie(r) + if uidErr != nil { + return "", 0, false + } + return "link", uid, true + } + } + + if loginCookie, err := r.Cookie(googleOAuthLoginStateCookie); err == nil && loginCookie != nil && loginCookie.Value != "" { + if subtleConstantTimeEq(loginCookie.Value, state) == 1 { + clearCookie(w, googleOAuthLoginStateCookie, s.config.CookieSecure) + return "login", 0, true + } + } + + return "", 0, false +} + +func (s *Server) userIDFromAccessCookie(r *http.Request) (int64, error) { + cookie, err := r.Cookie("access_token") + if err != nil || cookie == nil || cookie.Value == "" { + return 0, fmt.Errorf("missing access token") + } + + claims := &AccessClaims{} + tkn, err := jwt.ParseWithClaims(cookie.Value, claims, func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method") + } + return []byte(s.config.JWTSecret), nil + }) + if err != nil || !tkn.Valid { + return 0, fmt.Errorf("invalid access token") + } + if claims.UserID <= 0 { + return 0, fmt.Errorf("invalid access token claims") + } + return claims.UserID, nil +} + +func (s *Server) linkGoogleSubToUser(userID int64, googleSub string) error { + googleSub = strings.TrimSpace(googleSub) + if userID <= 0 || googleSub == "" { + return fmt.Errorf("invalid link request") + } + + if _, err := s.findUser(userID); err != nil { + return err + } + + if existing, err := s.findUserByGoogleSub(googleSub); err == nil { + if existing.ID != userID { + return fmt.Errorf("google account already linked") + } + return nil + } else if !isNoRows(err) { + return err + } + + if err := s.orm.updateGoogleSub(userID, googleSub); err != nil { + if strings.Contains(strings.ToLower(err.Error()), "unique") { + return fmt.Errorf("google account already linked") + } + return err + } + return nil +} + func (s *Server) googleRedirectURL(r *http.Request) string { if v := strings.TrimSpace(s.config.GoogleRedirectURL); v != "" { return v diff --git a/backend/oauth_google_test.go b/backend/oauth_google_test.go index 2a44676..d693884 100644 --- a/backend/oauth_google_test.go +++ b/backend/oauth_google_test.go @@ -60,7 +60,7 @@ func TestGoogleOAuthCallbackCreatesSessionAndUser(t *testing.T) { if startRec.Code != http.StatusFound { t.Fatalf("start status = %d, want %d", startRec.Code, http.StatusFound) } - stateCookie := cookieByName(startRec.Result().Cookies(), googleOAuthStateCookie) + stateCookie := cookieByName(startRec.Result().Cookies(), googleOAuthLoginStateCookie) if stateCookie == nil || stateCookie.Value == "" { t.Fatal("missing oauth state cookie") } @@ -106,3 +106,84 @@ func TestGoogleOAuthCallbackCreatesSessionAndUser(t *testing.T) { t.Fatalf("google_sub = %q, want %q", googleSub, "google-sub-1") } } + +func TestGoogleOAuthCallbackLinksGoogleToExistingUser(t *testing.T) { + t.Parallel() + + mux := http.NewServeMux() + provider := httptest.NewServer(mux) + defer provider.Close() + + mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "google-link-token"}) + }) + + mux.HandleFunc("/userinfo", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "sub": "google-sub-link-1", + "email": "other@example.com", + "email_verified": true, + }) + }) + + s := makeTestServer(t, func(cfg *Config) { + cfg.GoogleAuthEnabled = true + cfg.GoogleClientID = "client-id" + cfg.GoogleClientSecret = "client-secret" + cfg.GoogleAuthURL = provider.URL + "/auth" + cfg.GoogleTokenURL = provider.URL + "/token" + cfg.GoogleUserInfoURL = provider.URL + "/userinfo" + }) + + user, err := s.createUser("alice", "password123", "dracula", "auto") + if err != nil { + t.Fatalf("create user failed: %v", err) + } + + loginReq := httptest.NewRequest(http.MethodGet, "/api/auth/login-link", nil) + loginRec := httptest.NewRecorder() + if err := s.issueUserSession(loginRec, loginReq, user.ID); err != nil { + t.Fatalf("issueUserSession failed: %v", err) + } + accessCookie := cookieByName(loginRec.Result().Cookies(), "access_token") + if accessCookie == nil || accessCookie.Value == "" { + t.Fatal("missing access cookie") + } + + startReq := httptest.NewRequest(http.MethodGet, "/api/user/google/link/start", nil) + startReq.Host = "file.example.com" + startReq.AddCookie(accessCookie) + startRec := httptest.NewRecorder() + s.handleGoogleLinkStart(startRec, startReq) + + if startRec.Code != http.StatusFound { + t.Fatalf("link start status = %d, want %d", startRec.Code, http.StatusFound) + } + stateCookie := cookieByName(startRec.Result().Cookies(), googleOAuthLinkStateCookie) + if stateCookie == nil || stateCookie.Value == "" { + t.Fatal("missing link state cookie") + } + + cbReq := httptest.NewRequest(http.MethodGet, "/api/auth/google/callback?code=ok-code&state="+url.QueryEscape(stateCookie.Value), nil) + cbReq.Host = "file.example.com" + cbReq.AddCookie(stateCookie) + cbReq.AddCookie(accessCookie) + cbRec := httptest.NewRecorder() + s.handleGoogleAuthCallback(cbRec, cbReq) + + if cbRec.Code != http.StatusFound { + t.Fatalf("callback status = %d, want %d", cbRec.Code, http.StatusFound) + } + if got := cbRec.Header().Get("Location"); got != "/drive" { + t.Fatalf("callback redirect = %q, want %q", got, "/drive") + } + + var googleSub string + err = s.db.QueryRow(`SELECT COALESCE(google_sub, '') FROM users WHERE id = ?`, user.ID).Scan(&googleSub) + if err != nil { + t.Fatalf("query user google_sub failed: %v", err) + } + if strings.TrimSpace(googleSub) != "google-sub-link-1" { + t.Fatalf("google_sub = %q, want %q", googleSub, "google-sub-link-1") + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f626914..bf868dd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -29,7 +29,7 @@ import { Tabs, TabsList, TabsTrigger } from './components/ui/tabs' import { Textarea } from './components/ui/textarea' import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from './components/ui/context-menu' -type Theme = 'dracula' | 'nord' | 'monokai' | 'solarized' | 'github' +type Theme = 'dracula' | 'nord' | 'monokai' | 'solarized' | 'github' | 'bureau' type ColorMode = 'auto' | 'light' | 'dark' type Lang = 'en' | 'ru' | 'de' type Route = 'landing' | 'drive' | 'admin' @@ -63,7 +63,7 @@ function resolveModuleValue(mod: unknown): T | null { return (firstFn as T | undefined) ?? null } -const themeOptions: Theme[] = ['dracula', 'nord', 'monokai', 'solarized', 'github'] +const themeOptions: Theme[] = ['dracula', 'nord', 'monokai', 'solarized', 'github', 'bureau'] const modeOptions: ColorMode[] = ['auto', 'light', 'dark'] const archiveOptions: ArchiveFormat[] = ['zip', 'rar', 'tar.gz', 'lz4'] const shareTTLPresets: Array<{ value: string; label: string }> = [ @@ -93,6 +93,7 @@ const dict: Record> = { configuredFromEnv: 'Admin credentials are configured in backend environment.', signIn: 'Sign in', signInGoogle: 'Continue with Google', + linkGoogle: 'Link Google account', signInAdmin: 'Sign in as admin', loading: 'Loading', username: 'Username', @@ -116,6 +117,7 @@ const dict: Record> = { limit: 'Max downloads (blank = unlimited)', accountSubtitle: 'Workspace', settings: 'Settings', + language: 'Language', theme: 'Color scheme', mode: 'Mode', archiveFormat: 'Folder download format', @@ -128,6 +130,7 @@ const dict: Record> = { monokai: 'Monokai', solarized: 'Solarized', github: 'GitHub', + bureau: 'Bureau', search: 'Search in this folder', items: 'items', empty: 'No files in this folder', @@ -179,6 +182,14 @@ const dict: Record> = { optional: 'optional', useAccountPassword: 'Use your account password', copyUri: 'Copy URI', + all: 'all', + shellTagline: 'Private storage, sharing, and admin controls in one interface.', + controlNode: 'Control node', + authFeatureDesc: 'Hardened authentication and session handling.', + storageFeatureDesc: 'Private storage lanes for each account.', + shareFeatureDesc: 'Expiring links with resumable downloads.', + remoteAccessDesc: 'Direct transfer access for desktop clients.', + controlSurface: 'FileZ control surface', }, de: { brand: 'FileZ', @@ -194,6 +205,7 @@ const dict: Record> = { configuredFromEnv: 'Admin-Zugangsdaten werden im Backend-Environment konfiguriert.', signIn: 'Anmelden', signInGoogle: 'Mit Google fortfahren', + linkGoogle: 'Google-Konto verknupfen', signInAdmin: 'Als Admin anmelden', loading: 'Laden', username: 'Benutzername', @@ -217,6 +229,7 @@ const dict: Record> = { limit: 'Max. Downloads (leer = unbegrenzt)', accountSubtitle: 'Arbeitsbereich', settings: 'Einstellungen', + language: 'Sprache', theme: 'Farbschema', mode: 'Modus', archiveFormat: 'Format fur Ordner-Download', @@ -229,6 +242,7 @@ const dict: Record> = { monokai: 'Monokai', solarized: 'Solarized', github: 'GitHub', + bureau: 'Bureau', search: 'In diesem Ordner suchen', items: 'Elemente', empty: 'Keine Dateien in diesem Ordner', @@ -280,6 +294,14 @@ const dict: Record> = { optional: 'optional', useAccountPassword: 'Verwende dein Kontopasswort', copyUri: 'URI kopieren', + all: 'alle', + shellTagline: 'Private Speicherung, Freigaben und Admin-Steuerung in einer Oberflache.', + controlNode: 'Kontrollknoten', + authFeatureDesc: 'Geharte Authentifizierung und Sitzungsverwaltung.', + storageFeatureDesc: 'Private Speicherbereiche fur jedes Konto.', + shareFeatureDesc: 'Ablaufende Links mit fortsetzbaren Downloads.', + remoteAccessDesc: 'Direkter Transferzugang fur Desktop-Clients.', + controlSurface: 'FileZ Kontrolloberflache', }, ru: { brand: 'FileZ', @@ -295,6 +317,7 @@ const dict: Record> = { configuredFromEnv: 'Данные администратора задаются в окружении backend.', signIn: 'Войти', signInGoogle: 'Продолжить через Google', + linkGoogle: 'Привязать Google аккаунт', signInAdmin: 'Войти как админ', loading: 'Загрузка', username: 'Логин', @@ -318,6 +341,7 @@ const dict: Record> = { limit: 'Макс. скачиваний (пусто = без лимита)', accountSubtitle: 'Рабочее пространство', settings: 'Настройки', + language: 'Язык', theme: 'Цветовая схема', mode: 'Режим', archiveFormat: 'Формат скачивания папок', @@ -330,6 +354,7 @@ const dict: Record> = { monokai: 'Monokai', solarized: 'Solarized', github: 'GitHub', + bureau: 'Bureau', search: 'Поиск в текущей папке', items: 'объектов', empty: 'В этой папке нет файлов', @@ -381,6 +406,14 @@ const dict: Record> = { optional: 'опционально', useAccountPassword: 'Используйте пароль аккаунта', copyUri: 'Копировать URI', + all: 'все', + shellTagline: 'Приватное хранилище, шаринг и админ-панель в одном интерфейсе.', + controlNode: 'Контрольный узел', + authFeatureDesc: 'Защищенная аутентификация и управление сессиями.', + storageFeatureDesc: 'Приватные зоны хранения для каждого аккаунта.', + shareFeatureDesc: 'Ссылки с истечением срока и докачкой.', + remoteAccessDesc: 'Прямой доступ для настольных клиентов.', + controlSurface: 'Контрольная панель FileZ', }, } @@ -502,6 +535,29 @@ function KindIcon({ file }: { file: FileEntry }) { return } +function GoogleIcon({ className }: { className?: string }) { + return ( + + ) +} + async function api(url: string, init?: RequestInit, allowRefresh = true): Promise { const response = await fetch(url, { credentials: 'include', @@ -870,6 +926,10 @@ export default function App() { window.location.href = '/api/auth/google/start' } + function linkGoogleAccount() { + window.location.href = '/api/user/google/link/start' + } + async function upload(list: FileList | null, useRelative = false) { if (!list?.length) return const junkNames = new Set(['.ds_store', 'thumbs.db', 'desktop.ini']) @@ -1155,6 +1215,8 @@ export default function App() { return out }, [visibleFiles]) + const routeLabel = route === 'admin' ? '/admin' : route === 'drive' ? '/drive' : '/' + function selectVisibleRange(toPath: string, keepExisting = true) { const anchor = selectionAnchorPath || toPath const fromIdx = visiblePathIndex[anchor] @@ -1177,79 +1239,94 @@ export default function App() { } const authLoadingCard = ( - - -