# The Gift Bag: How Subscription Links Are Built

> A subscription link is a URL your VPN app fetches to learn which servers to use. This closing page asks: "how does S-UI turn raw database records into a Clash YAML, a JSON config, or a base64 share-link?" It walks through sub/subService.go, clashService.go, jsonService.go, and linkService.go — then leaves you with the key question to keep exploring: what new formats could you add, and where would you start?

- Repository: alireza0/s-ui
- GitHub: https://github.com/alireza0/s-ui
- Human wiki: https://grok-wiki.com/public/wiki/alireza0-s-ui-2ddf594ac444
- Complete Markdown: https://grok-wiki.com/public/wiki/alireza0-s-ui-2ddf594ac444/llms-full.txt

## Source Files

- `sub/sub.go`
- `sub/subHandler.go`
- `sub/subService.go`
- `sub/clashService.go`
- `sub/jsonService.go`
- `sub/linkService.go`
- `util/genLink.go`
- `util/subInfo.go`

---

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:

- [sub/sub.go](sub/sub.go)
- [sub/subHandler.go](sub/subHandler.go)
- [sub/subService.go](sub/subService.go)
- [sub/clashService.go](sub/clashService.go)
- [sub/jsonService.go](sub/jsonService.go)
- [sub/linkService.go](sub/linkService.go)
- [util/genLink.go](util/genLink.go)
- [util/subInfo.go](util/subInfo.go)
</details>

# The Gift Bag: How Subscription Links Are Built

Imagine you walk into a store and ask for "the usual." The clerk reaches under the counter, puts your favorite things in a bag, and hands it over. A **subscription link** is exactly that bag — a single URL your VPN app fetches, and out comes a list of every server you're allowed to use, formatted exactly the way your app understands.

This page explains how S-UI fills that bag. Starting from an HTTP request, it traces the path through `subHandler.go`, `subService.go`, `linkService.go`, `clashService.go`, `jsonService.go`, and `util/genLink.go`, arriving at the final gift: a base64 text list, a Clash YAML, or a sing-box JSON config.

---

## Step 1 — Who Answers the Door? (`subHandler.go`)

When your VPN app fetches a subscription URL, it hits a Gin HTTP route registered in `sub/subHandler.go`.

```go
// sub/subHandler.go:22-25
g.GET("/:subid", s.subs)
g.HEAD("/:subid", s.subHeaders)
```

The `subs` function looks at the query string first:

```go
// sub/subHandler.go:31-56
format, isFormat := c.GetQuery("format")
if isFormat {
    switch format {
    case "json":
        result, headers, err = s.JsonService.GetJson(subId, format)
    case "clash":
        result, headers, err = s.ClashService.GetClash(subId)
    }
} else {
    result, headers, err = s.SubService.GetSubs(subId)
}
```

**Socratic question:** Why does the code check `isFormat` first rather than just defaulting to an empty string? Because "no format" is not the same as "an unknown format" — the no-query-string path deliberately goes to the base64 share-link format, which is the most widely supported.

The three routes:

| URL | Format | Service |
|-----|--------|---------|
| `/<subid>` | base64 text | `SubService` |
| `/<subid>?format=json` | sing-box JSON | `JsonService` |
| `/<subid>?format=clash` | Clash YAML | `ClashService` |

Sources: [sub/subHandler.go:27-57]()

---

## Step 2 — Finding the Client Record (`jsonService.go`, `subService.go`)

Every subscription path starts by looking up the client by name from the database.

```go
// sub/jsonService.go:99-116
func (j *JsonService) getData(subId string) (*model.Client, []*model.Inbound, error) {
    db := database.GetDB()
    client := &model.Client{}
    err := db.Model(model.Client{}).Where("enable = true and name = ?", subId).First(client).Error
    ...
    var clientInbounds []uint
    err = json.Unmarshal(client.Inbounds, &clientInbounds)
    ...
    err = db.Model(model.Inbound{}).Preload("Tls").Where("id in ?", clientInbounds).Find(&inbounds).Error
    ...
}
```

**Socratic question:** Why does the query filter on `enable = true`? So that disabling a client in the admin panel instantly stops new subscriptions from including that client's servers — no code changes needed.

The `Client` record carries:
- `Inbounds` — a JSON array of inbound IDs this client can use
- `Links` — a JSON array of pre-built or external links
- `Config` — per-protocol user credentials (uuid, password, etc.)
- `Volume`, `Up`, `Down`, `Expiry` — usage and expiry data for headers

Sources: [sub/jsonService.go:98-116]()

---

## Step 3 — Assembling the Links (Three Paths)

### Path A: Base64 Share-Link (`SubService` + `LinkService`)

`SubService.GetSubs` is the simplest path. It reads `client.Links` directly and delegates to `LinkService.GetLinks`:

```go
// sub/subService.go:34-44
linksArray := s.LinkService.GetLinks(&client.Links, "all", clientInfo)
result := strings.Join(linksArray, "\n")
...
if subEncode {
    result = base64.StdEncoding.EncodeToString([]byte(result))
}
```

`LinkService.GetLinks` processes three link types stored in the `Links` JSON field:

```go
// sub/linkService.go:28-39
for _, link := range links {
    switch link.Type {
    case "external":
        result = append(result, link.Uri)        // paste the URI as-is
    case "sub":
        subLinks := util.GetExternalLink(link.Uri) // fetch a remote sub
        result = append(result, strings.Split(subLinks, "\n")...)
    case "local":
        if types == "all" {
            result = append(result, s.addClientInfo(link.Uri, clientInfo))
        }
    }
}
```

**Socratic question:** Why does `local` only appear when `types == "all"` but not for Clash or JSON? Because Clash and JSON services build outbound configs from raw database records and only pull `external` links for additional servers. The `local` type holds already-formatted share URIs — those are the final gift-wrapped items meant only for the share-link format.

Sources: [sub/linkService.go:20-41](), [sub/subService.go:20-45]()

---

### Path B: sing-box JSON (`JsonService`)

The JSON path constructs a full sing-box client config from scratch.

```go
// sub/jsonService.go:51-96
func (j *JsonService) GetJson(subId string, format string) (*string, []string, error) {
    client, inDatas, err := j.getData(subId)
    outbounds, outTags, err := j.getOutbounds(client.Config, inDatas)
    // ...append external links as additional outbounds
    j.addDefaultOutbounds(outbounds, outTags)
    json.Unmarshal([]byte(defaultJson), &jsonConfig)
    jsonConfig["outbounds"] = outbounds
    j.addOthers(&jsonConfig)
    result, _ := json.MarshalIndent(jsonConfig, "", "  ")
}
```

The `defaultJson` constant provides the inbound skeleton (a `tun` + `mixed` listener pair). Then `getOutbounds` iterates through the client's inbound records, takes the pre-computed `OutJson` from each inbound, and merges in the client's credentials from `client.Config`:

```go
// sub/jsonService.go:118-168 (excerpt)
config, _ := configs[protocol].(map[string]interface{})
for key, value := range config {
    if key == "name" || key == "alterId" || (key == "flow" && inData.TlsId == 0) {
        continue
    }
    outbound[key] = value
}
```

Finally, `addDefaultOutbounds` wraps everything in `selector → urltest → direct` outbound groups, and `addOthers` merges optional settings like `dns`, `rules`, and `experimental` from the admin's extension JSON.

Sources: [sub/jsonService.go:51-96](), [sub/jsonService.go:224-245](), [sub/jsonService.go:247-308]()

---

### Path C: Clash YAML (`ClashService`)

The Clash path reuses `JsonService.getData` and `getOutbounds` to get the same list of sing-box-style outbound maps, then **translates** them into Clash's different field names:

```go
// sub/clashService.go:64-103
func (s *ClashService) GetClash(subId string) (*string, []string, error) {
    client, inDatas, err := s.getData(subId)
    outbounds, outTags, err := s.getOutbounds(client.Config, inDatas)
    // ...
    resultStr, err := s.ConvertToClashMeta(outbounds, basicConfig)
}
```

`ConvertToClashMeta` is where the real translation happens. For each outbound it maps sing-box field names to Clash Meta field names:

```go
// sub/clashService.go:114-136 (excerpt)
proxy["name"] = obMap["tag"]
proxy["type"] = t
proxy["server"] = server
proxy["port"] = obMap["server_port"]   // sing-box: "server_port" → Clash: "port"
```

It handles every supported protocol (vmess, vless, trojan, shadowsocks, hysteria/hysteria2, tuic, socks, http, anytls), transport types (ws, grpc, http/h2, httpupgrade), TLS options including Reality and ECH, and multiplexing (smux). The final step merges the proxies into a base YAML config:

```go
// sub/clashService.go:376-392
output["proxies"] = append(p, proxies...)
output["proxy-groups"] = append(pg, proxyGroups[0], proxyGroups[1])
result, err := yaml.Marshal(output)
```

**Socratic question:** Why does `ConvertToClashMeta` skip outbounds of type `selector`, `urltest`, and `direct`? Because those are S-UI's internal routing constructs; Clash builds its own routing groups from scratch using the `ProxyGroups` constant defined in the same file.

Sources: [sub/clashService.go:114-393]()

---

## Step 4 — Generating the Share-Link URIs (`util/genLink.go`)

When S-UI first registers an inbound, it calls `util.LinkGenerator` to produce the actual share-link URIs that get stored in `client.Links`. This is the factory that knows every URI scheme:

```go
// util/genLink.go:14
var InboundTypeWithLink = []string{"socks", "http", "mixed", "shadowsocks",
    "naive", "hysteria", "hysteria2", "anytls", "tuic", "vless", "trojan", "vmess"}
```

Each protocol has its own function. For example, vmess encodes a JSON object as base64:

```go
// util/genLink.go:482-483
jsonStr, _ := json.Marshal(obj)
uri := fmt.Sprintf("vmess://%s", toBase64(jsonStr))
```

While vless/trojan use query-string parameters:

```go
// util/genLink.go:392-397
uri := fmt.Sprintf("vless://%s@%s:%.0f", uuid, addr["server"].(string), port)
uri = addParams(uri, params, addr["remark"].(string))
```

`addParams` (line 516) uses Go's `url.Parse` to properly encode parameters, then appends the server's remark as the URL fragment (`#server-name`).

Sources: [util/genLink.go:21-102](), [util/genLink.go:516-530]()

---

## Step 5 — The Response Headers (`util/subInfo.go`)

Every subscription response, regardless of format, includes three standard HTTP headers:

```go
// util/subInfo.go:9-15
func GetHeaders(client *model.Client, updateInterval int) []string {
    headers = append(headers, fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d",
        client.Up, client.Down, client.Volume, client.Expiry))
    headers = append(headers, fmt.Sprintf("%d", updateInterval))
    headers = append(headers, client.Name)
    return headers
}
```

These map to:

| Header | Content |
|--------|---------|
| `Subscription-Userinfo` | Traffic used + total + expiry timestamp |
| `Profile-Update-Interval` | How often (in hours) the app should re-fetch |
| `Profile-Title` | The client's name shown in the VPN app |

Sources: [util/subInfo.go:9-15](), [sub/subHandler.go:74-78]()

---

## The Full Picture

```text
HTTP GET /<subid>[?format=...]
        │
        ▼
┌─────────────────────┐
│   SubHandler.subs   │  ← sub/subHandler.go
└──────────┬──────────┘
           │
     ┌─────┴──────────────┐
     │                    │
  no ?format          ?format=
     │                    │
     ▼               ┌────┴────┐
SubService         json      clash
  .GetSubs()         │          │
     │               ▼          ▼
     │          JsonService  ClashService
     │           .GetJson()   .GetClash()
     │               │          │
     │        getData() ─────────┘
     │        getOutbounds()
     │               │
     ▼               ▼
LinkService     util/genLink.go
.GetLinks()     (pre-stored URIs)
     │
     ▼
base64-encode (optional)
return string + headers
```

---

## What Could You Add?

Here is the key question to carry forward: **what new format would you add, and where would you start?**

The routing gate is in `subHandler.go:38-39` — add a new `case "singbox-v2"` (or any other format name) there. The data-fetching plumbing (`getData`, `getOutbounds`) is already shared between JSON and Clash — your new service can embed `JsonService` and call those same methods. The translation layer is entirely self-contained in a single function like `ConvertToClashMeta`. And if your format needs new link URI schemes, `util/genLink.go` is where you'd add a new `case` to `LinkGenerator` and register the type in `InboundTypeWithLink`.

The three existing services show the full range: pure text assembly (`SubService`), full config generation (`JsonService`), and protocol translation (`ClashService`). Any new format fits one of those three shapes.

Sources: [sub/subHandler.go:27-57](), [sub/clashService.go:13-17](), [util/genLink.go:14]()
