mirror of
https://github.com/certimate-go/certimate.git
synced 2026-09-24 23:10:13 +08:00
refactor(provider): use lego to implement dns-01 challenger of dnsla
This commit is contained in:
@@ -4,8 +4,9 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-acme/lego/v5/providers/dns/dnsla"
|
||||
|
||||
"github.com/certimate-go/certimate/pkg/core/certifier"
|
||||
"github.com/certimate-go/certimate/pkg/core/certifier/challengers/dns01/dnsla/internal"
|
||||
)
|
||||
|
||||
type ChallengerConfig struct {
|
||||
@@ -20,8 +21,8 @@ func NewChallenger(config *ChallengerConfig) (certifier.ACMEChallenger, error) {
|
||||
return nil, fmt.Errorf("the configuration of the acme challenge provider is nil")
|
||||
}
|
||||
|
||||
providerConfig := internal.NewDefaultConfig()
|
||||
providerConfig.APIId = config.ApiId
|
||||
providerConfig := dnsla.NewDefaultConfig()
|
||||
providerConfig.APIID = config.ApiId
|
||||
providerConfig.APISecret = config.ApiSecret
|
||||
if config.DnsPropagationTimeout != 0 {
|
||||
providerConfig.PropagationTimeout = time.Duration(config.DnsPropagationTimeout) * time.Second
|
||||
@@ -30,7 +31,7 @@ func NewChallenger(config *ChallengerConfig) (certifier.ACMEChallenger, error) {
|
||||
providerConfig.TTL = config.DnsTTL
|
||||
}
|
||||
|
||||
provider, err := internal.NewDNSProviderConfig(providerConfig)
|
||||
provider, err := dnsla.NewDNSProviderConfig(providerConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-acme/lego/v5/challenge"
|
||||
"github.com/go-acme/lego/v5/challenge/dns01"
|
||||
"github.com/go-acme/lego/v5/platform/env"
|
||||
"github.com/samber/lo"
|
||||
|
||||
dnslasdk "github.com/certimate-go/certimate/pkg/sdk3rd/dnsla"
|
||||
)
|
||||
|
||||
const (
|
||||
envNamespace = "DNSLA_"
|
||||
|
||||
EnvAPIId = envNamespace + "API_ID"
|
||||
EnvAPISecret = envNamespace + "API_SECRET"
|
||||
|
||||
EnvTTL = envNamespace + "TTL"
|
||||
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
|
||||
EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
|
||||
EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
|
||||
)
|
||||
|
||||
var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
|
||||
|
||||
type Config struct {
|
||||
APIId string
|
||||
APISecret string
|
||||
|
||||
PropagationTimeout time.Duration
|
||||
PollingInterval time.Duration
|
||||
TTL int
|
||||
HTTPTimeout time.Duration
|
||||
}
|
||||
|
||||
type DNSProvider struct {
|
||||
config *Config
|
||||
client *dnslasdk.Client
|
||||
|
||||
recordIDs map[string]string // Key: ChallengeToken; Value: RecordID
|
||||
recordIDsMu sync.Mutex
|
||||
}
|
||||
|
||||
func NewDefaultConfig() *Config {
|
||||
return &Config{
|
||||
TTL: env.GetOrDefaultInt(EnvTTL, 300),
|
||||
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute),
|
||||
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
|
||||
HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
func NewDNSProvider() (*DNSProvider, error) {
|
||||
values, err := env.Get(EnvAPIId, EnvAPISecret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dnsla: %w", err)
|
||||
}
|
||||
|
||||
config := NewDefaultConfig()
|
||||
config.APIId = values[EnvAPIId]
|
||||
config.APISecret = values[EnvAPISecret]
|
||||
|
||||
return NewDNSProviderConfig(config)
|
||||
}
|
||||
|
||||
func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
|
||||
if config == nil {
|
||||
return nil, fmt.Errorf("dnsla: the configuration of the DNS provider is nil")
|
||||
}
|
||||
|
||||
client, err := dnslasdk.NewClient(config.APIId, config.APISecret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dnsla: %w", err)
|
||||
} else {
|
||||
client.SetTimeout(config.HTTPTimeout)
|
||||
}
|
||||
|
||||
return &DNSProvider{
|
||||
config: config,
|
||||
client: client,
|
||||
recordIDs: make(map[string]string),
|
||||
recordIDsMu: sync.Mutex{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *DNSProvider) Present(ctx context.Context, domain, token, keyAuth string) error {
|
||||
info := dns01.GetChallengeInfo(ctx, domain, keyAuth)
|
||||
|
||||
authZone, err := dns01.DefaultClient().FindZoneByFqdn(ctx, info.EffectiveFQDN)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dnsla: could not find zone for domain %q: %w", domain, err)
|
||||
}
|
||||
|
||||
subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dnsla: %w", err)
|
||||
}
|
||||
|
||||
zone, err := d.findZone(ctx, dns01.UnFqdn(authZone))
|
||||
if err != nil {
|
||||
return fmt.Errorf("dnsla: error when list zones: %w", err)
|
||||
}
|
||||
|
||||
// REF: https://www.dnsla.cn/docs/ApiDoc
|
||||
request := &dnslasdk.CreateRecordRequest{
|
||||
DomainId: lo.ToPtr(zone.Id),
|
||||
Type: lo.ToPtr(int32(16)),
|
||||
Host: lo.ToPtr(subDomain),
|
||||
Data: lo.ToPtr(info.Value),
|
||||
Ttl: lo.ToPtr(int32(d.config.TTL)),
|
||||
}
|
||||
response, err := d.client.CreateRecordWithContext(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dnsla: error when create record: %w", err)
|
||||
}
|
||||
|
||||
d.recordIDsMu.Lock()
|
||||
d.recordIDs[token] = response.Data.Id
|
||||
d.recordIDsMu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DNSProvider) CleanUp(ctx context.Context, domain, token, keyAuth string) error {
|
||||
info := dns01.GetChallengeInfo(ctx, domain, keyAuth)
|
||||
|
||||
d.recordIDsMu.Lock()
|
||||
recordID, ok := d.recordIDs[token]
|
||||
d.recordIDsMu.Unlock()
|
||||
if !ok {
|
||||
return fmt.Errorf("dnsla: unknown record ID for '%s'", info.EffectiveFQDN)
|
||||
}
|
||||
|
||||
// REF: https://www.dnsla.cn/docs/ApiDoc
|
||||
if _, err := d.client.DeleteRecordWithContext(ctx, recordID); err != nil {
|
||||
return fmt.Errorf("dnsla: error when delete record: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
|
||||
return d.config.PropagationTimeout, d.config.PollingInterval
|
||||
}
|
||||
|
||||
func (d *DNSProvider) findZone(ctx context.Context, zoneName string) (*dnslasdk.DomainRecord, error) {
|
||||
pageIndex := 1
|
||||
pageSize := 100
|
||||
for {
|
||||
// REF: https://www.dnsla.cn/docs/ApiDoc
|
||||
request := &dnslasdk.ListDomainsRequest{
|
||||
PageIndex: lo.ToPtr(int32(pageIndex)),
|
||||
PageSize: lo.ToPtr(int32(pageSize)),
|
||||
}
|
||||
response, err := d.client.ListDomainsWithContext(ctx, request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if response.Data == nil {
|
||||
break
|
||||
}
|
||||
|
||||
for _, domainItem := range response.Data.Results {
|
||||
if strings.TrimSuffix(domainItem.Domain, ".") == zoneName || strings.TrimSuffix(domainItem.DisplayDomain, ".") == zoneName {
|
||||
return domainItem, nil
|
||||
}
|
||||
}
|
||||
|
||||
if len(response.Data.Results) < pageSize {
|
||||
break
|
||||
}
|
||||
|
||||
pageIndex++
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("could not find zone '%s'", zoneName)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package dnsla
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type CreateRecordRequest struct {
|
||||
DomainId *string `json:"domainId"`
|
||||
GroupId *string `json:"groupId,omitempty"`
|
||||
LineId *string `json:"lineId,omitempty"`
|
||||
Type *int32 `json:"type"`
|
||||
Host *string `json:"host"`
|
||||
Data *string `json:"data"`
|
||||
Ttl *int32 `json:"ttl"`
|
||||
Weight *int32 `json:"weight,omitempty"`
|
||||
Preference *int32 `json:"preference,omitempty"`
|
||||
}
|
||||
|
||||
type CreateRecordResponse struct {
|
||||
sdkResponseBase
|
||||
Data *struct {
|
||||
Id string `json:"id"`
|
||||
} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) CreateRecord(req *CreateRecordRequest) (*CreateRecordResponse, error) {
|
||||
return c.CreateRecordWithContext(context.Background(), req)
|
||||
}
|
||||
|
||||
func (c *Client) CreateRecordWithContext(ctx context.Context, req *CreateRecordRequest) (*CreateRecordResponse, error) {
|
||||
httpreq, err := c.newRequest(http.MethodPost, "/record")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
httpreq.SetBody(req)
|
||||
httpreq.SetContext(ctx)
|
||||
}
|
||||
|
||||
result := &CreateRecordResponse{}
|
||||
if _, err := c.doRequestWithResult(httpreq, result); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package dnsla
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type DeleteRecordResponse struct {
|
||||
sdkResponseBase
|
||||
}
|
||||
|
||||
func (c *Client) DeleteRecord(recordId string) (*DeleteRecordResponse, error) {
|
||||
return c.DeleteRecordWithContext(context.Background(), recordId)
|
||||
}
|
||||
|
||||
func (c *Client) DeleteRecordWithContext(ctx context.Context, recordId string) (*DeleteRecordResponse, error) {
|
||||
if recordId == "" {
|
||||
return nil, fmt.Errorf("sdkerr: unset recordId")
|
||||
}
|
||||
|
||||
httpreq, err := c.newRequest(http.MethodDelete, "/record")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
httpreq.SetQueryParam("id", recordId)
|
||||
httpreq.SetContext(ctx)
|
||||
}
|
||||
|
||||
result := &DeleteRecordResponse{}
|
||||
if _, err := c.doRequestWithResult(httpreq, result); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package dnsla
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
qs "github.com/google/go-querystring/query"
|
||||
)
|
||||
|
||||
type ListDomainsRequest struct {
|
||||
GroupId *string `json:"groupId,omitempty" url:"groupId,omitempty"`
|
||||
PageIndex *int32 `json:"pageIndex,omitempty" url:"pageIndex,omitempty"`
|
||||
PageSize *int32 `json:"pageSize,omitempty" url:"pageSize,omitempty"`
|
||||
}
|
||||
|
||||
type ListDomainsResponse struct {
|
||||
sdkResponseBase
|
||||
Data *struct {
|
||||
Total int32 `json:"total"`
|
||||
Results []*DomainRecord `json:"results"`
|
||||
} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) ListDomains(req *ListDomainsRequest) (*ListDomainsResponse, error) {
|
||||
return c.ListDomainsWithContext(context.Background(), req)
|
||||
}
|
||||
|
||||
func (c *Client) ListDomainsWithContext(ctx context.Context, req *ListDomainsRequest) (*ListDomainsResponse, error) {
|
||||
httpreq, err := c.newRequest(http.MethodGet, "/domainList")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
values, err := qs.Values(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpreq.SetQueryParamsFromValues(values)
|
||||
httpreq.SetContext(ctx)
|
||||
}
|
||||
|
||||
result := &ListDomainsResponse{}
|
||||
if _, err := c.doRequestWithResult(httpreq, result); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package dnsla
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
qs "github.com/google/go-querystring/query"
|
||||
)
|
||||
|
||||
type ListRecordsRequest struct {
|
||||
DomainId *string `json:"domainId,omitempty" url:"domainId,omitempty"`
|
||||
GroupId *string `json:"groupId,omitempty" url:"groupId,omitempty"`
|
||||
LineId *string `json:"lineId,omitempty" url:"lineId,omitempty"`
|
||||
Type *int32 `json:"type,omitempty" url:"type,omitempty"`
|
||||
Host *string `json:"host,omitempty" url:"host,omitempty"`
|
||||
Data *string `json:"data,omitempty" url:"data,omitempty"`
|
||||
PageIndex *int32 `json:"pageIndex,omitempty" url:"pageIndex,omitempty"`
|
||||
PageSize *int32 `json:"pageSize,omitempty" url:"pageSize,omitempty"`
|
||||
}
|
||||
|
||||
type ListRecordsResponse struct {
|
||||
sdkResponseBase
|
||||
Data *struct {
|
||||
Total int32 `json:"total"`
|
||||
Results []*DnsRecord `json:"results"`
|
||||
} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) ListRecords(req *ListRecordsRequest) (*ListRecordsResponse, error) {
|
||||
return c.ListRecordsWithContext(context.Background(), req)
|
||||
}
|
||||
|
||||
func (c *Client) ListRecordsWithContext(ctx context.Context, req *ListRecordsRequest) (*ListRecordsResponse, error) {
|
||||
httpreq, err := c.newRequest(http.MethodGet, "/recordList")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
values, err := qs.Values(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpreq.SetQueryParamsFromValues(values)
|
||||
httpreq.SetContext(ctx)
|
||||
}
|
||||
|
||||
result := &ListRecordsResponse{}
|
||||
if _, err := c.doRequestWithResult(httpreq, result); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package dnsla
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type UpdateRecordRequest struct {
|
||||
Id *string `json:"id"`
|
||||
GroupId *string `json:"groupId,omitempty"`
|
||||
LineId *string `json:"lineId,omitempty"`
|
||||
Type *int32 `json:"type,omitempty"`
|
||||
Host *string `json:"host,omitempty"`
|
||||
Data *string `json:"data,omitempty"`
|
||||
Ttl *int32 `json:"ttl,omitempty"`
|
||||
Weight *int32 `json:"weight,omitempty"`
|
||||
Preference *int32 `json:"preference,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateRecordResponse struct {
|
||||
sdkResponseBase
|
||||
}
|
||||
|
||||
func (c *Client) UpdateRecord(req *UpdateRecordRequest) (*UpdateRecordResponse, error) {
|
||||
return c.UpdateRecordWithContext(context.Background(), req)
|
||||
}
|
||||
|
||||
func (c *Client) UpdateRecordWithContext(ctx context.Context, req *UpdateRecordRequest) (*UpdateRecordResponse, error) {
|
||||
httpreq, err := c.newRequest(http.MethodPut, "/record")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
httpreq.SetBody(req)
|
||||
httpreq.SetContext(ctx)
|
||||
}
|
||||
|
||||
result := &UpdateRecordResponse{}
|
||||
if _, err := c.doRequestWithResult(httpreq, result); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package dnsla
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
|
||||
"github.com/certimate-go/certimate/internal/app"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
client *resty.Client
|
||||
}
|
||||
|
||||
func NewClient(apiId, apiSecret string) (*Client, error) {
|
||||
if apiId == "" {
|
||||
return nil, fmt.Errorf("sdkerr: unset apiId")
|
||||
}
|
||||
if apiSecret == "" {
|
||||
return nil, fmt.Errorf("sdkerr: unset apiSecret")
|
||||
}
|
||||
|
||||
client := resty.New().
|
||||
SetBaseURL("https://api.dns.la/api").
|
||||
SetBasicAuth(apiId, apiSecret).
|
||||
SetHeader("Accept", "application/json").
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetHeader("User-Agent", app.AppUserAgent)
|
||||
|
||||
return &Client{client}, nil
|
||||
}
|
||||
|
||||
func (c *Client) SetTimeout(timeout time.Duration) *Client {
|
||||
c.client.SetTimeout(timeout)
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Client) newRequest(method string, path string) (*resty.Request, error) {
|
||||
if method == "" {
|
||||
return nil, fmt.Errorf("sdkerr: unset method")
|
||||
}
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("sdkerr: unset path")
|
||||
}
|
||||
|
||||
req := c.client.R()
|
||||
req.Method = method
|
||||
req.URL = path
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (c *Client) doRequest(req *resty.Request) (*resty.Response, error) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("sdkerr: nil request")
|
||||
}
|
||||
|
||||
// WARN:
|
||||
// PLEASE DO NOT USE `req.SetResult` or `req.SetError` HERE! USE `doRequestWithResult` INSTEAD.
|
||||
|
||||
resp, err := req.Send()
|
||||
if err != nil {
|
||||
return resp, fmt.Errorf("sdkerr: failed to send request: %w", err)
|
||||
} else if resp.IsError() {
|
||||
return resp, fmt.Errorf("sdkerr: unexpected status code: %d (resp: %s)", resp.StatusCode(), resp.String())
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) doRequestWithResult(req *resty.Request, res sdkResponse) (*resty.Response, error) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("sdkerr: nil request")
|
||||
}
|
||||
|
||||
resp, err := c.doRequest(req)
|
||||
if err != nil {
|
||||
if resp != nil {
|
||||
json.Unmarshal(resp.Body(), &res)
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
if len(resp.Body()) != 0 {
|
||||
if err := json.Unmarshal(resp.Body(), &res); err != nil {
|
||||
return resp, fmt.Errorf("sdkerr: failed to unmarshal response: %w (resp: %s)", err, resp.String())
|
||||
} else {
|
||||
if tcode := res.GetCode(); tcode/100 != 2 {
|
||||
return resp, fmt.Errorf("sdkerr: code='%d', message='%s'", tcode, res.GetMessage())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package dnsla
|
||||
|
||||
type sdkResponse interface {
|
||||
GetCode() int
|
||||
GetMessage() string
|
||||
}
|
||||
|
||||
type sdkResponseBase struct {
|
||||
Code *int `json:"code,omitempty"`
|
||||
Message *string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
func (r *sdkResponseBase) GetCode() int {
|
||||
if r.Code == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return *r.Code
|
||||
}
|
||||
|
||||
func (r *sdkResponseBase) GetMessage() string {
|
||||
if r.Message == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return *r.Message
|
||||
}
|
||||
|
||||
var _ sdkResponse = (*sdkResponseBase)(nil)
|
||||
|
||||
type DomainRecord struct {
|
||||
Id string `json:"id"`
|
||||
GroupId string `json:"groupId"`
|
||||
GroupName string `json:"groupName"`
|
||||
Domain string `json:"domain"`
|
||||
DisplayDomain string `json:"displayDomain"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type DnsRecord struct {
|
||||
Id string `json:"id"`
|
||||
DomainId string `json:"domainId"`
|
||||
GroupId string `json:"groupId"`
|
||||
GroupName string `json:"groupName"`
|
||||
LineId string `json:"lineId"`
|
||||
LineCode string `json:"lineCode"`
|
||||
LineName string `json:"lineName"`
|
||||
Type int32 `json:"type"`
|
||||
Host string `json:"host"`
|
||||
DisplayHost string `json:"displayHost"`
|
||||
Data string `json:"data"`
|
||||
DisplayData string `json:"displayData"`
|
||||
Ttl int32 `json:"ttl"`
|
||||
Weight int32 `json:"weight"`
|
||||
Preference int32 `json:"preference"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
Reference in New Issue
Block a user