mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
feat(delete): real deletes — adapters seeded once into adapter_dir (existing dir is authoritative), sources removed from config.yaml on delete; drop tombstone mechanism for both. Docs: multi-key architecture, gateway_keys as seed
This commit is contained in:
23
README.md
23
README.md
@ -68,7 +68,7 @@ curl -N -H "Authorization: Bearer sk-gw-local-0001" \
|
|||||||
http://127.0.0.1:8080/v1/chat/completions
|
http://127.0.0.1:8080/v1/chat/completions
|
||||||
```
|
```
|
||||||
|
|
||||||
任何 OpenAI SDK 把 `base_url` 指到网关地址、`api_key` 用 `gateway_keys` 之一即可。
|
任何 OpenAI SDK 把 `base_url` 指到网关地址、`api_key` 用任意一个已创建并授权的网关 key 即可。
|
||||||
|
|
||||||
## 配置
|
## 配置
|
||||||
|
|
||||||
@ -76,10 +76,10 @@ curl -N -H "Authorization: Bearer sk-gw-local-0001" \
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
listen: 127.0.0.1:8080 # 网关监听地址(建议绑内网/回环)
|
listen: 127.0.0.1:8080 # 网关监听地址(建议绑内网/回环)
|
||||||
gateway_keys: [sk-gw-0001] # 客户端访问网关的 key;留空=不鉴权
|
gateway_keys: [sk-gw-0001] # 初始 admin 密钥种子,仅首启时写入运行时存储用
|
||||||
default_model: AUTO # model 无法路由时自动按 priority 选源
|
default_model: AUTO # model 无法路由时自动按优先级链选源
|
||||||
adapter_dir: adapters # Lua 适配目录,首启自动写入内置适配器
|
adapter_dir: adapters # Lua 适配目录;目录不存在时首启创建并 seed 内置,存在则只读
|
||||||
runtime_file: runtime.json # WebUI 编辑的源持久化到此文件
|
runtime_file: runtime.json # WebUI 编辑的源/密钥/AUTO 链持久化到此文件
|
||||||
|
|
||||||
sources:
|
sources:
|
||||||
- name: deepseek
|
- name: deepseek
|
||||||
@ -111,6 +111,19 @@ sources:
|
|||||||
- 升级时旧明文文件自动兼容:首次运行正常读取,任何 UI 保存操作触发全文件加密迁移。
|
- 升级时旧明文文件自动兼容:首次运行正常读取,任何 UI 保存操作触发全文件加密迁移。
|
||||||
- 注意:**master.key 丢失后密文无法解密**,请随配置一起备份;切勿提交到版本库。
|
- 注意:**master.key 丢失后密文无法解密**,请随配置一起备份;切勿提交到版本库。
|
||||||
|
|
||||||
|
### 网关密钥(多密钥)
|
||||||
|
|
||||||
|
网关鉴权采用「多密钥 + 角色 + 模型范围」架构,密钥持久化在 `runtime_file` 的
|
||||||
|
`keys` 字段(加密存储):
|
||||||
|
|
||||||
|
- `gateway_keys` 配置只是**初始 admin 密钥种子**:首次启动迁移为运行时 admin
|
||||||
|
key,之后不再参与鉴权管理。
|
||||||
|
- WebUI **密钥页**可创建/删除密钥;每个密钥可指定 `admin`(管理全部)或
|
||||||
|
`user`(仅看自己的 key)角色,并配置**模型范围**(模型 + 源 + token 配额 +
|
||||||
|
重置周期)。
|
||||||
|
- 客户端用任意一个已授权的密钥明文作为 Bearer(`Authorization: Bearer <key>`)。
|
||||||
|
- 删除密钥即从运行时存储移除,立即失效。
|
||||||
|
|
||||||
### 模型路由
|
### 模型路由
|
||||||
|
|
||||||
`/v1/chat/completions` 的 `model` 解析顺序:
|
`/v1/chat/completions` 的 `model` 解析顺序:
|
||||||
|
|||||||
26
README_EN.md
26
README_EN.md
@ -88,8 +88,8 @@ curl -N -H "Authorization: Bearer sk-gw-local-0001" \
|
|||||||
http://127.0.0.1:8080/v1/chat/completions
|
http://127.0.0.1:8080/v1/chat/completions
|
||||||
```
|
```
|
||||||
|
|
||||||
Any OpenAI SDK works: point `base_url` at the gateway, use one of `gateway_keys`
|
Any OpenAI SDK works: point `base_url` at the gateway, use any authorized
|
||||||
as the API key.
|
gateway key as the API key.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@ -97,10 +97,10 @@ See [`config.example.yaml`](config.example.yaml). Core fields:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
listen: 127.0.0.1:8080 # bind address (keep internal/loopback)
|
listen: 127.0.0.1:8080 # bind address (keep internal/loopback)
|
||||||
gateway_keys: [sk-gw-0001] # keys clients use; empty = no auth
|
gateway_keys: [sk-gw-0001] # initial admin key seed, migrated to the store on first start
|
||||||
default_model: AUTO # when model is unroutable, pick source by priority
|
default_model: AUTO # when model is unroutable, follow the AUTO chain
|
||||||
adapter_dir: adapters # Lua adapter dir; built-ins written on first start
|
adapter_dir: adapters # Lua adapter dir; created+seeded if missing, read-only otherwise
|
||||||
runtime_file: runtime.json # WebUI-edited sources persist here
|
runtime_file: runtime.json # WebUI-edited sources/keys/AUTO chain persist here
|
||||||
|
|
||||||
sources:
|
sources:
|
||||||
- name: deepseek
|
- name: deepseek
|
||||||
@ -135,6 +135,20 @@ Sensitive fields in the runtime file (`runtime_file`) are encrypted at rest:
|
|||||||
- Back up `master.key` with your config — losing it makes the secrets
|
- Back up `master.key` with your config — losing it makes the secrets
|
||||||
undecryptable. Do not commit it.
|
undecryptable. Do not commit it.
|
||||||
|
|
||||||
|
### Gateway keys (multi-key)
|
||||||
|
|
||||||
|
Gateway auth uses a "multi-key + role + model scope" model. Keys are persisted
|
||||||
|
under the `keys` field of the runtime file (encrypted at rest):
|
||||||
|
|
||||||
|
- The `gateway_keys` config is only an **initial admin key seed** — it is
|
||||||
|
migrated into the runtime store on first start and no longer drives auth.
|
||||||
|
- The WebUI **Keys page** creates/deletes keys. Each key has a role (`admin`
|
||||||
|
manages everything, `user` sees only its own key) and an optional **model
|
||||||
|
scope** (model + source + token quota + reset period).
|
||||||
|
- Clients authenticate with any authorized key's plaintext as
|
||||||
|
`Authorization: Bearer <key>`.
|
||||||
|
- Deleting a key removes it from the store immediately.
|
||||||
|
|
||||||
### Model routing
|
### Model routing
|
||||||
|
|
||||||
`/v1/chat/completions` `model` resolution order:
|
`/v1/chat/completions` `model` resolution order:
|
||||||
|
|||||||
@ -3,7 +3,9 @@
|
|||||||
# 网关监听地址(默认 :8080,建议绑内网/回环)
|
# 网关监听地址(默认 :8080,建议绑内网/回环)
|
||||||
listen: 127.0.0.1:8080
|
listen: 127.0.0.1:8080
|
||||||
|
|
||||||
# 客户端访问本网关所需的 API Key(Bearer)。留空数组 = 不鉴权(仅内网)。
|
# 网关自身鉴权密钥。现在为「多密钥」架构:此项仅作为初始 admin 密钥种子,
|
||||||
|
# 首次启动写入运行时存储(runtime_file 的 keys 字段,加密存储)。之后请在
|
||||||
|
# WebUI「密钥」页创建、删除密钥并配置其模型范围。留空 = 首启无 admin 密钥。
|
||||||
gateway_keys:
|
gateway_keys:
|
||||||
- sk-gw-local-0001
|
- sk-gw-local-0001
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
// Config is the top-level gateway configuration.
|
// Config is the top-level gateway configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
Path string `yaml:"-" json:"-"`
|
||||||
Listen string `yaml:"listen"`
|
Listen string `yaml:"listen"`
|
||||||
GatewayKeys []string `yaml:"gateway_keys"`
|
GatewayKeys []string `yaml:"gateway_keys"`
|
||||||
DefaultModel string `yaml:"default_model"` // e.g. "AUTO" or a model id
|
DefaultModel string `yaml:"default_model"` // e.g. "AUTO" or a model id
|
||||||
@ -60,12 +61,63 @@ func Load(path string) (*Config, error) {
|
|||||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||||
return nil, fmt.Errorf("parse config: %w", err)
|
return nil, fmt.Errorf("parse config: %w", err)
|
||||||
}
|
}
|
||||||
|
cfg.Path = path
|
||||||
if err := cfg.ApplyDefaults(); err != nil {
|
if err := cfg.ApplyDefaults(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemoveSourceFromYAML deletes the named source entry from the config file so
|
||||||
|
// the delete is a real one (no tombstone needed). Uses yaml.Node to preserve
|
||||||
|
// the rest of the file's comments and formatting.
|
||||||
|
func RemoveSourceFromYAML(path, name string) error {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var doc yaml.Node
|
||||||
|
if err := yaml.Unmarshal(data, &doc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
content := doc.Content
|
||||||
|
if len(content) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
root := content[0]
|
||||||
|
if root.Kind != yaml.MappingNode {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for i := 0; i+1 < len(root.Content); i += 2 {
|
||||||
|
key, val := root.Content[i], root.Content[i+1]
|
||||||
|
if key.Value != "sources" || val.Kind != yaml.SequenceNode {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept := val.Content[:0]
|
||||||
|
for _, item := range val.Content {
|
||||||
|
if item.Kind != yaml.MappingNode {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for j := 0; j+1 < len(item.Content); j += 2 {
|
||||||
|
if item.Content[j].Value == "name" && item.Content[j+1].Value == name {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
kept = append(kept, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val.Content = kept
|
||||||
|
}
|
||||||
|
out, err := yaml.Marshal(&doc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, out, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
// ApplyDefaults sets missing values and validates the config.
|
// ApplyDefaults sets missing values and validates the config.
|
||||||
func (c *Config) ApplyDefaults() error {
|
func (c *Config) ApplyDefaults() error {
|
||||||
if c.Listen == "" {
|
if c.Listen == "" {
|
||||||
|
|||||||
@ -196,4 +196,53 @@ func TestSecretBoxRoundTrip(t *testing.T) {
|
|||||||
if _, err := bad.Decrypt(v); err == nil {
|
if _, err := bad.Decrypt(v); err == nil {
|
||||||
t.Fatal("expected decrypt failure with wrong key")
|
t.Fatal("expected decrypt failure with wrong key")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
func TestRemoveSourceFromYAML(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "cfg.yaml")
|
||||||
|
content := `
|
||||||
|
listen: 127.0.0.1:9999
|
||||||
|
gateway_keys: [sk-1]
|
||||||
|
default_model: AUTO
|
||||||
|
adapter_dir: adapters
|
||||||
|
runtime_file: runtime.json
|
||||||
|
sources:
|
||||||
|
- name: deepseek
|
||||||
|
base_url: https://api.deepseek.com
|
||||||
|
api_key: sk-d
|
||||||
|
adapter: deepseek
|
||||||
|
models:
|
||||||
|
- id: deepseek-v4-flash
|
||||||
|
- name: ollama
|
||||||
|
base_url: http://127.0.0.1:11434
|
||||||
|
adapter: ollama
|
||||||
|
models:
|
||||||
|
- id: llama3
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := RemoveSourceFromYAML(path, "deepseek"); err != nil {
|
||||||
|
t.Fatalf("remove: %v", err)
|
||||||
|
}
|
||||||
|
reloaded, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload: %v", err)
|
||||||
|
}
|
||||||
|
if len(reloaded.Sources) != 1 {
|
||||||
|
t.Fatalf("sources = %d, want 1", len(reloaded.Sources))
|
||||||
|
}
|
||||||
|
if reloaded.Sources[0].Name != "ollama" {
|
||||||
|
t.Fatalf("remaining = %q, want ollama", reloaded.Sources[0].Name)
|
||||||
|
}
|
||||||
|
if reloaded.Sources[0].Models[0].ID != "llama3" {
|
||||||
|
t.Fatalf("remaining models broken: %+v", reloaded.Sources[0].Models)
|
||||||
|
}
|
||||||
|
// removing a non-existent name is a no-op that keeps the file valid
|
||||||
|
if err := RemoveSourceFromYAML(path, "nope"); err != nil {
|
||||||
|
t.Fatalf("remove missing: %v", err)
|
||||||
|
}
|
||||||
|
if cfg, err := Load(path); err != nil || len(cfg.Sources) != 1 {
|
||||||
|
t.Fatalf("after no-op: %v %v", len(cfg.Sources), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -94,7 +94,8 @@ func (s *Store) Upsert(src Source) error {
|
|||||||
return s.persistLocked()
|
return s.persistLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove deletes a runtime source or hides a base YAML source and persists.
|
// Remove deletes a runtime source from the store and persists. Base YAML
|
||||||
|
// sources are handled (truly removed from the config file) by the caller.
|
||||||
func (s *Store) Remove(name string) (bool, error) {
|
func (s *Store) Remove(name string) (bool, error) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
@ -108,10 +109,7 @@ func (s *Store) Remove(name string) (bool, error) {
|
|||||||
kept = append(kept, src)
|
kept = append(kept, src)
|
||||||
}
|
}
|
||||||
s.data.Sources = kept
|
s.data.Sources = kept
|
||||||
if !containsString(s.data.DeletedSources, name) {
|
return removed, s.persistLocked()
|
||||||
s.data.DeletedSources = append(s.data.DeletedSources, name)
|
|
||||||
}
|
|
||||||
return removed || containsString(s.data.DeletedSources, name), s.persistLocked()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) DeletedSources() map[string]bool {
|
func (s *Store) DeletedSources() map[string]bool {
|
||||||
@ -124,32 +122,6 @@ func (s *Store) DeletedSources() map[string]bool {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) DeleteAdapter(name string) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
if !containsString(s.data.DeletedAdapters, name) {
|
|
||||||
s.data.DeletedAdapters = append(s.data.DeletedAdapters, name)
|
|
||||||
}
|
|
||||||
return s.persistLocked()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) RestoreAdapter(name string) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
s.data.DeletedAdapters = removeString(s.data.DeletedAdapters, name)
|
|
||||||
return s.persistLocked()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) DeletedAdapters() map[string]bool {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
out := map[string]bool{}
|
|
||||||
for _, name := range s.data.DeletedAdapters {
|
|
||||||
out[name] = true
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) persistLocked() error {
|
func (s *Store) persistLocked() error {
|
||||||
if s.box != nil {
|
if s.box != nil {
|
||||||
s.encryptLocked()
|
s.encryptLocked()
|
||||||
|
|||||||
@ -239,13 +239,9 @@ func (c *Core) Config() *config.Config { return c.cfg }
|
|||||||
|
|
||||||
// mergedSources = base YAML sources + runtime sources (runtime wins by name).
|
// mergedSources = base YAML sources + runtime sources (runtime wins by name).
|
||||||
func (c *Core) mergedSources() []config.Source {
|
func (c *Core) mergedSources() []config.Source {
|
||||||
deleted := c.store.DeletedSources()
|
|
||||||
byName := map[string]config.Source{}
|
byName := map[string]config.Source{}
|
||||||
order := []string{}
|
order := []string{}
|
||||||
for _, s := range c.cfg.Sources {
|
for _, s := range c.cfg.Sources {
|
||||||
if deleted[s.Name] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
byName[s.Name] = s
|
byName[s.Name] = s
|
||||||
order = append(order, s.Name)
|
order = append(order, s.Name)
|
||||||
}
|
}
|
||||||
@ -313,15 +309,7 @@ func (c *Core) Reload() error {
|
|||||||
// ---- adapter management (web UI) ----
|
// ---- adapter management (web UI) ----
|
||||||
|
|
||||||
func (c *Core) ListAdapters() []lua.APIAdapter {
|
func (c *Core) ListAdapters() []lua.APIAdapter {
|
||||||
deleted := c.store.DeletedAdapters()
|
return c.vm.ListAdapters()
|
||||||
list := c.vm.ListAdapters()
|
|
||||||
out := make([]lua.APIAdapter, 0, len(list))
|
|
||||||
for _, a := range list {
|
|
||||||
if !deleted[a.Name] {
|
|
||||||
out = append(out, a)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadAdapter saves a new Lua adapter script to the adapter dir and loads it.
|
// UploadAdapter saves a new Lua adapter script to the adapter dir and loads it.
|
||||||
@ -336,18 +324,17 @@ func (c *Core) UploadAdapter(name, code string) error {
|
|||||||
if err := os.WriteFile(path, []byte(code), 0644); err != nil {
|
if err := os.WriteFile(path, []byte(code), 0644); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := c.vm.LoadAdapter(path); err != nil {
|
return c.vm.LoadAdapter(path)
|
||||||
return fmt.Errorf("load adapter: %w", err)
|
|
||||||
}
|
|
||||||
return c.store.RestoreAdapter(name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveAdapter deletes an adapter script and evicts it from the VM.
|
// RemoveAdapter deletes an adapter script and evicts it from the VM. The file
|
||||||
|
// is removed for real (adapter dir is authoritative after first run), so the
|
||||||
|
// adapter stays gone across restarts.
|
||||||
func (c *Core) RemoveAdapter(name string) error {
|
func (c *Core) RemoveAdapter(name string) error {
|
||||||
path := filepath.Join(c.cfg.AdapterDir, name+".lua")
|
path := filepath.Join(c.cfg.AdapterDir, name+".lua")
|
||||||
_ = os.Remove(path)
|
_ = os.Remove(path)
|
||||||
c.vm.RemoveAdapter(name)
|
c.vm.RemoveAdapter(name)
|
||||||
return c.store.DeleteAdapter(name)
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- source management (web UI) ----
|
// ---- source management (web UI) ----
|
||||||
@ -363,12 +350,31 @@ func (c *Core) AddSource(src config.Source) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Core) RemoveSource(name string) error {
|
func (c *Core) RemoveSource(name string) error {
|
||||||
|
for _, s := range c.cfg.Sources {
|
||||||
|
if s.Name == name {
|
||||||
|
if err := config.RemoveSourceFromYAML(c.cfg.Path, name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.cfg.Sources = c.removeCfgSource(name)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
if _, err := c.store.Remove(name); err != nil {
|
if _, err := c.store.Remove(name); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return c.rebuildRegistry()
|
return c.rebuildRegistry()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Core) removeCfgSource(name string) []config.Source {
|
||||||
|
out := c.cfg.Sources[:0]
|
||||||
|
for _, s := range c.cfg.Sources {
|
||||||
|
if s.Name != name {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Core) Sources() []config.Source { return c.mergedSources() }
|
func (c *Core) Sources() []config.Source { return c.mergedSources() }
|
||||||
|
|
||||||
func normalizeSource(s *config.Source) error {
|
func normalizeSource(s *config.Source) error {
|
||||||
|
|||||||
@ -183,11 +183,20 @@ func (v *VM) Start() error {
|
|||||||
if v.dir == "" {
|
if v.dir == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(v.dir, 0755); err != nil {
|
// First-run seeding: a brand-new adapter dir is created and populated with
|
||||||
return fmt.Errorf("mkdir adapter dir: %w", err)
|
// the bundled adapters. If the dir already exists it is treated as
|
||||||
|
// authoritative and never rewritten — deleting a file there is a real delete.
|
||||||
|
firstRun := false
|
||||||
|
if _, err := os.Stat(v.dir); os.IsNotExist(err) {
|
||||||
|
firstRun = true
|
||||||
|
if err := os.MkdirAll(v.dir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("mkdir adapter dir: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := v.writeBundledAdapters(); err != nil {
|
if firstRun {
|
||||||
return err
|
if err := v.writeBundledAdapters(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
entries, err := os.ReadDir(v.dir)
|
entries, err := os.ReadDir(v.dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user