package api import ( "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "errors" "net/http" "strings" "time" "dana/internal/storage" ) // Ссылка «только просмотр» на дашборд клиники для человека без входа в админку (маркетолога). // Токен стоит в адресе после «#»: эта часть не уходит на сервер, в журналы и в Referer. // Страница передаёт его в заголовке Authorization — Caddy этот заголовок в журнал не пишет. // В базе хранится только SHA-256 токена; новый выпуск отключает прежнюю ссылку. const sharePagePath = "/admin/share.html" func shareTokenHash(token string) string { sum := sha256.Sum256([]byte(token)) return hex.EncodeToString(sum[:]) } func (a *API) adminShareLink(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { clinic, valid := requestClinic(w, r) if !valid { return } info, err := a.store.ShareLinkInfo(r.Context(), clinic.Key) if err != nil { handleErr(w, err) return } ok(w, info, nil) } // adminCreateShareLink выпускает новую ссылку; токен отдаётся только в этом ответе. func (a *API) adminCreateShareLink(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { clinic, valid := requestClinic(w, r) if !valid { return } b := make([]byte, 32) if _, err := rand.Read(b); err != nil { handleErr(w, err) return } token := base64.RawURLEncoding.EncodeToString(b) if err := a.store.SetShareLink(r.Context(), clinic.Key, shareTokenHash(token), u.ID); err != nil { handleErr(w, err) return } a.store.Audit(r.Context(), u.ID, "create", "share_link_"+clinic.Key, 0, clientIP(r)) w.Header().Set("Cache-Control", "no-store") ok(w, map[string]any{"path": sharePagePath + "#" + token}, nil) } func (a *API) adminDeleteShareLink(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { clinic, valid := requestClinic(w, r) if !valid { return } if err := a.store.DeleteShareLink(r.Context(), clinic.Key); err != nil { handleErr(w, err) return } a.store.Audit(r.Context(), u.ID, "delete", "share_link_"+clinic.Key, 0, clientIP(r)) ok(w, map[string]any{"clinic": clinic.Key}, nil) } // shareWeeklyReports — дашборд клиники по ссылке: те же периоды, что во вкладке «По неделям», // только чтение. Данных пациентов там нет — только итоги по каналам и колл-центру. func (a *API) shareWeeklyReports(w http.ResponseWriter, r *http.Request) { if a.tooMany(w, "share:"+clientIP(r), 60, time.Minute) { return } w.Header().Set("Cache-Control", "no-store") token := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) if len(token) < 40 || len(token) > 64 { fail(w, http.StatusUnauthorized, "share_invalid", "Ссылка недействительна") return } key, err := a.store.ShareLinkClinic(r.Context(), shareTokenHash(token)) clinic := clinicByKey[key] if errors.Is(err, storage.ErrNotFound) || (err == nil && clinic == nil) { fail(w, http.StatusUnauthorized, "share_invalid", "Ссылка недействительна или её отключили — попросите новую") return } if err != nil { handleErr(w, err) return } list, err := a.store.WeeklyReports(r.Context(), clinic.Key) if err != nil { handleErr(w, err) return } ok(w, list, map[string]any{"clinic": clinic.Key, "title": clinic.Title}) }