package main import ( "crypto/subtle" "encoding/json" "errors" "fmt" "io" "net/http" "os" "path/filepath" "regexp" "sort" "strconv" "strings" "time" "git.cin.su/m/Assistant/adapters/notificationexecutor" ) const maxDeliveryBytes = 8 << 10 var safeIntent = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`) type storedReceipt struct { notificationexecutor.Receipt Payload notificationexecutor.Payload `json:"payload"` } type receiverServer struct { directory string writeToken string readToken string now func() time.Time } func newReceiverServer(directory, writeToken, readToken string, now func() time.Time) (*receiverServer, error) { if directory == "" || strings.TrimSpace(writeToken) == "" || strings.TrimSpace(readToken) == "" || writeToken == readToken || now == nil { return nil, errors.New("invalid receiver configuration") } if err := os.MkdirAll(directory, 0700); err != nil { return nil, fmt.Errorf("create receipt directory: %w", err) } return &receiverServer{directory: directory, writeToken: writeToken, readToken: readToken, now: now}, nil } func (s *receiverServer) routes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{"status":"ok"}`) }) mux.HandleFunc("PUT /delivery/v1/deliveries/{intent}", s.put) mux.HandleFunc("GET /delivery/v1/receipts/{intent}", s.get) mux.HandleFunc("GET /delivery/v1/receipts", s.list) return mux } func (s *receiverServer) put(w http.ResponseWriter, r *http.Request) { if !tokenMatches(s.writeToken, r) { http.Error(w, "receiver writer denied", http.StatusUnauthorized) return } intent := r.PathValue("intent") if !safeIntent.MatchString(intent) { http.Error(w, "invalid action intent", http.StatusBadRequest) return } var request notificationexecutor.DeliveryRequest decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxDeliveryBytes)) decoder.DisallowUnknownFields() if err := decoder.Decode(&request); err != nil || string(request.ActionIntentID) != intent { http.Error(w, "invalid delivery", http.StatusBadRequest) return } if err := ensureEOF(decoder); err != nil { http.Error(w, "invalid delivery", http.StatusBadRequest) return } digest, err := notificationexecutor.Digest(request.Payload) if err != nil || digest != request.RequestDigest { http.Error(w, "delivery digest mismatch", http.StatusConflict) return } receipt := storedReceipt{Receipt: notificationexecutor.Receipt{ReceiptID: "receipt-" + intent, ActionIntentID: request.ActionIntentID, RequestDigest: digest, AcceptedAt: s.now().UnixMilli()}, Payload: request.Payload} created, existing, err := s.create(receipt) if err != nil { http.Error(w, "receipt unavailable", http.StatusInternalServerError) return } if !created { if existing.RequestDigest != digest { http.Error(w, "action intent digest conflict", http.StatusConflict) return } receipt = existing } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") if created { w.WriteHeader(http.StatusCreated) } _ = json.NewEncoder(w).Encode(receipt) } func (s *receiverServer) get(w http.ResponseWriter, r *http.Request) { if !tokenMatches(s.writeToken, r) && !tokenMatches(s.readToken, r) { http.Error(w, "receiver reader denied", http.StatusUnauthorized) return } intent := r.PathValue("intent") if !safeIntent.MatchString(intent) { http.Error(w, "invalid action intent", http.StatusBadRequest) return } receipt, err := s.load(intent) if errors.Is(err, os.ErrNotExist) { http.Error(w, "receipt not found", http.StatusNotFound) return } if err != nil { http.Error(w, "receipt unavailable", http.StatusInternalServerError) return } writeStoredReceipt(w, receipt) } func (s *receiverServer) list(w http.ResponseWriter, r *http.Request) { if !tokenMatches(s.readToken, r) { http.Error(w, "receiver reader denied", http.StatusUnauthorized) return } limit := 50 if raw := r.URL.Query().Get("limit"); raw != "" { parsed, err := strconv.Atoi(raw) if err != nil || parsed < 1 || parsed > 100 { http.Error(w, "invalid limit", http.StatusBadRequest) return } limit = parsed } entries, err := os.ReadDir(s.directory) if err != nil { http.Error(w, "receipts unavailable", http.StatusInternalServerError) return } receipts := make([]storedReceipt, 0, len(entries)) for _, entry := range entries { if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { continue } receipt, loadErr := s.load(strings.TrimSuffix(entry.Name(), ".json")) if loadErr != nil { continue } receipts = append(receipts, receipt) } sort.Slice(receipts, func(i, j int) bool { if receipts[i].AcceptedAt == receipts[j].AcceptedAt { return receipts[i].ReceiptID > receipts[j].ReceiptID } return receipts[i].AcceptedAt > receipts[j].AcceptedAt }) if len(receipts) > limit { receipts = receipts[:limit] } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(struct { Items []storedReceipt `json:"items"` }{Items: receipts}) } func (s *receiverServer) create(receipt storedReceipt) (bool, storedReceipt, error) { encoded, err := json.Marshal(receipt) if err != nil { return false, storedReceipt{}, err } temporary, err := os.CreateTemp(s.directory, ".receipt-*.tmp") if err != nil { return false, storedReceipt{}, err } temporaryName := temporary.Name() defer os.Remove(temporaryName) if err = temporary.Chmod(0600); err == nil { _, err = temporary.Write(encoded) } if err == nil { err = temporary.Sync() } closeErr := temporary.Close() if err == nil { err = closeErr } if err != nil { return false, storedReceipt{}, err } target := s.path(string(receipt.ActionIntentID)) if err = os.Link(temporaryName, target); err == nil { return true, storedReceipt{}, nil } if !errors.Is(err, os.ErrExist) { return false, storedReceipt{}, err } existing, err := s.load(string(receipt.ActionIntentID)) return false, existing, err } func (s *receiverServer) load(intent string) (storedReceipt, error) { var receipt storedReceipt file, err := os.Open(s.path(intent)) if err != nil { return receipt, err } defer file.Close() decoder := json.NewDecoder(io.LimitReader(file, maxDeliveryBytes+1)) if err = decoder.Decode(&receipt); err != nil { return storedReceipt{}, err } if string(receipt.ActionIntentID) != intent { return storedReceipt{}, errors.New("receipt identity mismatch") } return receipt, nil } func (s *receiverServer) path(intent string) string { return filepath.Join(s.directory, intent+".json") } func tokenMatches(expected string, r *http.Request) bool { provided := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") return len(provided) == len(expected) && subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) == 1 } func ensureEOF(decoder *json.Decoder) error { var extra any err := decoder.Decode(&extra) if errors.Is(err, io.EOF) { return nil } return errors.New("trailing JSON") } func writeStoredReceipt(w http.ResponseWriter, receipt storedReceipt) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(receipt) }