mirror of
https://github.com/yusing/godoxy.git
synced 2026-01-11 21:10:30 +01:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aaa3c9a8d8 | ||
|
|
bc44de3196 | ||
|
|
12b784d126 | ||
|
|
71f6636cc3 | ||
|
|
cc1fe30045 |
4
Makefile
4
Makefile
@@ -3,6 +3,8 @@ export VERSION ?= $(shell git describe --tags --abbrev=0)
|
||||
export BUILD_DATE ?= $(shell date -u +'%Y%m%d-%H%M')
|
||||
export GOOS = linux
|
||||
|
||||
REPO_URL ?= https://github.com/yusing/godoxy
|
||||
|
||||
WEBUI_DIR ?= ../godoxy-webui
|
||||
DOCS_DIR ?= ${WEBUI_DIR}/wiki
|
||||
|
||||
@@ -175,4 +177,4 @@ gen-api-types: gen-swagger
|
||||
|
||||
.PHONY: update-wiki
|
||||
update-wiki:
|
||||
DOCS_DIR=${DOCS_DIR} bun --bun scripts/update-wiki/main.ts
|
||||
DOCS_DIR=${DOCS_DIR} REPO_URL=${REPO_URL} bun --bun scripts/update-wiki/main.ts
|
||||
|
||||
@@ -27,26 +27,26 @@ graph TD
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Purpose |
|
||||
| -------------------------------------------------------- | --------------------------------------------------------- |
|
||||
| [`config.go`](agent/pkg/agent/config.go) | Core configuration, initialization, and API client logic. |
|
||||
| [`new_agent.go`](agent/pkg/agent/new_agent.go) | Agent creation and certificate generation logic. |
|
||||
| [`docker_compose.go`](agent/pkg/agent/docker_compose.go) | Generator for agent Docker Compose configurations. |
|
||||
| [`bare_metal.go`](agent/pkg/agent/bare_metal.go) | Generator for bare metal installation scripts. |
|
||||
| [`env.go`](agent/pkg/agent/env.go) | Environment configuration types and constants. |
|
||||
| [`common/`](agent/pkg/agent/common) | Shared constants and utilities for agents. |
|
||||
| File | Purpose |
|
||||
| ---------------------------------------- | --------------------------------------------------------- |
|
||||
| [`config.go`](config.go) | Core configuration, initialization, and API client logic. |
|
||||
| [`new_agent.go`](new_agent.go) | Agent creation and certificate generation logic. |
|
||||
| [`docker_compose.go`](docker_compose.go) | Generator for agent Docker Compose configurations. |
|
||||
| [`bare_metal.go`](bare_metal.go) | Generator for bare metal installation scripts. |
|
||||
| [`env.go`](env.go) | Environment configuration types and constants. |
|
||||
| `common/` | Shared constants and utilities for agents. |
|
||||
|
||||
## Core Types
|
||||
|
||||
### [`AgentConfig`](agent/pkg/agent/config.go:29)
|
||||
### [`AgentConfig`](config.go:29)
|
||||
|
||||
The primary struct used by the GoDoxy server to manage a connection to an agent. It stores the agent's address, metadata, and TLS configuration.
|
||||
|
||||
### [`AgentInfo`](agent/pkg/agent/config.go:45)
|
||||
### [`AgentInfo`](config.go:45)
|
||||
|
||||
Contains basic metadata about the agent, including its version, name, and container runtime (Docker or Podman).
|
||||
|
||||
### [`PEMPair`](agent/pkg/agent/new_agent.go:53)
|
||||
### [`PEMPair`](new_agent.go:53)
|
||||
|
||||
A utility struct for handling PEM-encoded certificate and key pairs, supporting encryption, decryption, and conversion to `tls.Certificate`.
|
||||
|
||||
@@ -54,7 +54,7 @@ A utility struct for handling PEM-encoded certificate and key pairs, supporting
|
||||
|
||||
### Certificate Generation
|
||||
|
||||
The [`NewAgent`](agent/pkg/agent/new_agent.go:147) function creates a complete certificate infrastructure for an agent:
|
||||
The [`NewAgent`](new_agent.go:147) function creates a complete certificate infrastructure for an agent:
|
||||
|
||||
- **CA Certificate**: Self-signed root certificate with 1000-year validity.
|
||||
- **Server Certificate**: For the agent's HTTPS server, signed by the CA.
|
||||
@@ -65,18 +65,18 @@ All certificates use ECDSA with P-256 curve and SHA-256 signatures.
|
||||
### Certificate Security
|
||||
|
||||
- Certificates are encrypted using AES-GCM with a provided encryption key.
|
||||
- The [`PEMPair`](agent/pkg/agent/new_agent.go:53) struct provides methods for encryption, decryption, and conversion to `tls.Certificate`.
|
||||
- The [`PEMPair`](new_agent.go:53) struct provides methods for encryption, decryption, and conversion to `tls.Certificate`.
|
||||
- Base64 encoding is used for certificate storage and transmission.
|
||||
|
||||
## Key Features
|
||||
|
||||
### 1. Secure Communication
|
||||
|
||||
All communication between the GoDoxy server and agents is secured using mutual TLS (mTLS). The [`AgentConfig`](agent/pkg/agent/config.go:29) handles the loading of CA and client certificates to establish secure connections.
|
||||
All communication between the GoDoxy server and agents is secured using mutual TLS (mTLS). The [`AgentConfig`](config.go:29) handles the loading of CA and client certificates to establish secure connections.
|
||||
|
||||
### 2. Agent Discovery and Initialization
|
||||
|
||||
The [`Init`](agent/pkg/agent/config.go:231) and [`InitWithCerts`](agent/pkg/agent/config.go:110) methods allow the server to:
|
||||
The [`Init`](config.go:231) and [`InitWithCerts`](config.go:110) methods allow the server to:
|
||||
|
||||
- Fetch agent metadata (version, name, runtime).
|
||||
- Verify compatibility between server and agent versions.
|
||||
@@ -86,12 +86,12 @@ The [`Init`](agent/pkg/agent/config.go:231) and [`InitWithCerts`](agent/pkg/agen
|
||||
|
||||
The package provides interfaces and implementations for generating deployment artifacts:
|
||||
|
||||
- **Docker Compose**: Generates a `docker-compose.yml` for running the agent as a container via [`AgentComposeConfig.Generate()`](agent/pkg/agent/docker_compose.go:21).
|
||||
- **Bare Metal**: Generates a shell script to install and run the agent as a systemd service via [`AgentEnvConfig.Generate()`](agent/pkg/agent/bare_metal.go:27).
|
||||
- **Docker Compose**: Generates a `docker-compose.yml` for running the agent as a container via [`AgentComposeConfig.Generate()`](docker_compose.go:21).
|
||||
- **Bare Metal**: Generates a shell script to install and run the agent as a systemd service via [`AgentEnvConfig.Generate()`](bare_metal.go:27).
|
||||
|
||||
### 4. Fake Docker Host
|
||||
|
||||
The package supports a "fake" Docker host scheme (`agent://<addr>`) to identify containers managed by an agent, allowing the GoDoxy server to route requests appropriately. See [`IsDockerHostAgent`](agent/pkg/agent/config.go:90) and [`GetAgentAddrFromDockerHost`](agent/pkg/agent/config.go:94).
|
||||
The package supports a "fake" Docker host scheme (`agent://<addr>`) to identify containers managed by an agent, allowing the GoDoxy server to route requests appropriately. See [`IsDockerHostAgent`](config.go:90) and [`GetAgentAddrFromDockerHost`](config.go:94).
|
||||
|
||||
## Usage Example
|
||||
|
||||
|
||||
@@ -811,7 +811,7 @@
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/homepage.FetchResult"
|
||||
"$ref": "#/definitions/iconfetch.Result"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1698,7 +1698,7 @@
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/homepage.IconMetaSearch"
|
||||
"$ref": "#/definitions/iconlist.IconMetaSearch"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4335,8 +4335,7 @@
|
||||
"items": {
|
||||
"$ref": "#/definitions/rules.Rule"
|
||||
},
|
||||
"x-nullable": false,
|
||||
"x-omitempty": false
|
||||
"x-nullable": true
|
||||
},
|
||||
"scheme": {
|
||||
"type": "string",
|
||||
@@ -5202,7 +5201,7 @@
|
||||
"x-nullable": false,
|
||||
"x-omitempty": false
|
||||
},
|
||||
"homepage.FetchResult": {
|
||||
"iconfetch.Result": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"icon": {
|
||||
@@ -5223,7 +5222,7 @@
|
||||
"x-nullable": false,
|
||||
"x-omitempty": false
|
||||
},
|
||||
"homepage.IconMetaSearch": {
|
||||
"iconlist.IconMetaSearch": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Dark": {
|
||||
@@ -5252,7 +5251,7 @@
|
||||
"x-omitempty": false
|
||||
},
|
||||
"Source": {
|
||||
"$ref": "#/definitions/homepage.IconSource",
|
||||
"$ref": "#/definitions/icons.Source",
|
||||
"x-nullable": false,
|
||||
"x-omitempty": false
|
||||
},
|
||||
@@ -5265,7 +5264,7 @@
|
||||
"x-nullable": false,
|
||||
"x-omitempty": false
|
||||
},
|
||||
"homepage.IconSource": {
|
||||
"icons.Source": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"https://",
|
||||
@@ -5274,10 +5273,10 @@
|
||||
"@selfhst"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"IconSourceAbsolute",
|
||||
"IconSourceRelative",
|
||||
"IconSourceWalkXCode",
|
||||
"IconSourceSelfhSt"
|
||||
"SourceAbsolute",
|
||||
"SourceRelative",
|
||||
"SourceWalkXCode",
|
||||
"SourceSelfhSt"
|
||||
],
|
||||
"x-nullable": false,
|
||||
"x-omitempty": false
|
||||
@@ -5515,8 +5514,7 @@
|
||||
"items": {
|
||||
"$ref": "#/definitions/rules.Rule"
|
||||
},
|
||||
"x-nullable": false,
|
||||
"x-omitempty": false
|
||||
"x-nullable": true
|
||||
},
|
||||
"scheme": {
|
||||
"type": "string",
|
||||
|
||||
@@ -959,6 +959,7 @@ definitions:
|
||||
items:
|
||||
$ref: '#/definitions/rules.Rule'
|
||||
type: array
|
||||
x-nullable: true
|
||||
scheme:
|
||||
enum:
|
||||
- http
|
||||
@@ -1428,7 +1429,7 @@ definitions:
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
homepage.FetchResult:
|
||||
iconfetch.Result:
|
||||
properties:
|
||||
icon:
|
||||
items:
|
||||
@@ -1438,7 +1439,7 @@ definitions:
|
||||
statusCode:
|
||||
type: integer
|
||||
type: object
|
||||
homepage.IconMetaSearch:
|
||||
iconlist.IconMetaSearch:
|
||||
properties:
|
||||
Dark:
|
||||
type: boolean
|
||||
@@ -1451,11 +1452,11 @@ definitions:
|
||||
SVG:
|
||||
type: boolean
|
||||
Source:
|
||||
$ref: '#/definitions/homepage.IconSource'
|
||||
$ref: '#/definitions/icons.Source'
|
||||
WebP:
|
||||
type: boolean
|
||||
type: object
|
||||
homepage.IconSource:
|
||||
icons.Source:
|
||||
enum:
|
||||
- https://
|
||||
- '@target'
|
||||
@@ -1463,10 +1464,10 @@ definitions:
|
||||
- '@selfhst'
|
||||
type: string
|
||||
x-enum-varnames:
|
||||
- IconSourceAbsolute
|
||||
- IconSourceRelative
|
||||
- IconSourceWalkXCode
|
||||
- IconSourceSelfhSt
|
||||
- SourceAbsolute
|
||||
- SourceRelative
|
||||
- SourceWalkXCode
|
||||
- SourceSelfhSt
|
||||
mem.VirtualMemoryStat:
|
||||
properties:
|
||||
available:
|
||||
@@ -1594,6 +1595,7 @@ definitions:
|
||||
items:
|
||||
$ref: '#/definitions/rules.Rule'
|
||||
type: array
|
||||
x-nullable: true
|
||||
scheme:
|
||||
enum:
|
||||
- http
|
||||
@@ -2229,7 +2231,7 @@ paths:
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/homepage.FetchResult'
|
||||
$ref: '#/definitions/iconfetch.Result'
|
||||
type: array
|
||||
"400":
|
||||
description: 'Bad Request: alias is empty or route is not HTTPRoute'
|
||||
@@ -2811,7 +2813,7 @@ paths:
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/homepage.IconMetaSearch'
|
||||
$ref: '#/definitions/iconlist.IconMetaSearch'
|
||||
type: array
|
||||
"400":
|
||||
description: Bad Request
|
||||
|
||||
@@ -28,7 +28,7 @@ type GetFavIconRequest struct {
|
||||
// @Produce image/svg+xml,image/x-icon,image/png,image/webp
|
||||
// @Param url query string false "URL of the route"
|
||||
// @Param alias query string false "Alias of the route"
|
||||
// @Success 200 {array} homepage.FetchResult
|
||||
// @Success 200 {array} iconfetch.Result
|
||||
// @Failure 400 {object} apitypes.ErrorResponse "Bad Request: alias is empty or route is not HTTPRoute"
|
||||
// @Failure 403 {object} apitypes.ErrorResponse "Forbidden: unauthorized"
|
||||
// @Failure 404 {object} apitypes.ErrorResponse "Not Found: route or icon not found"
|
||||
|
||||
@@ -22,7 +22,7 @@ type ListIconsRequest struct {
|
||||
// @Produce json
|
||||
// @Param limit query int false "Limit"
|
||||
// @Param keyword query string false "Keyword"
|
||||
// @Success 200 {array} homepage.IconMetaSearch
|
||||
// @Success 200 {array} iconlist.IconMetaSearch
|
||||
// @Failure 400 {object} apitypes.ErrorResponse
|
||||
// @Failure 403 {object} apitypes.ErrorResponse
|
||||
// @Router /icons [get]
|
||||
|
||||
@@ -55,7 +55,7 @@ type (
|
||||
|
||||
route.HTTPConfig
|
||||
PathPatterns []string `json:"path_patterns,omitempty" extensions:"x-nullable"`
|
||||
Rules rules.Rules `json:"rules,omitempty" extension:"x-nullable"`
|
||||
Rules rules.Rules `json:"rules,omitempty" extensions:"x-nullable"`
|
||||
RuleFile string `json:"rule_file,omitempty" extensions:"x-nullable"`
|
||||
HealthCheck types.HealthCheckConfig `json:"healthcheck,omitempty" extensions:"x-nullable"` // null on load-balancer routes
|
||||
LoadBalance *types.LoadBalancerConfig `json:"load_balance,omitempty" extensions:"x-nullable"`
|
||||
|
||||
@@ -15,6 +15,7 @@ var (
|
||||
ErrInvalidArguments = gperr.New("invalid arguments")
|
||||
ErrInvalidOnTarget = gperr.New("invalid `rule.on` target")
|
||||
ErrInvalidCommandSequence = gperr.New("invalid command sequence")
|
||||
ErrMultipleDefaultRules = gperr.New("multiple default rules")
|
||||
|
||||
// vars errors
|
||||
ErrNoArgProvided = gperr.New("no argument provided")
|
||||
|
||||
@@ -26,6 +26,7 @@ func (on *RuleOn) Check(w http.ResponseWriter, r *http.Request) bool {
|
||||
}
|
||||
|
||||
const (
|
||||
OnDefault = "default"
|
||||
OnHeader = "header"
|
||||
OnQuery = "query"
|
||||
OnCookie = "cookie"
|
||||
@@ -50,6 +51,22 @@ var checkers = map[string]struct {
|
||||
builder func(args any) CheckFunc
|
||||
isResponseChecker bool
|
||||
}{
|
||||
OnDefault: {
|
||||
help: Help{
|
||||
command: OnDefault,
|
||||
description: makeLines(
|
||||
"The default rule is matched when no other rules are matched.",
|
||||
),
|
||||
args: map[string]string{},
|
||||
},
|
||||
validate: func(args []string) (any, gperr.Error) {
|
||||
if len(args) != 0 {
|
||||
return nil, ErrExpectNoArg
|
||||
}
|
||||
return nil, nil
|
||||
},
|
||||
builder: func(args any) CheckFunc { return func(w http.ResponseWriter, r *http.Request) bool { return false } }, // this should never be called
|
||||
},
|
||||
OnHeader: {
|
||||
help: Help{
|
||||
command: OnHeader,
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/quic-go/quic-go/http3"
|
||||
"github.com/rs/zerolog/log"
|
||||
gperr "github.com/yusing/goutils/errs"
|
||||
httputils "github.com/yusing/goutils/http"
|
||||
"golang.org/x/net/http2"
|
||||
|
||||
@@ -57,6 +58,19 @@ func (rule *Rule) IsResponseRule() bool {
|
||||
return rule.On.IsResponseChecker() || rule.Do.IsResponseHandler()
|
||||
}
|
||||
|
||||
func (rules Rules) Validate() gperr.Error {
|
||||
var defaultRulesFound []int
|
||||
for i, rule := range rules {
|
||||
if rule.Name == "default" || rule.On.raw == OnDefault {
|
||||
defaultRulesFound = append(defaultRulesFound, i)
|
||||
}
|
||||
}
|
||||
if len(defaultRulesFound) > 1 {
|
||||
return ErrMultipleDefaultRules.Withf("found %d", len(defaultRulesFound))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildHandler returns a http.HandlerFunc that implements the rules.
|
||||
func (rules Rules) BuildHandler(up http.HandlerFunc) http.HandlerFunc {
|
||||
if len(rules) == 0 {
|
||||
@@ -74,7 +88,7 @@ func (rules Rules) BuildHandler(up http.HandlerFunc) http.HandlerFunc {
|
||||
var nonDefaultRules Rules
|
||||
hasDefaultRule := false
|
||||
for i, rule := range rules {
|
||||
if rule.Name == "default" {
|
||||
if rule.Name == "default" || rule.On.raw == OnDefault {
|
||||
defaultRule = rule
|
||||
hasDefaultRule = true
|
||||
} else {
|
||||
|
||||
52
internal/route/rules/rules_test.go
Normal file
52
internal/route/rules/rules_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yusing/godoxy/internal/serialization"
|
||||
)
|
||||
|
||||
func TestRulesValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rules string
|
||||
want error
|
||||
}{
|
||||
{
|
||||
name: "no default rule",
|
||||
rules: `
|
||||
- name: rule1
|
||||
on: header Host example.com
|
||||
do: pass
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "multiple default rules",
|
||||
rules: `
|
||||
- name: default
|
||||
do: pass
|
||||
- name: rule1
|
||||
on: default
|
||||
do: pass
|
||||
`,
|
||||
want: ErrMultipleDefaultRules,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var rules Rules
|
||||
convertible, err := serialization.ConvertString(strings.TrimSpace(tt.rules), reflect.ValueOf(&rules))
|
||||
require.True(t, convertible)
|
||||
|
||||
if tt.want == nil {
|
||||
assert.NoError(t, err)
|
||||
return
|
||||
}
|
||||
assert.ErrorIs(t, err, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -449,51 +449,66 @@ func Convert(src reflect.Value, dst reflect.Value, checkValidateTag bool) gperr.
|
||||
}
|
||||
return mapUnmarshalValidate(obj, dst.Addr(), checkValidateTag)
|
||||
case srcKind == reflect.Slice: // slice to slice
|
||||
srcLen := src.Len()
|
||||
if srcLen == 0 {
|
||||
dst.SetZero()
|
||||
return nil
|
||||
}
|
||||
if dstT.Kind() != reflect.Slice {
|
||||
return ErrUnsupportedConversion.Subject(dstT.String() + " to " + srcT.String())
|
||||
}
|
||||
var sliceErrs gperr.Builder
|
||||
i := 0
|
||||
gi.ReflectInitSlice(dst, srcLen, srcLen)
|
||||
for j, v := range src.Seq2() {
|
||||
err := Convert(v, dst.Index(i), checkValidateTag)
|
||||
if err != nil {
|
||||
sliceErrs.Add(err.Subjectf("[%d]", j))
|
||||
continue
|
||||
}
|
||||
i++
|
||||
}
|
||||
if err := sliceErrs.Error(); err != nil {
|
||||
dst.SetLen(i) // shrink to number of elements that were successfully converted
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return ConvertSlice(src, dst, checkValidateTag)
|
||||
}
|
||||
|
||||
return ErrUnsupportedConversion.Subjectf("%s to %s", srcT.String(), dstT.String())
|
||||
return ErrUnsupportedConversion.Subjectf("%s to %s", srcT, dstT)
|
||||
}
|
||||
|
||||
var parserType = reflect.TypeFor[strutils.Parser]()
|
||||
func ConvertSlice(src reflect.Value, dst reflect.Value, checkValidateTag bool) gperr.Error {
|
||||
if src.Kind() != reflect.Slice {
|
||||
return Convert(src, dst, checkValidateTag)
|
||||
}
|
||||
|
||||
srcLen := src.Len()
|
||||
if srcLen == 0 {
|
||||
dst.SetZero()
|
||||
return nil
|
||||
}
|
||||
if dst.Kind() != reflect.Slice {
|
||||
return ErrUnsupportedConversion.Subjectf("%s to %s", dst.Type(), src.Type())
|
||||
}
|
||||
|
||||
var sliceErrs gperr.Builder
|
||||
numValid := 0
|
||||
gi.ReflectInitSlice(dst, srcLen, srcLen)
|
||||
for j := range srcLen {
|
||||
err := Convert(src.Index(j), dst.Index(numValid), checkValidateTag)
|
||||
if err != nil {
|
||||
sliceErrs.Add(err.Subjectf("[%d]", j))
|
||||
continue
|
||||
}
|
||||
numValid++
|
||||
}
|
||||
|
||||
if dst.Type().Implements(reflect.TypeFor[CustomValidator]()) {
|
||||
err := dst.Interface().(CustomValidator).Validate()
|
||||
if err != nil {
|
||||
sliceErrs.Add(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := sliceErrs.Error(); err != nil {
|
||||
dst.SetLen(numValid) // shrink to number of elements that were successfully converted
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gperr.Error) {
|
||||
convertible = true
|
||||
dstT := dst.Type()
|
||||
if dst.Kind() == reflect.Pointer {
|
||||
if dst.IsNil() {
|
||||
// Early return for empty string
|
||||
if src == "" {
|
||||
return true, nil
|
||||
}
|
||||
initPtr(dst)
|
||||
}
|
||||
dst = dst.Elem()
|
||||
dstT = dst.Type()
|
||||
}
|
||||
if dst.Kind() == reflect.String {
|
||||
dst.SetString(src)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Early return for empty string
|
||||
if src == "" {
|
||||
@@ -501,6 +516,17 @@ func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gpe
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if dst.Kind() == reflect.String {
|
||||
dst.SetString(src)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// check if (*T).Convertor is implemented
|
||||
if addr := dst.Addr(); addr.Type().Implements(reflect.TypeFor[strutils.Parser]()) {
|
||||
parser := addr.Interface().(strutils.Parser)
|
||||
return true, gperr.Wrap(parser.Parse(src))
|
||||
}
|
||||
|
||||
switch dstT {
|
||||
case reflect.TypeFor[time.Duration]():
|
||||
d, err := time.ParseDuration(src)
|
||||
@@ -512,12 +538,6 @@ func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gpe
|
||||
default:
|
||||
}
|
||||
|
||||
// check if (*T).Convertor is implemented
|
||||
if dst.Addr().Type().Implements(parserType) {
|
||||
parser := dst.Addr().Interface().(strutils.Parser)
|
||||
return true, gperr.Wrap(parser.Parse(src))
|
||||
}
|
||||
|
||||
if gi.ReflectIsNumeric(dst) || dst.Kind() == reflect.Bool {
|
||||
err := gi.ReflectStrToNumBool(dst, src)
|
||||
if err != nil {
|
||||
@@ -527,29 +547,25 @@ func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gpe
|
||||
}
|
||||
|
||||
// yaml like
|
||||
var tmp any
|
||||
switch dst.Kind() {
|
||||
case reflect.Slice:
|
||||
// Avoid unnecessary TrimSpace if we can detect the format early
|
||||
srcLen := len(src)
|
||||
if srcLen == 0 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// one liner is comma separated list
|
||||
isMultiline := strings.ContainsRune(src, '\n')
|
||||
if !isMultiline && src[0] != '-' {
|
||||
isMultiline := strings.IndexByte(src, '\n') != -1
|
||||
if !isMultiline && src[0] != '-' && src[0] != '[' {
|
||||
values := strutils.CommaSeperatedList(src)
|
||||
gi.ReflectInitSlice(dst, len(values), len(values))
|
||||
size := len(values)
|
||||
gi.ReflectInitSlice(dst, size, size)
|
||||
var errs gperr.Builder
|
||||
for i, v := range values {
|
||||
_, err := ConvertString(v, dst.Index(i))
|
||||
if err != nil {
|
||||
errs.Add(err.Subjectf("[%d]", i))
|
||||
errs.AddSubjectf(err, "[%d]", i)
|
||||
}
|
||||
}
|
||||
err := errs.Error()
|
||||
return true, err
|
||||
if errs.HasError() {
|
||||
return true, errs.Error()
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
sl := []any{}
|
||||
@@ -557,18 +573,17 @@ func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gpe
|
||||
if err != nil {
|
||||
return true, gperr.Wrap(err)
|
||||
}
|
||||
tmp = sl
|
||||
return true, ConvertSlice(reflect.ValueOf(sl), dst, true)
|
||||
case reflect.Map, reflect.Struct:
|
||||
rawMap := SerializedObject{}
|
||||
err := yaml.Unmarshal(unsafe.Slice(unsafe.StringData(src), len(src)), &rawMap)
|
||||
if err != nil {
|
||||
return true, gperr.Wrap(err)
|
||||
}
|
||||
tmp = rawMap
|
||||
return true, mapUnmarshalValidate(rawMap, dst, true)
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
return true, Convert(reflect.ValueOf(tmp), dst, true)
|
||||
}
|
||||
|
||||
var envRegex = regexp.MustCompile(`\$\{([^}]+)\}`) // e.g. ${CLOUDFLARE_API_KEY}
|
||||
|
||||
@@ -32,9 +32,9 @@ func BenchmarkDeserialize(b *testing.B) {
|
||||
"c": "1,2,3",
|
||||
"d": "a: a\nb: b\nc: c",
|
||||
"e": "- a: a\n b: b\n c: c",
|
||||
"f": map[string]any{"a": "a", "b": "456", "c": []string{"1", "2", "3"}},
|
||||
"f": map[string]any{"a": "a", "b": "456", "c": `1,2,3`},
|
||||
"g": map[string]any{"g1": "1.23", "g2": 123},
|
||||
"h": []map[string]any{{"a": 123, "b": "456", "c": []string{"1", "2", "3"}}},
|
||||
"h": []map[string]any{{"a": 123, "b": "456", "c": `["1","2","3"]`}},
|
||||
"j": "1.23",
|
||||
"k": 123,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Glob } from "bun";
|
||||
import { linkSync } from "fs";
|
||||
import { mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
@@ -8,6 +7,8 @@ type ImplDoc = {
|
||||
pkgPath: string;
|
||||
/** File name in wiki `src/impl/`, e.g. "internal-health-check.md" */
|
||||
docFileName: string;
|
||||
/** VitePress route path (extensionless), e.g. "/impl/internal-health-check" */
|
||||
docRoute: string;
|
||||
/** Absolute source README path */
|
||||
srcPathAbs: string;
|
||||
/** Absolute destination doc path */
|
||||
@@ -17,6 +18,8 @@ type ImplDoc = {
|
||||
const START_MARKER = "// GENERATED-IMPL-SIDEBAR-START";
|
||||
const END_MARKER = "// GENERATED-IMPL-SIDEBAR-END";
|
||||
|
||||
const skipSubmodules = ["internal/go-oidc/", "internal/gopsutil/"];
|
||||
|
||||
function escapeRegex(s: string) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
@@ -25,6 +28,16 @@ function escapeSingleQuotedTs(s: string) {
|
||||
return s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
||||
}
|
||||
|
||||
function normalizeRepoUrl(raw: string) {
|
||||
let url = (raw ?? "").trim();
|
||||
if (!url) return "";
|
||||
// Common typo: "https://https://github.com/..."
|
||||
url = url.replace(/^https?:\/\/https?:\/\//i, "https://");
|
||||
if (!/^https?:\/\//i.test(url)) url = `https://${url}`;
|
||||
url = url.replace(/\/+$/, "");
|
||||
return url;
|
||||
}
|
||||
|
||||
function sanitizeFileStemFromPkgPath(pkgPath: string) {
|
||||
// Convert a package path into a stable filename.
|
||||
// Example: "internal/go-oidc/example" -> "internal-go-oidc-example"
|
||||
@@ -37,6 +50,133 @@ function sanitizeFileStemFromPkgPath(pkgPath: string) {
|
||||
return joined.replace(/-+/g, "-").replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
function splitUrlAndFragment(url: string): {
|
||||
urlNoFragment: string;
|
||||
fragment: string;
|
||||
} {
|
||||
const i = url.indexOf("#");
|
||||
if (i === -1) return { urlNoFragment: url, fragment: "" };
|
||||
return { urlNoFragment: url.slice(0, i), fragment: url.slice(i) };
|
||||
}
|
||||
|
||||
function isExternalOrAbsoluteUrl(url: string) {
|
||||
// - absolute site links: "/foo"
|
||||
// - pure fragments: "#bar"
|
||||
// - external schemes: "https:", "mailto:", "vscode:", etc.
|
||||
// IMPORTANT: don't treat "config.go:29" as a scheme.
|
||||
if (url.startsWith("/") || url.startsWith("#")) return true;
|
||||
if (url.includes("://")) return true;
|
||||
return /^(https?|mailto|tel|vscode|file|data|ssh|git):/i.test(url);
|
||||
}
|
||||
|
||||
function isRepoSourceFilePath(filePath: string) {
|
||||
// Conservative allow-list: avoid rewriting .md (non-README) which may be VitePress docs.
|
||||
return /\.(go|ts|tsx|js|jsx|py|sh|yml|yaml|json|toml|env|css|html|txt)$/i.test(
|
||||
filePath
|
||||
);
|
||||
}
|
||||
|
||||
function parseFileLineSuffix(urlNoFragment: string): {
|
||||
filePath: string;
|
||||
line?: string;
|
||||
} {
|
||||
// Match "file.ext:123" (line suffix), while leaving "file.ext" untouched.
|
||||
const m = urlNoFragment.match(/^(.*?):(\d+)$/);
|
||||
if (!m) return { filePath: urlNoFragment };
|
||||
return { filePath: m[1] ?? urlNoFragment, line: m[2] };
|
||||
}
|
||||
|
||||
function rewriteMarkdownLinksOutsideFences(
|
||||
md: string,
|
||||
rewriteInline: (url: string) => string
|
||||
) {
|
||||
const lines = md.split("\n");
|
||||
let inFence = false;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i] ?? "";
|
||||
const trimmed = line.trimStart();
|
||||
if (trimmed.startsWith("```")) {
|
||||
inFence = !inFence;
|
||||
continue;
|
||||
}
|
||||
if (inFence) continue;
|
||||
|
||||
// Inline markdown links/images: [text](url "title") / 
|
||||
lines[i] = line.replace(
|
||||
/\]\(([^)\s]+)(\s+"[^"]*")?\)/g,
|
||||
(_full, urlRaw: string, maybeTitle: string | undefined) => {
|
||||
const rewritten = rewriteInline(urlRaw);
|
||||
return `](${rewritten}${maybeTitle ?? ""})`;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function rewriteImplMarkdown(params: {
|
||||
md: string;
|
||||
pkgPath: string;
|
||||
readmeRelToDocRoute: Map<string, string>;
|
||||
dirPathToDocRoute: Map<string, string>;
|
||||
repoUrl: string;
|
||||
}) {
|
||||
const { md, pkgPath, readmeRelToDocRoute, dirPathToDocRoute, repoUrl } =
|
||||
params;
|
||||
|
||||
return rewriteMarkdownLinksOutsideFences(md, (urlRaw) => {
|
||||
// Handle angle-bracketed destinations: (<./foo/README.md>)
|
||||
const angleWrapped =
|
||||
urlRaw.startsWith("<") && urlRaw.endsWith(">")
|
||||
? urlRaw.slice(1, -1)
|
||||
: urlRaw;
|
||||
|
||||
const { urlNoFragment, fragment } = splitUrlAndFragment(angleWrapped);
|
||||
if (!urlNoFragment) return urlRaw;
|
||||
if (isExternalOrAbsoluteUrl(urlNoFragment)) return urlRaw;
|
||||
|
||||
// 1) Directory links like "common" or "common/" that have a README
|
||||
const dirPathNormalized = urlNoFragment.replace(/\/+$/, "");
|
||||
if (dirPathToDocRoute.has(dirPathNormalized)) {
|
||||
const rewritten = `${dirPathToDocRoute.get(
|
||||
dirPathNormalized
|
||||
)!}${fragment}`;
|
||||
return angleWrapped === urlRaw ? rewritten : `<${rewritten}>`;
|
||||
}
|
||||
|
||||
// 2) Intra-repo README links -> VitePress impl routes
|
||||
if (/(^|\/)README\.md$/.test(urlNoFragment)) {
|
||||
const targetReadmeRel = path.posix.normalize(
|
||||
path.posix.join(pkgPath, urlNoFragment)
|
||||
);
|
||||
const route = readmeRelToDocRoute.get(targetReadmeRel);
|
||||
if (route) {
|
||||
const rewritten = `${route}${fragment}`;
|
||||
return angleWrapped === urlRaw ? rewritten : `<${rewritten}>`;
|
||||
}
|
||||
return urlRaw;
|
||||
}
|
||||
|
||||
// 3) Local source-file references like "config.go:29" -> GitHub blob link
|
||||
if (repoUrl) {
|
||||
const { filePath, line } = parseFileLineSuffix(urlNoFragment);
|
||||
if (isRepoSourceFilePath(filePath)) {
|
||||
const repoRel = path.posix.normalize(
|
||||
path.posix.join(pkgPath, filePath)
|
||||
);
|
||||
const githubUrl = `${repoUrl}/blob/main/${repoRel}${
|
||||
line ? `#L${line}` : ""
|
||||
}`;
|
||||
const rewritten = `${githubUrl}${fragment}`;
|
||||
return angleWrapped === urlRaw ? rewritten : `<${rewritten}>`;
|
||||
}
|
||||
}
|
||||
|
||||
return urlRaw;
|
||||
});
|
||||
}
|
||||
|
||||
async function listRepoReadmes(repoRootAbs: string): Promise<string[]> {
|
||||
const glob = new Glob("**/README.md");
|
||||
const readmes: string[] = [];
|
||||
@@ -51,8 +191,14 @@ async function listRepoReadmes(repoRootAbs: string): Promise<string[]> {
|
||||
if (rel.startsWith(".git/") || rel.includes("/.git/")) continue;
|
||||
if (rel.startsWith("node_modules/") || rel.includes("/node_modules/"))
|
||||
continue;
|
||||
if (rel.startsWith("internal/go-oidc/")) continue;
|
||||
if (rel.startsWith("internal/gopsutil/")) continue;
|
||||
let skip = false;
|
||||
for (const submodule of skipSubmodules) {
|
||||
if (rel.startsWith(submodule)) {
|
||||
skip = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (skip) continue;
|
||||
readmes.push(rel);
|
||||
}
|
||||
|
||||
@@ -61,11 +207,34 @@ async function listRepoReadmes(repoRootAbs: string): Promise<string[]> {
|
||||
return readmes;
|
||||
}
|
||||
|
||||
async function ensureHardLink(srcAbs: string, dstAbs: string) {
|
||||
async function writeImplDocCopy(params: {
|
||||
srcAbs: string;
|
||||
dstAbs: string;
|
||||
pkgPath: string;
|
||||
readmeRelToDocRoute: Map<string, string>;
|
||||
dirPathToDocRoute: Map<string, string>;
|
||||
repoUrl: string;
|
||||
}) {
|
||||
const {
|
||||
srcAbs,
|
||||
dstAbs,
|
||||
pkgPath,
|
||||
readmeRelToDocRoute,
|
||||
dirPathToDocRoute,
|
||||
repoUrl,
|
||||
} = params;
|
||||
await mkdir(path.dirname(dstAbs), { recursive: true });
|
||||
await rm(dstAbs, { force: true });
|
||||
// Prefer sync for better error surfaces in Bun on some platforms.
|
||||
linkSync(srcAbs, dstAbs);
|
||||
|
||||
const original = await readFile(srcAbs, "utf8");
|
||||
const rewritten = rewriteImplMarkdown({
|
||||
md: original,
|
||||
pkgPath,
|
||||
readmeRelToDocRoute,
|
||||
dirPathToDocRoute,
|
||||
repoUrl,
|
||||
});
|
||||
await writeFile(dstAbs, rewritten);
|
||||
}
|
||||
|
||||
async function syncImplDocs(
|
||||
@@ -78,6 +247,30 @@ async function syncImplDocs(
|
||||
const readmes = await listRepoReadmes(repoRootAbs);
|
||||
const docs: ImplDoc[] = [];
|
||||
const expectedFileNames = new Set<string>();
|
||||
expectedFileNames.add("introduction.md");
|
||||
|
||||
const repoUrl = normalizeRepoUrl(
|
||||
Bun.env.REPO_URL ?? "https://github.com/yusing/godoxy"
|
||||
);
|
||||
|
||||
// Precompute mapping from repo-relative README path -> VitePress route.
|
||||
// This lets us rewrite intra-repo README links when copying content.
|
||||
const readmeRelToDocRoute = new Map<string, string>();
|
||||
|
||||
// Also precompute mapping from directory path -> VitePress route.
|
||||
// This handles links like "[`common/`](common)" that point to directories with READMEs.
|
||||
const dirPathToDocRoute = new Map<string, string>();
|
||||
|
||||
for (const readmeRel of readmes) {
|
||||
const pkgPath = path.posix.dirname(readmeRel);
|
||||
if (!pkgPath || pkgPath === ".") continue;
|
||||
|
||||
const docStem = sanitizeFileStemFromPkgPath(pkgPath);
|
||||
if (!docStem) continue;
|
||||
const route = `/impl/${docStem}`;
|
||||
readmeRelToDocRoute.set(readmeRel, route);
|
||||
dirPathToDocRoute.set(pkgPath, route);
|
||||
}
|
||||
|
||||
for (const readmeRel of readmes) {
|
||||
const pkgPath = path.posix.dirname(readmeRel);
|
||||
@@ -86,13 +279,21 @@ async function syncImplDocs(
|
||||
const docStem = sanitizeFileStemFromPkgPath(pkgPath);
|
||||
if (!docStem) continue;
|
||||
const docFileName = `${docStem}.md`;
|
||||
const docRoute = `/impl/${docStem}`;
|
||||
|
||||
const srcPathAbs = path.join(repoRootAbs, readmeRel);
|
||||
const dstPathAbs = path.join(implDirAbs, docFileName);
|
||||
|
||||
await ensureHardLink(srcPathAbs, dstPathAbs);
|
||||
await writeImplDocCopy({
|
||||
srcAbs: srcPathAbs,
|
||||
dstAbs: dstPathAbs,
|
||||
pkgPath,
|
||||
readmeRelToDocRoute,
|
||||
dirPathToDocRoute,
|
||||
repoUrl,
|
||||
});
|
||||
|
||||
docs.push({ pkgPath, docFileName, srcPathAbs, dstPathAbs });
|
||||
docs.push({ pkgPath, docFileName, docRoute, srcPathAbs, dstPathAbs });
|
||||
expectedFileNames.add(docFileName);
|
||||
}
|
||||
|
||||
@@ -111,13 +312,13 @@ async function syncImplDocs(
|
||||
}
|
||||
|
||||
function renderSidebarItems(docs: ImplDoc[], indent: string) {
|
||||
// link: '/impl/<file>.md' because VitePress `srcDir = "src"`.
|
||||
// link: '/impl/<stem>' (extensionless) because VitePress `srcDir = "src"`.
|
||||
if (docs.length === 0) return "";
|
||||
return (
|
||||
docs
|
||||
.map((d) => {
|
||||
const text = escapeSingleQuotedTs(d.pkgPath);
|
||||
const link = escapeSingleQuotedTs(`/impl/${d.docFileName}`);
|
||||
const link = escapeSingleQuotedTs(d.docRoute);
|
||||
return `${indent}{ text: '${text}', link: '${link}' },`;
|
||||
})
|
||||
.join("\n") + "\n"
|
||||
|
||||
Reference in New Issue
Block a user