mirror of
https://github.com/usual2970/certimate.git
synced 2025-06-28 07:09:58 +00:00
feat: download certificate archive
This commit is contained in:
parent
d28b89f03e
commit
d5e4ea385d
@ -1,6 +1,8 @@
|
|||||||
package certificate
|
package certificate
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strconv"
|
"strconv"
|
||||||
@ -9,6 +11,7 @@ import (
|
|||||||
"github.com/usual2970/certimate/internal/app"
|
"github.com/usual2970/certimate/internal/app"
|
||||||
"github.com/usual2970/certimate/internal/domain"
|
"github.com/usual2970/certimate/internal/domain"
|
||||||
"github.com/usual2970/certimate/internal/notify"
|
"github.com/usual2970/certimate/internal/notify"
|
||||||
|
"github.com/usual2970/certimate/internal/pkg/utils/certs"
|
||||||
"github.com/usual2970/certimate/internal/repository"
|
"github.com/usual2970/certimate/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -18,6 +21,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type certificateRepository interface {
|
type certificateRepository interface {
|
||||||
|
GetById(ctx context.Context, id string) (*domain.Certificate, error)
|
||||||
ListExpireSoon(ctx context.Context) ([]*domain.Certificate, error)
|
ListExpireSoon(ctx context.Context) ([]*domain.Certificate, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -51,6 +55,126 @@ func (s *CertificateService) InitSchedule(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *CertificateService) ArchiveFile(ctx context.Context, req *domain.CertificateArchiveFileReq) ([]byte, error) {
|
||||||
|
certificate, err := s.repo.GetById(ctx, req.CertificateId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
zipWriter := zip.NewWriter(&buf)
|
||||||
|
defer zipWriter.Close()
|
||||||
|
|
||||||
|
switch strings.ToUpper(req.Format) {
|
||||||
|
case "", "PEM":
|
||||||
|
{
|
||||||
|
certWriter, err := zipWriter.Create("certbundle.pem")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = certWriter.Write([]byte(certificate.Certificate))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
keyWriter, err := zipWriter.Create("privkey.pem")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = keyWriter.Write([]byte(certificate.PrivateKey))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = zipWriter.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
case "PFX":
|
||||||
|
{
|
||||||
|
const pfxPassword = "certimate"
|
||||||
|
|
||||||
|
certPFX, err := certs.TransformCertificateFromPEMToPFX(certificate.Certificate, certificate.PrivateKey, pfxPassword)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
certWriter, err := zipWriter.Create("cert.pfx")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = certWriter.Write(certPFX)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
keyWriter, err := zipWriter.Create("pfx-password.txt")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = keyWriter.Write([]byte(pfxPassword))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = zipWriter.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
case "JKS":
|
||||||
|
{
|
||||||
|
const jksPassword = "certimate"
|
||||||
|
|
||||||
|
certJKS, err := certs.TransformCertificateFromPEMToJKS(certificate.Certificate, certificate.PrivateKey, jksPassword, jksPassword, jksPassword)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
certWriter, err := zipWriter.Create("cert.jks")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = certWriter.Write(certJKS)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
keyWriter, err := zipWriter.Create("jks-password.txt")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = keyWriter.Write([]byte(jksPassword))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = zipWriter.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, domain.ErrInvalidParams
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func buildExpireSoonNotification(certificates []*domain.Certificate) *struct {
|
func buildExpireSoonNotification(certificates []*domain.Certificate) *struct {
|
||||||
Subject string
|
Subject string
|
||||||
Message string
|
Message string
|
||||||
|
@ -25,3 +25,8 @@ type Certificate struct {
|
|||||||
WorkflowOutputId string `json:"workflowOutputId" db:"workflowOutputId"`
|
WorkflowOutputId string `json:"workflowOutputId" db:"workflowOutputId"`
|
||||||
DeletedAt *time.Time `json:"deleted" db:"deleted"`
|
DeletedAt *time.Time `json:"deleted" db:"deleted"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CertificateArchiveFileReq struct {
|
||||||
|
CertificateId string `json:"-"`
|
||||||
|
Format string `json:"format"`
|
||||||
|
}
|
||||||
|
@ -152,6 +152,6 @@ type WorkflowNodeIOValueSelector struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type WorkflowRunReq struct {
|
type WorkflowRunReq struct {
|
||||||
WorkflowId string `json:"workflowId"`
|
WorkflowId string `json:"-"`
|
||||||
Trigger WorkflowTriggerType `json:"trigger"`
|
Trigger WorkflowTriggerType `json:"trigger"`
|
||||||
}
|
}
|
||||||
|
42
internal/rest/handlers/certificate.go
Normal file
42
internal/rest/handlers/certificate.go
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/router"
|
||||||
|
|
||||||
|
"github.com/usual2970/certimate/internal/domain"
|
||||||
|
"github.com/usual2970/certimate/internal/rest/resp"
|
||||||
|
)
|
||||||
|
|
||||||
|
type certificateService interface {
|
||||||
|
ArchiveFile(ctx context.Context, req *domain.CertificateArchiveFileReq) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type CertificateHandler struct {
|
||||||
|
service certificateService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCertificateHandler(router *router.RouterGroup[*core.RequestEvent], service certificateService) {
|
||||||
|
handler := &CertificateHandler{
|
||||||
|
service: service,
|
||||||
|
}
|
||||||
|
|
||||||
|
group := router.Group("/certificates")
|
||||||
|
group.POST("/{id}/archive", handler.run)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *CertificateHandler) run(e *core.RequestEvent) error {
|
||||||
|
req := &domain.CertificateArchiveFileReq{}
|
||||||
|
req.CertificateId = e.Request.PathValue("id")
|
||||||
|
if err := e.BindBody(req); err != nil {
|
||||||
|
return resp.Err(e, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if bt, err := handler.service.ArchiveFile(e.Request.Context(), req); err != nil {
|
||||||
|
return resp.Err(e, err)
|
||||||
|
} else {
|
||||||
|
return resp.Ok(e, bt)
|
||||||
|
}
|
||||||
|
}
|
@ -24,12 +24,13 @@ func NewWorkflowHandler(router *router.RouterGroup[*core.RequestEvent], service
|
|||||||
service: service,
|
service: service,
|
||||||
}
|
}
|
||||||
|
|
||||||
group := router.Group("/workflow")
|
group := router.Group("/workflows")
|
||||||
group.POST("/run", handler.run)
|
group.POST("/{id}/run", handler.run)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler *WorkflowHandler) run(e *core.RequestEvent) error {
|
func (handler *WorkflowHandler) run(e *core.RequestEvent) error {
|
||||||
req := &domain.WorkflowRunReq{}
|
req := &domain.WorkflowRunReq{}
|
||||||
|
req.WorkflowId = e.Request.PathValue("id")
|
||||||
if err := e.BindBody(req); err != nil {
|
if err := e.BindBody(req); err != nil {
|
||||||
return resp.Err(e, err)
|
return resp.Err(e, err)
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,7 @@ import (
|
|||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/router"
|
"github.com/pocketbase/pocketbase/tools/router"
|
||||||
|
|
||||||
|
"github.com/usual2970/certimate/internal/certificate"
|
||||||
"github.com/usual2970/certimate/internal/notify"
|
"github.com/usual2970/certimate/internal/notify"
|
||||||
"github.com/usual2970/certimate/internal/repository"
|
"github.com/usual2970/certimate/internal/repository"
|
||||||
"github.com/usual2970/certimate/internal/rest/handlers"
|
"github.com/usual2970/certimate/internal/rest/handlers"
|
||||||
@ -15,14 +16,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
notifySvc *notify.NotifyService
|
certificateSvc *certificate.CertificateService
|
||||||
workflowSvc *workflow.WorkflowService
|
workflowSvc *workflow.WorkflowService
|
||||||
statisticsSvc *statistics.StatisticsService
|
statisticsSvc *statistics.StatisticsService
|
||||||
|
notifySvc *notify.NotifyService
|
||||||
)
|
)
|
||||||
|
|
||||||
func Register(router *router.Router[*core.RequestEvent]) {
|
func Register(router *router.Router[*core.RequestEvent]) {
|
||||||
notifyRepo := repository.NewSettingsRepository()
|
certificateRepo := repository.NewCertificateRepository()
|
||||||
notifySvc = notify.NewNotifyService(notifyRepo)
|
certificateSvc = certificate.NewCertificateService(certificateRepo)
|
||||||
|
|
||||||
workflowRepo := repository.NewWorkflowRepository()
|
workflowRepo := repository.NewWorkflowRepository()
|
||||||
workflowSvc = workflow.NewWorkflowService(workflowRepo)
|
workflowSvc = workflow.NewWorkflowService(workflowRepo)
|
||||||
@ -30,11 +32,15 @@ func Register(router *router.Router[*core.RequestEvent]) {
|
|||||||
statisticsRepo := repository.NewStatisticsRepository()
|
statisticsRepo := repository.NewStatisticsRepository()
|
||||||
statisticsSvc = statistics.NewStatisticsService(statisticsRepo)
|
statisticsSvc = statistics.NewStatisticsService(statisticsRepo)
|
||||||
|
|
||||||
|
notifyRepo := repository.NewSettingsRepository()
|
||||||
|
notifySvc = notify.NewNotifyService(notifyRepo)
|
||||||
|
|
||||||
group := router.Group("/api")
|
group := router.Group("/api")
|
||||||
group.Bind(apis.RequireSuperuserAuth())
|
group.Bind(apis.RequireSuperuserAuth())
|
||||||
|
handlers.NewCertificateHandler(group, certificateSvc)
|
||||||
handlers.NewWorkflowHandler(group, workflowSvc)
|
handlers.NewWorkflowHandler(group, workflowSvc)
|
||||||
handlers.NewNotifyHandler(group, notifySvc)
|
|
||||||
handlers.NewStatisticsHandler(group, statisticsSvc)
|
handlers.NewStatisticsHandler(group, statisticsSvc)
|
||||||
|
handlers.NewNotifyHandler(group, notifySvc)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Unregister() {
|
func Unregister() {
|
||||||
|
96
ui/package-lock.json
generated
96
ui/package-lock.json
generated
@ -14,10 +14,10 @@
|
|||||||
"antd": "^5.22.7",
|
"antd": "^5.22.7",
|
||||||
"antd-zod": "^6.0.0",
|
"antd-zod": "^6.0.0",
|
||||||
"cron-parser": "^4.9.0",
|
"cron-parser": "^4.9.0",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
"i18next": "^24.2.0",
|
"i18next": "^24.2.0",
|
||||||
"i18next-browser-languagedetector": "^8.0.2",
|
"i18next-browser-languagedetector": "^8.0.2",
|
||||||
"immer": "^10.1.1",
|
"immer": "^10.1.1",
|
||||||
"jszip": "^3.10.1",
|
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"nanoid": "^5.0.9",
|
"nanoid": "^5.0.9",
|
||||||
"pocketbase": "^0.25.0",
|
"pocketbase": "^0.25.0",
|
||||||
@ -31,6 +31,7 @@
|
|||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@types/fs-extra": "^11.0.4",
|
||||||
"@types/node": "^22.10.3",
|
"@types/node": "^22.10.3",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
@ -3188,6 +3189,12 @@
|
|||||||
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
|
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/file-saver": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@types/file-saver/-/file-saver-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/@types/fs-extra": {
|
"node_modules/@types/fs-extra": {
|
||||||
"version": "11.0.4",
|
"version": "11.0.4",
|
||||||
"resolved": "https://registry.npmmirror.com/@types/fs-extra/-/fs-extra-11.0.4.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/fs-extra/-/fs-extra-11.0.4.tgz",
|
||||||
@ -4193,11 +4200,6 @@
|
|||||||
"url": "https://opencollective.com/core-js"
|
"url": "https://opencollective.com/core-js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/core-util-is": {
|
|
||||||
"version": "1.0.3",
|
|
||||||
"resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz",
|
|
||||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
|
|
||||||
},
|
|
||||||
"node_modules/cron-parser": {
|
"node_modules/cron-parser": {
|
||||||
"version": "4.9.0",
|
"version": "4.9.0",
|
||||||
"resolved": "https://registry.npmmirror.com/cron-parser/-/cron-parser-4.9.0.tgz",
|
"resolved": "https://registry.npmmirror.com/cron-parser/-/cron-parser-4.9.0.tgz",
|
||||||
@ -5183,6 +5185,11 @@
|
|||||||
"node": "^10.12.0 || >=12.0.0"
|
"node": "^10.12.0 || >=12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/file-saver": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmmirror.com/file-saver/-/file-saver-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="
|
||||||
|
},
|
||||||
"node_modules/fill-range": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
@ -5640,11 +5647,6 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/immediate": {
|
|
||||||
"version": "3.0.6",
|
|
||||||
"resolved": "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz",
|
|
||||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
|
|
||||||
},
|
|
||||||
"node_modules/immer": {
|
"node_modules/immer": {
|
||||||
"version": "10.1.1",
|
"version": "10.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/immer/-/immer-10.1.1.tgz",
|
"resolved": "https://registry.npmmirror.com/immer/-/immer-10.1.1.tgz",
|
||||||
@ -5693,7 +5695,8 @@
|
|||||||
"node_modules/inherits": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/internal-slot": {
|
"node_modules/internal-slot": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
@ -6100,11 +6103,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/isarray": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
|
|
||||||
},
|
|
||||||
"node_modules/isexe": {
|
"node_modules/isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz",
|
||||||
@ -6222,17 +6220,6 @@
|
|||||||
"graceful-fs": "^4.1.6"
|
"graceful-fs": "^4.1.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jszip": {
|
|
||||||
"version": "3.10.1",
|
|
||||||
"resolved": "https://registry.npmmirror.com/jszip/-/jszip-3.10.1.tgz",
|
|
||||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
|
||||||
"dependencies": {
|
|
||||||
"lie": "~3.3.0",
|
|
||||||
"pako": "~1.0.2",
|
|
||||||
"readable-stream": "~2.3.6",
|
|
||||||
"setimmediate": "^1.0.5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz",
|
||||||
@ -6255,14 +6242,6 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lie": {
|
|
||||||
"version": "3.3.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz",
|
|
||||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
|
||||||
"dependencies": {
|
|
||||||
"immediate": "~3.0.5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lilconfig": {
|
"node_modules/lilconfig": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz",
|
"resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||||
@ -6694,11 +6673,6 @@
|
|||||||
"integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==",
|
"integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/pako": {
|
|
||||||
"version": "1.0.11",
|
|
||||||
"resolved": "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz",
|
|
||||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
|
|
||||||
},
|
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@ -7021,11 +6995,6 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/process-nextick-args": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
|
|
||||||
},
|
|
||||||
"node_modules/prop-types": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmmirror.com/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmmirror.com/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
@ -7784,20 +7753,6 @@
|
|||||||
"pify": "^2.3.0"
|
"pify": "^2.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/readable-stream": {
|
|
||||||
"version": "2.3.8",
|
|
||||||
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz",
|
|
||||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
|
||||||
"dependencies": {
|
|
||||||
"core-util-is": "~1.0.0",
|
|
||||||
"inherits": "~2.0.3",
|
|
||||||
"isarray": "~1.0.0",
|
|
||||||
"process-nextick-args": "~2.0.0",
|
|
||||||
"safe-buffer": "~5.1.1",
|
|
||||||
"string_decoder": "~1.1.1",
|
|
||||||
"util-deprecate": "~1.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz",
|
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz",
|
||||||
@ -8081,11 +8036,6 @@
|
|||||||
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
|
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/safe-buffer": {
|
|
||||||
"version": "5.1.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
|
||||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
|
|
||||||
},
|
|
||||||
"node_modules/safe-push-apply": {
|
"node_modules/safe-push-apply": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
||||||
@ -8209,11 +8159,6 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/setimmediate": {
|
|
||||||
"version": "1.0.5",
|
|
||||||
"resolved": "https://registry.npmmirror.com/setimmediate/-/setimmediate-1.0.5.tgz",
|
|
||||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
|
|
||||||
},
|
|
||||||
"node_modules/shallowequal": {
|
"node_modules/shallowequal": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/shallowequal/-/shallowequal-1.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/shallowequal/-/shallowequal-1.1.0.tgz",
|
||||||
@ -8360,14 +8305,6 @@
|
|||||||
"integrity": "sha512-LjdcbuBeLcdETCrPn9i8AYAZ1eCtu4ECAWtP7UleOiZ9LzVxRzzUZEoZ8zB24nhkQnDWyET0I+3sWokSDS3E7g==",
|
"integrity": "sha512-LjdcbuBeLcdETCrPn9i8AYAZ1eCtu4ECAWtP7UleOiZ9LzVxRzzUZEoZ8zB24nhkQnDWyET0I+3sWokSDS3E7g==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/string_decoder": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
|
||||||
"dependencies": {
|
|
||||||
"safe-buffer": "~5.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/string-convert": {
|
"node_modules/string-convert": {
|
||||||
"version": "0.2.1",
|
"version": "0.2.1",
|
||||||
"resolved": "https://registry.npmmirror.com/string-convert/-/string-convert-0.2.1.tgz",
|
"resolved": "https://registry.npmmirror.com/string-convert/-/string-convert-0.2.1.tgz",
|
||||||
@ -9057,7 +8994,8 @@
|
|||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.0.6",
|
"version": "6.0.6",
|
||||||
|
@ -16,10 +16,10 @@
|
|||||||
"antd": "^5.22.7",
|
"antd": "^5.22.7",
|
||||||
"antd-zod": "^6.0.0",
|
"antd-zod": "^6.0.0",
|
||||||
"cron-parser": "^4.9.0",
|
"cron-parser": "^4.9.0",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
"i18next": "^24.2.0",
|
"i18next": "^24.2.0",
|
||||||
"i18next-browser-languagedetector": "^8.0.2",
|
"i18next-browser-languagedetector": "^8.0.2",
|
||||||
"immer": "^10.1.1",
|
"immer": "^10.1.1",
|
||||||
"jszip": "^3.10.1",
|
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"nanoid": "^5.0.9",
|
"nanoid": "^5.0.9",
|
||||||
"pocketbase": "^0.25.0",
|
"pocketbase": "^0.25.0",
|
||||||
@ -33,6 +33,7 @@
|
|||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@types/fs-extra": "^11.0.4",
|
||||||
"@types/node": "^22.10.3",
|
"@types/node": "^22.10.3",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
|
24
ui/src/api/certificates.ts
Normal file
24
ui/src/api/certificates.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { ClientResponseError } from "pocketbase";
|
||||||
|
|
||||||
|
import { type CertificateFormatType } from "@/domain/certificate";
|
||||||
|
import { getPocketBase } from "@/repository/_pocketbase";
|
||||||
|
|
||||||
|
export const archive = async (id: string, format?: CertificateFormatType) => {
|
||||||
|
const pb = getPocketBase();
|
||||||
|
|
||||||
|
const resp = await pb.send<BaseResponse>(`/api/certificates/${encodeURIComponent(id)}/archive`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
format: format,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.code != 0) {
|
||||||
|
throw new ClientResponseError({ status: resp.code, response: resp, data: {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp;
|
||||||
|
};
|
@ -6,13 +6,12 @@ import { getPocketBase } from "@/repository/_pocketbase";
|
|||||||
export const run = async (id: string) => {
|
export const run = async (id: string) => {
|
||||||
const pb = getPocketBase();
|
const pb = getPocketBase();
|
||||||
|
|
||||||
const resp = await pb.send<BaseResponse>("/api/workflow/run", {
|
const resp = await pb.send<BaseResponse>(`/api/workflows/${encodeURIComponent(id)}/run`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: {
|
body: {
|
||||||
workflowId: id,
|
|
||||||
trigger: WORKFLOW_TRIGGERS.MANUAL,
|
trigger: WORKFLOW_TRIGGERS.MANUAL,
|
||||||
},
|
},
|
||||||
});
|
});
|
@ -3,9 +3,10 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { CopyOutlined as CopyOutlinedIcon, DownOutlined as DownOutlinedIcon, LikeOutlined as LikeOutlinedIcon } from "@ant-design/icons";
|
import { CopyOutlined as CopyOutlinedIcon, DownOutlined as DownOutlinedIcon, LikeOutlined as LikeOutlinedIcon } from "@ant-design/icons";
|
||||||
import { Button, Dropdown, Form, Input, Space, Tooltip, message } from "antd";
|
import { Button, Dropdown, Form, Input, Space, Tooltip, message } from "antd";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
import { saveAs } from "file-saver";
|
||||||
|
|
||||||
import { type CertificateModel } from "@/domain/certificate";
|
import { archive as archiveCertificate } from "@/api/certificates";
|
||||||
import { saveFiles2Zip } from "@/utils/file";
|
import { CERTIFICATE_FORMATS, type CertificateFormatType, type CertificateModel } from "@/domain/certificate";
|
||||||
|
|
||||||
export type CertificateDetailProps = {
|
export type CertificateDetailProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -18,20 +19,17 @@ const CertificateDetail = ({ data, ...props }: CertificateDetailProps) => {
|
|||||||
|
|
||||||
const [messageApi, MessageContextHolder] = message.useMessage();
|
const [messageApi, MessageContextHolder] = message.useMessage();
|
||||||
|
|
||||||
const handleDownloadPEMClick = async () => {
|
const handleDownloadClick = async (format: CertificateFormatType) => {
|
||||||
const zipName = `${data.id}-${data.subjectAltNames}.zip`;
|
try {
|
||||||
const files = [
|
const res = await archiveCertificate(data.id, format);
|
||||||
{
|
const bstr = atob(res.data);
|
||||||
name: `${data.subjectAltNames}.pem`,
|
const u8arr = Uint8Array.from(bstr, (ch) => ch.charCodeAt(0));
|
||||||
content: data.certificate ?? "",
|
const blob = new Blob([u8arr], { type: "application/zip" });
|
||||||
},
|
saveAs(blob, `${data.id}-${data.subjectAltNames}.zip`);
|
||||||
{
|
} catch (err) {
|
||||||
name: `${data.subjectAltNames}.key`,
|
console.log(err);
|
||||||
content: data.privateKey ?? "",
|
messageApi.warning(t("common.text.operation_failed"));
|
||||||
},
|
}
|
||||||
];
|
|
||||||
|
|
||||||
await saveFiles2Zip(zipName, files);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -90,21 +88,17 @@ const CertificateDetail = ({ data, ...props }: CertificateDetailProps) => {
|
|||||||
key: "PEM",
|
key: "PEM",
|
||||||
label: "PEM",
|
label: "PEM",
|
||||||
extra: <LikeOutlinedIcon />,
|
extra: <LikeOutlinedIcon />,
|
||||||
onClick: () => handleDownloadPEMClick(),
|
onClick: () => handleDownloadClick(CERTIFICATE_FORMATS.PEM),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "PFX",
|
key: "PFX",
|
||||||
label: "PFX",
|
label: "PFX",
|
||||||
onClick: () => {
|
onClick: () => handleDownloadClick(CERTIFICATE_FORMATS.PFX),
|
||||||
alert("TODO: 暂时不支持下载 PFX 证书");
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "JKS",
|
key: "JKS",
|
||||||
label: "JKS",
|
label: "JKS",
|
||||||
onClick: () => {
|
onClick: () => handleDownloadClick(CERTIFICATE_FORMATS.JKS),
|
||||||
alert("TODO: 暂时不支持下载 JKS 证书");
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
|
@ -5,6 +5,7 @@ import { createSchemaFieldRule } from "antd-zod";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import Show from "@/components/Show";
|
import Show from "@/components/Show";
|
||||||
|
import { CERTIFICATE_FORMATS } from "@/domain/certificate";
|
||||||
|
|
||||||
type DeployNodeConfigFormLocalConfigFieldValues = Nullish<{
|
type DeployNodeConfigFormLocalConfigFieldValues = Nullish<{
|
||||||
format: string;
|
format: string;
|
||||||
@ -27,9 +28,9 @@ export type DeployNodeConfigFormLocalConfigProps = {
|
|||||||
onValuesChange?: (values: DeployNodeConfigFormLocalConfigFieldValues) => void;
|
onValuesChange?: (values: DeployNodeConfigFormLocalConfigFieldValues) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FORMAT_PEM = "PEM" as const;
|
const FORMAT_PEM = CERTIFICATE_FORMATS.PEM;
|
||||||
const FORMAT_PFX = "PFX" as const;
|
const FORMAT_PFX = CERTIFICATE_FORMATS.PFX;
|
||||||
const FORMAT_JKS = "JKS" as const;
|
const FORMAT_JKS = CERTIFICATE_FORMATS.JKS;
|
||||||
|
|
||||||
const SHELLENV_SH = "sh" as const;
|
const SHELLENV_SH = "sh" as const;
|
||||||
const SHELLENV_CMD = "cmd" as const;
|
const SHELLENV_CMD = "cmd" as const;
|
||||||
|
@ -5,6 +5,7 @@ import { createSchemaFieldRule } from "antd-zod";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import Show from "@/components/Show";
|
import Show from "@/components/Show";
|
||||||
|
import { CERTIFICATE_FORMATS } from "@/domain/certificate";
|
||||||
|
|
||||||
type DeployNodeConfigFormSSHConfigFieldValues = Nullish<{
|
type DeployNodeConfigFormSSHConfigFieldValues = Nullish<{
|
||||||
format: string;
|
format: string;
|
||||||
@ -26,9 +27,9 @@ export type DeployNodeConfigFormSSHConfigProps = {
|
|||||||
onValuesChange?: (values: DeployNodeConfigFormSSHConfigFieldValues) => void;
|
onValuesChange?: (values: DeployNodeConfigFormSSHConfigFieldValues) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FORMAT_PEM = "PEM" as const;
|
const FORMAT_PEM = CERTIFICATE_FORMATS.PEM;
|
||||||
const FORMAT_PFX = "PFX" as const;
|
const FORMAT_PFX = CERTIFICATE_FORMATS.PFX;
|
||||||
const FORMAT_JKS = "JKS" as const;
|
const FORMAT_JKS = CERTIFICATE_FORMATS.JKS;
|
||||||
|
|
||||||
const initFormModel = (): DeployNodeConfigFormSSHConfigFieldValues => {
|
const initFormModel = (): DeployNodeConfigFormSSHConfigFieldValues => {
|
||||||
return {
|
return {
|
||||||
|
@ -19,3 +19,11 @@ export const CERTIFICATE_SOURCES = Object.freeze({
|
|||||||
} as const);
|
} as const);
|
||||||
|
|
||||||
export type CertificateSourceType = (typeof CERTIFICATE_SOURCES)[keyof typeof CERTIFICATE_SOURCES];
|
export type CertificateSourceType = (typeof CERTIFICATE_SOURCES)[keyof typeof CERTIFICATE_SOURCES];
|
||||||
|
|
||||||
|
export const CERTIFICATE_FORMATS = Object.freeze({
|
||||||
|
PEM: "PEM",
|
||||||
|
PFX: "PFX",
|
||||||
|
JKS: "JKS",
|
||||||
|
} as const);
|
||||||
|
|
||||||
|
export type CertificateFormatType = (typeof CERTIFICATE_FORMATS)[keyof typeof CERTIFICATE_FORMATS];
|
||||||
|
@ -16,7 +16,7 @@ import { createSchemaFieldRule } from "antd-zod";
|
|||||||
import { isEqual } from "radash";
|
import { isEqual } from "radash";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { run as runWorkflow } from "@/api/workflow";
|
import { run as runWorkflow } from "@/api/workflows";
|
||||||
import ModalForm from "@/components/ModalForm";
|
import ModalForm from "@/components/ModalForm";
|
||||||
import Show from "@/components/Show";
|
import Show from "@/components/Show";
|
||||||
import WorkflowElements from "@/components/workflow/WorkflowElements";
|
import WorkflowElements from "@/components/workflow/WorkflowElements";
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
import JSZip from "jszip";
|
|
||||||
|
|
||||||
export function readFileContent(file: File): Promise<string> {
|
export function readFileContent(file: File): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
@ -17,19 +15,3 @@ export function readFileContent(file: File): Promise<string> {
|
|||||||
reader.readAsText(file, "utf-8");
|
reader.readAsText(file, "utf-8");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const saveFiles2Zip = async (zipName: string, files: { name: string; content: string }[]) => {
|
|
||||||
const zip = new JSZip();
|
|
||||||
|
|
||||||
files.forEach((file) => {
|
|
||||||
zip.file(file.name, file.content);
|
|
||||||
});
|
|
||||||
|
|
||||||
const content = await zip.generateAsync({ type: "blob" });
|
|
||||||
|
|
||||||
// Save the zip file to the local system
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = URL.createObjectURL(content);
|
|
||||||
link.download = zipName;
|
|
||||||
link.click();
|
|
||||||
};
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user