diff --git a/svc/main.go b/svc/main.go index a8721ec..558db79 100644 --- a/svc/main.go +++ b/svc/main.go @@ -10,6 +10,7 @@ import ( "bssapp-backend/routes" "database/sql" "log" + "log/slog" "net/http" "os" "path" @@ -164,6 +165,7 @@ InitRoutes — FULL V3 (Method-aware) PERMISSION EDITION func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router { r := mux.NewRouter() + mountUploads(r) mountSPA(r) /* @@ -582,7 +584,7 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router bindV3(r, pgDB, "/api/product-images/{id}/content", "GET", "order", "view", - wrapV3(routes.GetProductImageContentHandler(pgDB)), + http.HandlerFunc(routes.GetProductImageContentHandler(pgDB)), ) // ============================================================ @@ -642,7 +644,35 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router return r } +func setupSlog() { + level := new(slog.LevelVar) + switch strings.ToLower(strings.TrimSpace(os.Getenv("LOG_LEVEL"))) { + case "debug": + level.Set(slog.LevelDebug) + case "warn", "warning": + level.Set(slog.LevelWarn) + case "error": + level.Set(slog.LevelError) + default: + level.Set(slog.LevelInfo) + } + + handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: level, + }) + slog.SetDefault(slog.New(handler)) +} + +func mountUploads(r *mux.Router) { + root := uploadsRootDir() + log.Printf("🖼️ uploads root: %s", root) + r.PathPrefix("/uploads/").Handler( + http.StripPrefix("/uploads/", http.FileServer(http.Dir(root))), + ) +} + func main() { + setupSlog() log.Println("🔥🔥🔥 BSSAPP BACKEND STARTED — LOGIN ROUTE SHOULD EXIST 🔥🔥🔥") // ------------------------------------------------------- @@ -807,3 +837,30 @@ func uiRootDir() string { return "../ui/dist/spa" } + +func uploadsRootDir() string { + if root := strings.TrimSpace(os.Getenv("BLOB_ROOT")); root != "" { + candidates := []string{ + root, + filepath.Join(root, "uploads"), + } + for _, d := range candidates { + if fi, err := os.Stat(d); err == nil && fi.IsDir() { + return d + } + } + } + + candidates := []string{ + "./uploads", + "../uploads", + "../../uploads", + } + for _, d := range candidates { + if fi, err := os.Stat(d); err == nil && fi.IsDir() { + return d + } + } + + return "./uploads" +} diff --git a/svc/middlewares/authz_v2.go b/svc/middlewares/authz_v2.go index fc89277..abb9130 100644 --- a/svc/middlewares/authz_v2.go +++ b/svc/middlewares/authz_v2.go @@ -57,6 +57,19 @@ type ttlCache struct { m map[string]cacheItem } +type routeMeta struct { + module string + action string +} + +var routeMetaCache sync.Map + +var routeMetaFallback = map[string]routeMeta{ + "GET /api/product-images": {module: "order", action: "view"}, + "GET /api/product-images/{id}/content": {module: "order", action: "view"}, + "GET /api/product-stock-query-by-attributes": {module: "order", action: "view"}, +} + // ===================================================== // 🌍 GLOBAL SCOPE CACHE (for invalidation) // ===================================================== @@ -912,35 +925,40 @@ func AuthzGuardByRoute(pg *sql.DB) func(http.Handler) http.Handler { } // ===================================================== - // 3️⃣ ROUTE LOOKUP (path + method) + // 3️⃣ ROUTE LOOKUP (path + method) with cache+fallback // ===================================================== var module, action string + routeKey := r.Method + " " + pathTemplate - err = pg.QueryRow(` - SELECT module_code, action - FROM mk_sys_routes - WHERE path = $1 - AND method = $2 - `, - pathTemplate, - r.Method, - ).Scan(&module, &action) - - if err != nil { - - log.Printf( - "❌ AUTHZ: route not registered: %s %s", - r.Method, + if cached, ok := routeMetaCache.Load(routeKey); ok { + meta := cached.(routeMeta) + module, action = meta.module, meta.action + } else { + err = pg.QueryRow(` + SELECT module_code, action + FROM mk_sys_routes + WHERE path = $1 + AND method = $2 + `, pathTemplate, - ) + r.Method, + ).Scan(&module, &action) - if pathTemplate == "/api/password/change" { - http.Error(w, "password change route permission not found", http.StatusForbidden) + if err == nil { + routeMetaCache.Store(routeKey, routeMeta{module: module, action: action}) + } else if fb, ok := routeMetaFallback[routeKey]; ok { + module, action = fb.module, fb.action + routeMetaCache.Store(routeKey, fb) + log.Printf("⚠️ AUTHZ: route lookup fallback used: %s", routeKey) + } else if err == sql.ErrNoRows { + log.Printf("❌ AUTHZ: route not registered: %s %s", r.Method, pathTemplate) + http.Error(w, "route permission not found", http.StatusForbidden) + return + } else { + log.Printf("❌ AUTHZ: route lookup db error: %s %s err=%v", r.Method, pathTemplate, err) + http.Error(w, "permission lookup failed", http.StatusInternalServerError) return } - - http.Error(w, "route permission not found", http.StatusForbidden) - return } // ===================================================== diff --git a/svc/middlewares/global_auth.go b/svc/middlewares/global_auth.go index b962993..d862e79 100644 --- a/svc/middlewares/global_auth.go +++ b/svc/middlewares/global_auth.go @@ -12,6 +12,8 @@ var publicPaths = []string{ "/api/auth/refresh", "/api/password/forgot", "/api/password/reset", + "/api/product-images/", + "/uploads/", } func GlobalAuthMiddleware(db any, next http.Handler) http.Handler { diff --git a/svc/middlewares/request_logger.go b/svc/middlewares/request_logger.go index bd8e4f6..55aec47 100644 --- a/svc/middlewares/request_logger.go +++ b/svc/middlewares/request_logger.go @@ -6,6 +6,7 @@ import ( "log" "net" "net/http" + "strings" "time" ) @@ -59,6 +60,9 @@ func RequestLogger(next http.Handler) http.Handler { log.Printf("⬅️ %s %s | status=%d | %s", r.Method, r.URL.Path, sw.status, time.Since(start)) + // High-frequency endpoints: skip route_access audit to reduce DB/log pressure. + skipAudit := r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/product-images") + // ---------- AUDIT (route_access) ---------- al := auditlog.ActivityLog{ ActionType: "route_access", @@ -95,9 +99,9 @@ func RequestLogger(next http.Handler) http.Handler { al.ErrorMessage = http.StatusText(sw.status) } - // ✅ ESKİ: auditlog.Write(al) - // ✅ YENİ: - auditlog.Enqueue(r.Context(), al) + if !skipAudit { + auditlog.Enqueue(r.Context(), al) + } if claims == nil { log.Println("⚠️ LOGGER: claims is NIL") diff --git a/svc/routes/product_images.go b/svc/routes/product_images.go index 94aad07..413ac47 100644 --- a/svc/routes/product_images.go +++ b/svc/routes/product_images.go @@ -4,12 +4,14 @@ import ( "database/sql" "encoding/json" "fmt" + "log/slog" "net/http" "os" "path/filepath" "strconv" "strings" + "github.com/google/uuid" "github.com/gorilla/mux" ) @@ -24,10 +26,22 @@ type ProductImageItem struct { // GET /api/product-images?code=...&color=... func GetProductImagesHandler(pg *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + reqID := strings.TrimSpace(r.Header.Get("X-Request-ID")) + if reqID == "" { + reqID = uuid.NewString() + } + w.Header().Set("X-Request-ID", reqID) + code := strings.TrimSpace(r.URL.Query().Get("code")) color := strings.TrimSpace(r.URL.Query().Get("color")) if code == "" { + slog.Warn("product_images.list.bad_request", + "req_id", reqID, + "path", r.URL.Path, + "query", r.URL.RawQuery, + "reason", "missing_code", + ) http.Error(w, "Eksik parametre: code gerekli", http.StatusBadRequest) return } @@ -43,14 +57,20 @@ JOIN mmitem i ON i.id = b.src_id WHERE b.typ = 'img' AND b.src_table = 'mmitem' - AND i.code = $1 + AND UPPER(i.code) = UPPER($1) AND ($2 = '' OR b.file_name ILIKE '%' || '-' || $2 || '-%') ORDER BY COALESCE(b.sort_order, 999999), b.zlins_dttm DESC, b.id DESC ` rows, err := pg.Query(query, code, color) if err != nil { - http.Error(w, "Görsel sorgu hatası: "+err.Error(), http.StatusInternalServerError) + slog.Error("product_images.list.query_failed", + "req_id", reqID, + "code", code, + "color", color, + "err", err.Error(), + ) + http.Error(w, "Gorsel sorgu hatasi: "+err.Error(), http.StatusInternalServerError) return } defer rows.Close() @@ -65,6 +85,13 @@ ORDER BY COALESCE(b.sort_order, 999999), b.zlins_dttm DESC, b.id DESC items = append(items, it) } + slog.Info("product_images.list.ok", + "req_id", reqID, + "code", code, + "color", color, + "count", len(items), + ) + w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(items) } @@ -73,10 +100,22 @@ ORDER BY COALESCE(b.sort_order, 999999), b.zlins_dttm DESC, b.id DESC // GET /api/product-images/{id}/content func GetProductImageContentHandler(pg *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + reqID := strings.TrimSpace(r.Header.Get("X-Request-ID")) + if reqID == "" { + reqID = uuid.NewString() + } + w.Header().Set("X-Request-ID", reqID) + idStr := mux.Vars(r)["id"] id, err := strconv.ParseInt(idStr, 10, 64) if err != nil || id <= 0 { - http.Error(w, "Geçersiz görsel id", http.StatusBadRequest) + slog.Warn("product_images.content.bad_request", + "req_id", reqID, + "id_raw", idStr, + "path", r.URL.Path, + "reason", "invalid_id", + ) + http.Error(w, "Gecersiz gorsel id", http.StatusBadRequest) return } @@ -99,47 +138,118 @@ WHERE id = $1 `, id).Scan(&fileName, &storagePath, &storedInDB, &binData) if err != nil { if err == sql.ErrNoRows { + slog.Warn("product_images.content.not_found_row", + "req_id", reqID, + "id", id, + ) http.NotFound(w, r) return } - http.Error(w, "Görsel okunamadı: "+err.Error(), http.StatusInternalServerError) + slog.Error("product_images.content.query_failed", + "req_id", reqID, + "id", id, + "err", err.Error(), + ) + http.Error(w, "Gorsel okunamadi: "+err.Error(), http.StatusInternalServerError) return } if storedInDB && len(binData) > 0 { + slog.Info("product_images.content.served_from_db", + "req_id", reqID, + "id", id, + "file_name", fileName, + "bytes", len(binData), + ) w.Header().Set("Content-Type", http.DetectContentType(binData)) w.Header().Set("Cache-Control", "public, max-age=3600") _, _ = w.Write(binData) return } - resolved := resolveStoragePath(storagePath) + resolved, tried := resolveStoragePath(storagePath) if resolved == "" { + slog.Warn("product_images.content.file_not_found", + "req_id", reqID, + "id", id, + "stored_in_db", storedInDB, + "file_name", fileName, + "storage_path", storagePath, + "tried", tried, + ) http.NotFound(w, r) return } + slog.Info("product_images.content.served_from_file", + "req_id", reqID, + "id", id, + "file_name", fileName, + "storage_path", storagePath, + "resolved_path", resolved, + ) + w.Header().Set("Cache-Control", "public, max-age=3600") http.ServeFile(w, r, resolved) } } -func resolveStoragePath(storagePath string) string { +func resolveStoragePath(storagePath string) (string, []string) { raw := strings.TrimSpace(storagePath) if raw == "" { - return "" + return "", nil + } + + // URL/query temizligi ve platforma uygun normalize + if i := strings.Index(raw, "?"); i >= 0 { + raw = raw[:i] + } + raw = strings.ReplaceAll(raw, "\\", "/") + if scheme := strings.Index(raw, "://"); scheme >= 0 { + rest := raw[scheme+3:] + if i := strings.Index(rest, "/"); i >= 0 { + raw = rest[i:] + } } raw = strings.TrimPrefix(raw, "./") + raw = strings.TrimPrefix(raw, "/") + raw = strings.TrimPrefix(raw, "uploads/") + raw = filepath.ToSlash(filepath.Clean(raw)) + relUploads := filepath.FromSlash(filepath.Join("uploads", raw)) + rawT300 := raw + relUploadsT300 := relUploads + if strings.Contains(filepath.ToSlash(relUploads), "uploads/image/") && + !strings.Contains(filepath.ToSlash(relUploads), "uploads/image/t300/") { + rawT300 = strings.Replace(filepath.ToSlash(raw), "image/", "image/t300/", 1) + relUploadsT300 = filepath.FromSlash( + strings.Replace(filepath.ToSlash(relUploads), "uploads/image/", "uploads/image/t300/", 1), + ) + } + candidates := []string{ filepath.Clean(storagePath), - filepath.Clean(raw), - filepath.Join(".", raw), - filepath.Join("..", raw), + filepath.FromSlash(filepath.Clean(strings.TrimPrefix(storagePath, "/"))), + filepath.FromSlash(filepath.Clean(raw)), + relUploads, + relUploadsT300, + filepath.Join(".", relUploads), + filepath.Join(".", relUploadsT300), + filepath.Join("..", relUploads), + filepath.Join("..", relUploadsT300), + filepath.Join("..", "..", relUploads), + filepath.Join("..", "..", relUploadsT300), } if root := strings.TrimSpace(os.Getenv("BLOB_ROOT")); root != "" { - candidates = append(candidates, filepath.Join(root, raw)) + candidates = append(candidates, + filepath.Join(root, raw), + filepath.Join(root, filepath.FromSlash(rawT300)), + filepath.Join(root, relUploads), + filepath.Join(root, relUploadsT300), + filepath.Join(root, "uploads", raw), + filepath.Join(root, "uploads", filepath.FromSlash(rawT300)), + ) } for _, p := range candidates { @@ -147,9 +257,9 @@ func resolveStoragePath(storagePath string) string { continue } if st, err := os.Stat(p); err == nil && !st.IsDir() { - return p + return p, candidates } } - return "" + return "", candidates } diff --git a/ui/quasar.config.js.temporary.compiled.1772626141848.mjs b/ui/quasar.config.js.temporary.compiled.1772633687488.mjs similarity index 100% rename from ui/quasar.config.js.temporary.compiled.1772626141848.mjs rename to ui/quasar.config.js.temporary.compiled.1772633687488.mjs diff --git a/ui/src/pages/ProductStockByAttributes.vue b/ui/src/pages/ProductStockByAttributes.vue index 99a5381..3229715 100644 --- a/ui/src/pages/ProductStockByAttributes.vue +++ b/ui/src/pages/ProductStockByAttributes.vue @@ -90,6 +90,7 @@
ADET
+
FOTO
@@ -176,6 +177,24 @@ + +
+ + + +
+ +
+
+
+
@@ -217,7 +237,7 @@