feat: static sites feature with Gitea/GitHub/GitLab support and Deno backend
Deploy static content from Git repository folders with optional server-side
API endpoints. Supports Gitea/Forgejo/Gogs, GitHub, and GitLab with provider
autodetection.
- New Sites entity with CRUD, encrypted secrets, and manual/push/tag sync triggers
- Pluggable GitProvider interface with three implementations
- Deno container mode: auto-generates router from API_{method}_{name} exports
- Static container mode: nginx serving files with optional markdown rendering
- Wizard UI with provider selector, repo picker, branch/folder tree pickers
- Deploy pipeline builds fresh image, starts container, configures NPM proxy
- Stop/Start buttons, force redeploy on manual trigger
- Periodic health checker detects crashed containers
- Proxy route existence check during auto-sync
This commit is contained in:
@@ -0,0 +1,613 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/crypto"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
)
|
||||
|
||||
// ── List / Get ─────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Server) listStaticSites(w http.ResponseWriter, r *http.Request) {
|
||||
sites, err := s.store.GetAllStaticSites()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list static sites")
|
||||
return
|
||||
}
|
||||
|
||||
// Mask access tokens in response.
|
||||
for i := range sites {
|
||||
sites[i].AccessToken = maskToken(sites[i].AccessToken)
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, sites)
|
||||
}
|
||||
|
||||
func (s *Server) getStaticSite(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
site, err := s.store.GetStaticSiteByID(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "static site")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to get static site")
|
||||
return
|
||||
}
|
||||
|
||||
site.AccessToken = maskToken(site.AccessToken)
|
||||
respondJSON(w, http.StatusOK, site)
|
||||
}
|
||||
|
||||
// ── Create ──────────────────────────────────────────────────────────
|
||||
|
||||
type createStaticSiteRequest struct {
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
GiteaURL string `json:"gitea_url"`
|
||||
RepoOwner string `json:"repo_owner"`
|
||||
RepoName string `json:"repo_name"`
|
||||
Branch string `json:"branch"`
|
||||
FolderPath string `json:"folder_path"`
|
||||
AccessToken string `json:"access_token"`
|
||||
Domain string `json:"domain"`
|
||||
Mode string `json:"mode"`
|
||||
RenderMarkdown bool `json:"render_markdown"`
|
||||
SyncTrigger string `json:"sync_trigger"`
|
||||
TagPattern string `json:"tag_pattern"`
|
||||
}
|
||||
|
||||
func (s *Server) createStaticSite(w http.ResponseWriter, r *http.Request) {
|
||||
var req createStaticSiteRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name == "" || req.GiteaURL == "" || req.RepoOwner == "" || req.RepoName == "" {
|
||||
respondError(w, http.StatusBadRequest, "name, gitea_url, repo_owner, and repo_name are required")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Branch == "" {
|
||||
req.Branch = "main"
|
||||
}
|
||||
if req.Mode == "" {
|
||||
req.Mode = "static"
|
||||
}
|
||||
if req.SyncTrigger == "" {
|
||||
req.SyncTrigger = "manual"
|
||||
}
|
||||
|
||||
// Encrypt access token if provided.
|
||||
encryptedToken := ""
|
||||
if req.AccessToken != "" {
|
||||
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to encrypt access token")
|
||||
return
|
||||
}
|
||||
encryptedToken = encrypted
|
||||
}
|
||||
|
||||
site := store.StaticSite{
|
||||
Name: req.Name,
|
||||
Provider: req.Provider,
|
||||
GiteaURL: strings.TrimRight(req.GiteaURL, "/"),
|
||||
RepoOwner: req.RepoOwner,
|
||||
RepoName: req.RepoName,
|
||||
Branch: req.Branch,
|
||||
FolderPath: req.FolderPath,
|
||||
AccessToken: encryptedToken,
|
||||
Domain: req.Domain,
|
||||
Mode: req.Mode,
|
||||
RenderMarkdown: req.RenderMarkdown,
|
||||
SyncTrigger: req.SyncTrigger,
|
||||
TagPattern: req.TagPattern,
|
||||
Status: "idle",
|
||||
}
|
||||
|
||||
created, err := s.store.CreateStaticSite(site)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to create static site: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
created.AccessToken = maskToken(created.AccessToken)
|
||||
respondJSON(w, http.StatusCreated, created)
|
||||
}
|
||||
|
||||
// ── Update ──────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Server) updateStaticSite(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
existing, err := s.store.GetStaticSiteByID(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "static site")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to get static site")
|
||||
return
|
||||
}
|
||||
|
||||
var req createStaticSiteRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
// Update fields.
|
||||
if req.Name != "" {
|
||||
existing.Name = req.Name
|
||||
}
|
||||
if req.Provider != "" {
|
||||
existing.Provider = req.Provider
|
||||
}
|
||||
if req.GiteaURL != "" {
|
||||
existing.GiteaURL = strings.TrimRight(req.GiteaURL, "/")
|
||||
}
|
||||
if req.RepoOwner != "" {
|
||||
existing.RepoOwner = req.RepoOwner
|
||||
}
|
||||
if req.RepoName != "" {
|
||||
existing.RepoName = req.RepoName
|
||||
}
|
||||
if req.Branch != "" {
|
||||
existing.Branch = req.Branch
|
||||
}
|
||||
if req.FolderPath != "" {
|
||||
existing.FolderPath = req.FolderPath
|
||||
}
|
||||
if req.Domain != "" {
|
||||
existing.Domain = req.Domain
|
||||
}
|
||||
if req.Mode != "" {
|
||||
existing.Mode = req.Mode
|
||||
}
|
||||
if req.SyncTrigger != "" {
|
||||
existing.SyncTrigger = req.SyncTrigger
|
||||
}
|
||||
existing.RenderMarkdown = req.RenderMarkdown
|
||||
existing.TagPattern = req.TagPattern
|
||||
|
||||
// Update access token only if a new one is provided.
|
||||
if req.AccessToken != "" {
|
||||
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to encrypt access token")
|
||||
return
|
||||
}
|
||||
existing.AccessToken = encrypted
|
||||
}
|
||||
|
||||
if err := s.store.UpdateStaticSite(existing); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update static site: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
existing.AccessToken = maskToken(existing.AccessToken)
|
||||
respondJSON(w, http.StatusOK, existing)
|
||||
}
|
||||
|
||||
// ── Delete ──────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Server) deleteStaticSite(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
// Remove container and proxy route first.
|
||||
if s.staticSiteManager != nil {
|
||||
if err := s.staticSiteManager.Remove(r.Context(), id); err != nil {
|
||||
// Log but don't fail — still delete the DB record.
|
||||
respondError(w, http.StatusInternalServerError, "failed to remove site resources: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.store.DeleteStaticSite(id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "static site")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete static site")
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]string{"deleted": id})
|
||||
}
|
||||
|
||||
// ── Deploy ──────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Server) deployStaticSite(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
if s.staticSiteManager == nil {
|
||||
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
// Trigger deploy asynchronously with a detached context
|
||||
// (the HTTP request context is canceled when the response is sent).
|
||||
// Manual deploys always force a full rebuild + proxy regeneration.
|
||||
go func() {
|
||||
ctx := context.Background()
|
||||
if err := s.staticSiteManager.Deploy(ctx, id, true); err != nil {
|
||||
// Error is already stored in the site status.
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
respondJSON(w, http.StatusAccepted, map[string]string{"status": "deploying"})
|
||||
}
|
||||
|
||||
// ── Stop / Start ────────────────────────────────────────────────────
|
||||
|
||||
func (s *Server) stopStaticSite(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
if s.staticSiteManager == nil {
|
||||
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
ctx := context.Background()
|
||||
_ = s.staticSiteManager.Stop(ctx, id)
|
||||
}()
|
||||
|
||||
respondJSON(w, http.StatusAccepted, map[string]string{"status": "stopping"})
|
||||
}
|
||||
|
||||
func (s *Server) startStaticSite(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
if s.staticSiteManager == nil {
|
||||
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
ctx := context.Background()
|
||||
_ = s.staticSiteManager.Start(ctx, id)
|
||||
}()
|
||||
|
||||
respondJSON(w, http.StatusAccepted, map[string]string{"status": "starting"})
|
||||
}
|
||||
|
||||
// ── Test Connection ─────────────────────────────────────────────────
|
||||
|
||||
type testConnectionRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
GiteaURL string `json:"gitea_url"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RepoOwner string `json:"repo_owner"`
|
||||
RepoName string `json:"repo_name"`
|
||||
}
|
||||
|
||||
func (s *Server) testStaticSiteConnection(w http.ResponseWriter, r *http.Request) {
|
||||
var req testConnectionRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.GiteaURL == "" || req.RepoOwner == "" || req.RepoName == "" {
|
||||
respondError(w, http.StatusBadRequest, "gitea_url, repo_owner, and repo_name are required")
|
||||
return
|
||||
}
|
||||
|
||||
// Encrypt token for the manager to decrypt (consistent handling).
|
||||
encToken := ""
|
||||
if req.AccessToken != "" {
|
||||
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to process token")
|
||||
return
|
||||
}
|
||||
encToken = encrypted
|
||||
}
|
||||
|
||||
if s.staticSiteManager == nil {
|
||||
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.staticSiteManager.TestConnection(r.Context(), req.Provider, req.GiteaURL, encToken, req.RepoOwner, req.RepoName); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "connection failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]string{"status": "connected"})
|
||||
}
|
||||
|
||||
// ── Branches ────────────────────────────────────────────────────────
|
||||
|
||||
type listBranchesRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
GiteaURL string `json:"gitea_url"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RepoOwner string `json:"repo_owner"`
|
||||
RepoName string `json:"repo_name"`
|
||||
}
|
||||
|
||||
func (s *Server) listStaticSiteBranches(w http.ResponseWriter, r *http.Request) {
|
||||
var req listBranchesRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
encToken := ""
|
||||
if req.AccessToken != "" {
|
||||
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to process token")
|
||||
return
|
||||
}
|
||||
encToken = encrypted
|
||||
}
|
||||
|
||||
if s.staticSiteManager == nil {
|
||||
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
branches, err := s.staticSiteManager.ListBranches(r.Context(), req.Provider, req.GiteaURL, encToken, req.RepoOwner, req.RepoName)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "failed to list branches: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, branches)
|
||||
}
|
||||
|
||||
// ── Tree ────────────────────────────────────────────────────────────
|
||||
|
||||
type listTreeRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
GiteaURL string `json:"gitea_url"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RepoOwner string `json:"repo_owner"`
|
||||
RepoName string `json:"repo_name"`
|
||||
Branch string `json:"branch"`
|
||||
}
|
||||
|
||||
func (s *Server) listStaticSiteTree(w http.ResponseWriter, r *http.Request) {
|
||||
var req listTreeRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
encToken := ""
|
||||
if req.AccessToken != "" {
|
||||
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to process token")
|
||||
return
|
||||
}
|
||||
encToken = encrypted
|
||||
}
|
||||
|
||||
if s.staticSiteManager == nil {
|
||||
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := s.staticSiteManager.ListTree(r.Context(), req.Provider, req.GiteaURL, encToken, req.RepoOwner, req.RepoName, req.Branch)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "failed to list tree: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, entries)
|
||||
}
|
||||
|
||||
// ── Secrets ─────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Server) listStaticSiteSecrets(w http.ResponseWriter, r *http.Request) {
|
||||
siteID := chi.URLParam(r, "id")
|
||||
secrets, err := s.store.GetStaticSiteSecretsBySiteID(siteID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list secrets")
|
||||
return
|
||||
}
|
||||
|
||||
// Mask encrypted values.
|
||||
for i := range secrets {
|
||||
if secrets[i].Encrypted {
|
||||
secrets[i].Value = "••••••••"
|
||||
}
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, secrets)
|
||||
}
|
||||
|
||||
type createSecretRequest struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
Encrypted bool `json:"encrypted"`
|
||||
}
|
||||
|
||||
func (s *Server) createStaticSiteSecret(w http.ResponseWriter, r *http.Request) {
|
||||
siteID := chi.URLParam(r, "id")
|
||||
|
||||
var req createSecretRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Key == "" {
|
||||
respondError(w, http.StatusBadRequest, "key is required")
|
||||
return
|
||||
}
|
||||
|
||||
value := req.Value
|
||||
if req.Encrypted && value != "" {
|
||||
encrypted, err := crypto.Encrypt(s.encKey, value)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to encrypt secret")
|
||||
return
|
||||
}
|
||||
value = encrypted
|
||||
}
|
||||
|
||||
secret := store.StaticSiteSecret{
|
||||
SiteID: siteID,
|
||||
Key: req.Key,
|
||||
Value: value,
|
||||
Encrypted: req.Encrypted,
|
||||
}
|
||||
|
||||
created, err := s.store.CreateStaticSiteSecret(secret)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to create secret: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if created.Encrypted {
|
||||
created.Value = "••••••••"
|
||||
}
|
||||
respondJSON(w, http.StatusCreated, created)
|
||||
}
|
||||
|
||||
func (s *Server) updateStaticSiteSecret(w http.ResponseWriter, r *http.Request) {
|
||||
secretID := chi.URLParam(r, "sid")
|
||||
|
||||
existing, err := s.store.GetStaticSiteSecretByID(secretID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "secret")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to get secret")
|
||||
return
|
||||
}
|
||||
|
||||
var req createSecretRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Key != "" {
|
||||
existing.Key = req.Key
|
||||
}
|
||||
existing.Encrypted = req.Encrypted
|
||||
|
||||
if req.Value != "" {
|
||||
value := req.Value
|
||||
if req.Encrypted {
|
||||
encrypted, err := crypto.Encrypt(s.encKey, value)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to encrypt secret")
|
||||
return
|
||||
}
|
||||
value = encrypted
|
||||
}
|
||||
existing.Value = value
|
||||
}
|
||||
|
||||
if err := s.store.UpdateStaticSiteSecret(existing); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update secret: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if existing.Encrypted {
|
||||
existing.Value = "••••••••"
|
||||
}
|
||||
respondJSON(w, http.StatusOK, existing)
|
||||
}
|
||||
|
||||
func (s *Server) deleteStaticSiteSecret(w http.ResponseWriter, r *http.Request) {
|
||||
secretID := chi.URLParam(r, "sid")
|
||||
|
||||
if err := s.store.DeleteStaticSiteSecret(secretID); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "secret")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete secret")
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]string{"deleted": secretID})
|
||||
}
|
||||
|
||||
// ── List Repos ──────────────────────────────────────────────────────
|
||||
|
||||
type listReposRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
GiteaURL string `json:"gitea_url"`
|
||||
AccessToken string `json:"access_token"`
|
||||
Query string `json:"query"`
|
||||
}
|
||||
|
||||
func (s *Server) listStaticSiteRepos(w http.ResponseWriter, r *http.Request) {
|
||||
var req listReposRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.GiteaURL == "" {
|
||||
respondError(w, http.StatusBadRequest, "gitea_url is required")
|
||||
return
|
||||
}
|
||||
|
||||
encToken := ""
|
||||
if req.AccessToken != "" {
|
||||
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to process token")
|
||||
return
|
||||
}
|
||||
encToken = encrypted
|
||||
}
|
||||
|
||||
if s.staticSiteManager == nil {
|
||||
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
repos, err := s.staticSiteManager.ListRepos(r.Context(), req.Provider, req.GiteaURL, encToken, req.Query)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "failed to list repos: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, repos)
|
||||
}
|
||||
|
||||
// ── Detect Provider ─────────────────────────────────────────────────
|
||||
|
||||
type detectProviderRequest struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func (s *Server) detectStaticSiteProvider(w http.ResponseWriter, r *http.Request) {
|
||||
var req detectProviderRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.URL == "" {
|
||||
respondError(w, http.StatusBadRequest, "url is required")
|
||||
return
|
||||
}
|
||||
|
||||
if s.staticSiteManager == nil {
|
||||
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
provider := s.staticSiteManager.DetectProvider(r.Context(), req.URL)
|
||||
respondJSON(w, http.StatusOK, map[string]string{"provider": provider})
|
||||
}
|
||||
|
||||
// maskToken returns a masked version of a token string for API responses.
|
||||
func maskToken(token string) string {
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
return "••••••••"
|
||||
}
|
||||
Reference in New Issue
Block a user