Merge remote-tracking branch 'origin/master'
This commit is contained in:
59
svc/main.go
59
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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user