feat: sync deployed v1.0.0-rc.38-custom.42
Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 1m40s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 42m16s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s
Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 1m40s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 42m16s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s
Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
This commit is contained in:
1 parent
c2b44332d6
commit
a5f7b8e586
828 files changed
+122253
-13702
No files matched your search
@@ -0,0 +1,498 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/middleware"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
fingerprint "github.com/QuantumNous/new-api/pkg/model_fingerprint"
|
||||
"github.com/QuantumNous/new-api/relay"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
var detectionKeyPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{16,96}$`)
|
||||
var detectionCalls sync.Map
|
||||
|
||||
type detectionRequest struct {
|
||||
Group string `json:"group"`
|
||||
Kind string `json:"kind"`
|
||||
Key string `json:"key"`
|
||||
PriceVersion string `json:"price_version"`
|
||||
}
|
||||
|
||||
func detectionInput(c *gin.Context) (detectionRequest, *model.User, service.DetectionPrice, int, error) {
|
||||
var input detectionRequest
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
return input, nil, service.DetectionPrice{}, 0, errors.New("invalid detection request")
|
||||
}
|
||||
if input.Kind != "pelican" && input.Kind != "fingerprint" {
|
||||
return input, nil, service.DetectionPrice{}, 0, errors.New("invalid detection type")
|
||||
}
|
||||
user, err := model.GetUserById(c.GetInt("id"), false)
|
||||
if err != nil {
|
||||
return input, nil, service.DetectionPrice{}, 0, err
|
||||
}
|
||||
if !service.IsUserSelectableGroup(user.Group, input.Group) {
|
||||
return input, nil, service.DetectionPrice{}, 0, errors.New("group unavailable")
|
||||
}
|
||||
price, reserve, err := service.DetectionPricing(user.Group, input.Group, input.Kind)
|
||||
return input, user, price, reserve, err
|
||||
}
|
||||
|
||||
func EstimateDetection(c *gin.Context) {
|
||||
input, _, price, reserve, err := detectionInput(c)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
encoded, _ := common.Marshal(price)
|
||||
calls := 1
|
||||
if input.Kind == "fingerprint" {
|
||||
calls = 3
|
||||
}
|
||||
c.JSON(200, gin.H{"success": true, "data": gin.H{"reserved": reserve, "ratio": price.Snapshot.GroupRatio, "calls": calls, "price_version": model.DetectionSecretHash(string(encoded))}})
|
||||
}
|
||||
|
||||
func CreateUserDetection(c *gin.Context) {
|
||||
input, user, price, reserve, err := detectionInput(c)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if !detectionKeyPattern.MatchString(input.Key) {
|
||||
common.ApiError(c, errors.New("invalid idempotency key"))
|
||||
return
|
||||
}
|
||||
var previous []model.DetectionRun
|
||||
key := fmt.Sprintf("user:%d:%s", user.Id, input.Key)
|
||||
if err = model.DB.Where("request_key = ?", key).Find(&previous).Error; err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if len(previous) > 0 {
|
||||
if previous[0].GroupName != input.Group || previous[0].Kind != input.Kind {
|
||||
common.ApiError(c, errors.New("idempotency key conflict"))
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"success": true, "data": gin.H{"id": previous[0].ID}})
|
||||
return
|
||||
}
|
||||
encoded, _ := common.Marshal(price)
|
||||
if input.PriceVersion != model.DetectionSecretHash(string(encoded)) {
|
||||
common.ApiError(c, errors.New("pricing changed; request a new estimate"))
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
price.Request.At = &now
|
||||
encoded, _ = common.Marshal(price)
|
||||
channel, err := model.GetRandomSatisfiedChannel(input.Group, fingerprint.Model, 0, nil)
|
||||
if err != nil || channel == nil || !service.ChannelSupportsAstra(channel) {
|
||||
common.ApiError(c, errors.New("no Astra channel available"))
|
||||
return
|
||||
}
|
||||
id, err := common.GenerateRandomCharsKey(32)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
run := &model.DetectionRun{ID: id, UserID: user.Id, RequestKey: fmt.Sprintf("user:%d:%s", user.Id, input.Key), ChannelID: channel.Id, GroupName: input.Group, Kind: input.Kind, Status: "pending", CreatedAt: time.Now().Unix(), Reserved: reserve, Price: string(encoded)}
|
||||
if err = model.CreateDetection(run); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"success": true, "data": gin.H{"id": run.ID}})
|
||||
}
|
||||
|
||||
func GetUserDetection(c *gin.Context) {
|
||||
var run model.DetectionRun
|
||||
query := model.DB.Where("user_id = ?", c.GetInt("id"))
|
||||
if c.Param("id") == "latest" {
|
||||
query = query.Where("group_name = ?", c.Query("group")).Order("created_at desc")
|
||||
} else {
|
||||
query = query.Where("id = ?", c.Param("id"))
|
||||
}
|
||||
if err := query.First(&run).Error; err != nil {
|
||||
if c.Param("id") == "latest" {
|
||||
c.JSON(200, gin.H{"success": true, "data": nil})
|
||||
} else {
|
||||
c.JSON(404, gin.H{"success": false, "message": "Detection not found"})
|
||||
}
|
||||
return
|
||||
}
|
||||
var calls []model.DetectionCall
|
||||
if err := model.DB.Where("run_id = ?", run.ID).Find(&calls).Error; err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
usage := []any{}
|
||||
for _, call := range calls {
|
||||
var parsed any
|
||||
_ = common.UnmarshalJsonStr(call.Usage, &parsed)
|
||||
if parsed != nil {
|
||||
usage = append(usage, parsed)
|
||||
}
|
||||
if call.Error != "" && run.Error == "" {
|
||||
run.Error = "usage_or_generation_incomplete"
|
||||
}
|
||||
}
|
||||
c.JSON(200, gin.H{"success": true, "data": gin.H{"id": run.ID, "kind": run.Kind, "status": run.Status, "quality": run.Quality, "reserved": run.Reserved, "charged": run.Charged, "settled": run.Settled, "usage": usage, "error": run.Error}})
|
||||
}
|
||||
|
||||
func GetDetectionArtwork(c *gin.Context) {
|
||||
getDetectionArtwork(c, false)
|
||||
}
|
||||
|
||||
func GetSiteDetectionArtwork(c *gin.Context) {
|
||||
getDetectionArtwork(c, true)
|
||||
}
|
||||
|
||||
func getDetectionArtwork(c *gin.Context, allGroups bool) {
|
||||
var run model.DetectionRun
|
||||
if err := model.DB.Select("html", "channel_id", "created_at").Where("id = ? AND created_at > ?", c.Param("id"), time.Now().Add(-6*time.Hour).Unix()).First(&run).Error; err != nil || run.HTML == "" {
|
||||
c.Status(404)
|
||||
return
|
||||
}
|
||||
settings, err := model.GetStatusPageGroups(c.Request.Context())
|
||||
if err != nil {
|
||||
c.Status(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
channels, err := model.GetStatusChannels(c.Request.Context())
|
||||
if err != nil {
|
||||
c.Status(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
usable := service.GetUserUsableGroups("")
|
||||
visible := false
|
||||
for _, channel := range channels {
|
||||
if channel.Id != run.ChannelID {
|
||||
continue
|
||||
}
|
||||
for group := range strings.SplitSeq(channel.Group, ",") {
|
||||
group = strings.TrimSpace(group)
|
||||
if _, ok := usable[group]; (allGroups || (ok && !settings[group].Hidden)) && run.CreatedAt > settings[group].ResetBefore {
|
||||
visible = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !visible {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
c.Header("Content-Security-Policy", "sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; form-action 'none'; base-uri 'none'; frame-ancestors 'self'")
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Data(200, "text/html; charset=utf-8", []byte(sanitizeDetectionHTML(run.HTML)))
|
||||
}
|
||||
|
||||
// Decode only generation fields: external credentials cannot forward tools,
|
||||
// previous responses, billing modifiers or arbitrary provider parameters.
|
||||
func detectionProxyRequest(data []byte) (*dto.OpenAIResponsesRequest, error) {
|
||||
var fields map[string]any
|
||||
if len(data) > 64<<10 || common.Unmarshal(data, &fields) != nil {
|
||||
return nil, errors.New("invalid request")
|
||||
}
|
||||
if name, ok := fields["model"].(string); !ok || name != fingerprint.Model {
|
||||
return nil, errors.New("model not allowed")
|
||||
}
|
||||
for key := range fields {
|
||||
switch key {
|
||||
case "model", "input", "instructions", "stream", "max_output_tokens", "reasoning", "store", "tools", "tool_choice":
|
||||
default:
|
||||
return nil, errors.New("unsupported request field")
|
||||
}
|
||||
}
|
||||
input, err := common.Marshal(fields["input"])
|
||||
if err != nil || len(input) > 16384 {
|
||||
return nil, errors.New("input too large")
|
||||
}
|
||||
var checkText func(any) bool
|
||||
checkText = func(v any) bool {
|
||||
switch value := v.(type) {
|
||||
case string:
|
||||
return true
|
||||
case []any:
|
||||
for _, item := range value {
|
||||
if !checkText(item) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
case map[string]any:
|
||||
for k, item := range value {
|
||||
switch k {
|
||||
case "role":
|
||||
role, ok := item.(string)
|
||||
if !ok || (role != "user" && role != "system" && role != "developer") {
|
||||
return false
|
||||
}
|
||||
case "type":
|
||||
kind, ok := item.(string)
|
||||
if !ok || (kind != "input_text" && kind != "message") {
|
||||
return false
|
||||
}
|
||||
case "content", "text":
|
||||
if !checkText(item) {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
if !checkText(fields["input"]) {
|
||||
return nil, errors.New("text input required")
|
||||
}
|
||||
instructions, _ := fields["instructions"].(string)
|
||||
if len(input)+len(instructions) > 16384 {
|
||||
return nil, errors.New("input too large")
|
||||
}
|
||||
// Service-selected bounds cannot be overridden by the bearer.
|
||||
fields = map[string]any{"model": fingerprint.Model, "input": fields["input"], "instructions": instructions, "stream": true, "max_output_tokens": 16384, "reasoning": map[string]string{"effort": "high"}, "store": false, "tools": []any{}}
|
||||
encoded, _ := common.Marshal(fields)
|
||||
var request dto.OpenAIResponsesRequest
|
||||
err = common.Unmarshal(encoded, &request)
|
||||
return &request, err
|
||||
}
|
||||
|
||||
func DetectionProxy(c *gin.Context) {
|
||||
secret := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
|
||||
if !strings.HasPrefix(secret, "det_") {
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(c.Request.Body, (64<<10)+1))
|
||||
if err != nil {
|
||||
c.AbortWithStatus(400)
|
||||
return
|
||||
}
|
||||
request, err := detectionProxyRequest(data)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": gin.H{"message": err.Error()}})
|
||||
return
|
||||
}
|
||||
call, err := model.ClaimDetectionCall(model.DetectionSecretHash(secret))
|
||||
if err != nil {
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
var run model.DetectionRun
|
||||
if err = model.DB.First(&run, "id = ?", call.RunID).Error; err != nil {
|
||||
c.AbortWithStatus(503)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithDeadline(c.Request.Context(), time.Unix(run.Deadline-10, 0))
|
||||
detectionCalls.Store(call.ID, cancel)
|
||||
defer func() { cancel(); detectionCalls.Delete(call.ID) }()
|
||||
channel, err := model.GetChannelById(run.ChannelID, true)
|
||||
var raw []byte
|
||||
var contentType string
|
||||
// Flush SSE comments while generation is buffered for accounting. Without
|
||||
// these bytes, the public edge times out a valid long reasoning request.
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
c.Writer.WriteHeader(http.StatusOK)
|
||||
_, _ = c.Writer.WriteString(": detection started\n\n")
|
||||
c.Writer.Flush()
|
||||
if err == nil && channel.Status == common.ChannelStatusEnabled {
|
||||
raw, contentType, err = waitDetectionResponse(c, ctx, channel, request)
|
||||
} else {
|
||||
err = errors.New("channel unavailable")
|
||||
}
|
||||
success := err == nil
|
||||
call.Available = &success
|
||||
if err != nil {
|
||||
call.Error = "channel_call_failed"
|
||||
if ctx.Err() != nil {
|
||||
call.Available = nil
|
||||
call.Error = "interrupted_usage_unknown"
|
||||
}
|
||||
} else {
|
||||
usage, complete := detectionResponseUsage(raw, contentType)
|
||||
if !complete {
|
||||
call.Error = "incomplete_response"
|
||||
call.Available = nil
|
||||
} else {
|
||||
call.Usage = usage.Raw
|
||||
quota, quotaErr := service.DetectionUsageQuota(run.Price, usage.Get("input_tokens").Int(), usage.Get("output_tokens").Int(), usage.Get("input_tokens_details.cached_tokens").Int(), max(usage.Get("input_tokens_details.cached_creation_tokens").Int(), usage.Get("input_tokens_details.cache_creation_tokens").Int()))
|
||||
if quotaErr != nil || quota > run.Reserved {
|
||||
call.Error = "settlement_unavailable"
|
||||
} else if run.UserID > 0 {
|
||||
call.Quota = quota
|
||||
}
|
||||
}
|
||||
}
|
||||
if success {
|
||||
if html := detectionHTML(raw, contentType); html != "" {
|
||||
if saveErr := model.DB.Model(&model.DetectionRun{}).Where("id = ?", run.ID).Update("html", html).Error; saveErr != nil {
|
||||
call.Error = "artwork_save_failed"
|
||||
success = false
|
||||
}
|
||||
}
|
||||
}
|
||||
if err = model.FinishDetectionCall(call); err != nil {
|
||||
success = false
|
||||
}
|
||||
if !success {
|
||||
_, _ = c.Writer.WriteString("event: error\ndata: {\"type\":\"error\",\"error\":{\"message\":\"Detection generation failed\"}}\n\n")
|
||||
c.Writer.Flush()
|
||||
return
|
||||
}
|
||||
if strings.Contains(contentType, "text/event-stream") {
|
||||
_, _ = c.Writer.Write(raw)
|
||||
} else {
|
||||
event, _ := common.Marshal(map[string]any{"type": "response.completed", "response": gjson.ParseBytes(raw).Value()})
|
||||
_, _ = fmt.Fprintf(c.Writer, "event: response.completed\ndata: %s\n\n", event)
|
||||
}
|
||||
c.Writer.Flush()
|
||||
}
|
||||
|
||||
func waitDetectionResponse(c *gin.Context, ctx context.Context, channel *model.Channel, request *dto.OpenAIResponsesRequest) ([]byte, string, error) {
|
||||
type response struct {
|
||||
raw []byte
|
||||
contentType string
|
||||
err error
|
||||
}
|
||||
done := make(chan response, 1)
|
||||
go func() {
|
||||
raw, contentType, err := runDetectionResponse(ctx, channel, request)
|
||||
done <- response{raw, contentType, err}
|
||||
}()
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case result := <-done:
|
||||
return result.raw, result.contentType, result.err
|
||||
case <-ticker.C:
|
||||
_, _ = c.Writer.WriteString(": waiting for generation\n\n")
|
||||
c.Writer.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runDetectionResponse(ctx context.Context, channel *model.Channel, request *dto.OpenAIResponsesRequest) ([]byte, string, error) {
|
||||
encoded, err := common.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequestWithContext(ctx, http.MethodPost, "/v1/responses", bytes.NewReader(encoded))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
if middleware.SetupContextForSelectedChannel(c, channel, fingerprint.Model) != nil {
|
||||
return nil, "", errors.New("channel setup failed")
|
||||
}
|
||||
info, err := relaycommon.GenRelayInfo(c, types.RelayFormatOpenAIResponses, request, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
info.IsChannelTest = true
|
||||
info.DisablePing = true
|
||||
info.AstraDetectionDisabled = true
|
||||
info.AstraAutoSwitch = false
|
||||
adaptor, body, closer, apiErr := relay.PrepareResponsesRequest(c, info, request)
|
||||
if closer != nil {
|
||||
defer closer.Close()
|
||||
}
|
||||
if apiErr != nil {
|
||||
return nil, "", errors.New("preparation failed")
|
||||
}
|
||||
body, boundedCloser, err := boundDetectionBody(body, request)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer boundedCloser.Close()
|
||||
response, err := adaptor.DoRequest(c, info, body)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
resp, ok := response.(*http.Response)
|
||||
if !ok || resp == nil {
|
||||
return nil, "", errors.New("unsupported transport")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, "", errors.New("upstream request failed")
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, (8<<20)+1))
|
||||
if len(raw) > 8<<20 {
|
||||
return nil, "", errors.New("response too large")
|
||||
}
|
||||
return raw, resp.Header.Get("Content-Type"), err
|
||||
}
|
||||
|
||||
func detectionResponseUsage(raw []byte, contentType string) (gjson.Result, bool) {
|
||||
result := gjson.ParseBytes(raw)
|
||||
if strings.Contains(contentType, "text/event-stream") {
|
||||
result = gjson.Result{}
|
||||
for line := range strings.SplitSeq(string(raw), "\n") {
|
||||
if strings.HasPrefix(line, "data:") {
|
||||
event := gjson.Parse(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
|
||||
switch event.Get("type").String() {
|
||||
case "response.completed", "response.incomplete", "response.failed":
|
||||
result = event.Get("response")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
usage := result.Get("usage")
|
||||
status := result.Get("status").String()
|
||||
return usage, (status == "completed" || status == "incomplete" || status == "failed") && usage.Get("input_tokens").Type == gjson.Number && usage.Get("output_tokens").Type == gjson.Number
|
||||
}
|
||||
|
||||
// Apply billing and safety limits AFTER channel parameter overrides. Never let
|
||||
// an administrative override turn a bounded paid test into unbounded tool work.
|
||||
func boundDetectionBody(body common.ReplayableBody, request *dto.OpenAIResponsesRequest) (common.ReplayableBody, io.Closer, error) {
|
||||
raw, err := io.ReadAll(io.LimitReader(body, 128<<10))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var prepared map[string]any
|
||||
if common.Unmarshal(raw, &prepared) != nil {
|
||||
return nil, nil, errors.New("invalid prepared detection request")
|
||||
}
|
||||
var input any
|
||||
if common.Unmarshal(request.Input, &input) != nil {
|
||||
return nil, nil, errors.New("invalid detection input")
|
||||
}
|
||||
prepared["input"] = input
|
||||
var instructions any
|
||||
_ = common.Unmarshal(request.Instructions, &instructions)
|
||||
prepared["instructions"] = instructions
|
||||
prepared["max_output_tokens"] = *request.MaxOutputTokens
|
||||
if *request.MaxOutputTokens == 16384 {
|
||||
prepared["reasoning"] = map[string]string{"effort": "high"}
|
||||
}
|
||||
prepared["tools"] = []any{}
|
||||
prepared["tool_choice"] = "none"
|
||||
prepared["store"] = false
|
||||
prepared["stream"] = true
|
||||
for _, key := range []string{"previous_response_id", "conversation", "background", "max_tokens", "max_completion_tokens"} {
|
||||
delete(prepared, key)
|
||||
}
|
||||
encoded, err := common.Marshal(prepared)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return relaycommon.NewOutboundJSONBody(encoded)
|
||||
}
|
||||
Reference in new issue
Block a user