add rate limiting, CSRF, newsletter, auto-checker, /uses and /projects pages
This commit is contained in:
88
internal/checker/checker.go
Normal file
88
internal/checker/checker.go
Normal file
@@ -0,0 +1,88 @@
|
||||
// Package checker periodically HTTP-checks services and updates status.json.
|
||||
package checker
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"ridgwaysystems.org/website/internal/status"
|
||||
)
|
||||
|
||||
// Start launches the background checker. It checks services every interval
|
||||
// and updates status.json in dataDir. Services without a CheckURL are skipped.
|
||||
func Start(dataDir string, interval time.Duration) {
|
||||
go run(dataDir, interval)
|
||||
}
|
||||
|
||||
func run(dataDir string, interval time.Duration) {
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 3 {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
path := filepath.Join(dataDir, "status.json")
|
||||
|
||||
for {
|
||||
check(client, path)
|
||||
time.Sleep(interval)
|
||||
}
|
||||
}
|
||||
|
||||
func check(client *http.Client, path string) {
|
||||
page, err := status.Load(path)
|
||||
if err != nil {
|
||||
log.Printf("checker: load %s: %v", path, err)
|
||||
return
|
||||
}
|
||||
|
||||
changed := false
|
||||
for i, svc := range page.Services {
|
||||
if svc.CheckURL == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
prev := svc.Status
|
||||
newStatus := probe(client, svc.CheckURL)
|
||||
if newStatus != prev {
|
||||
log.Printf("checker: %s %s → %s", svc.Name, prev, newStatus)
|
||||
page.Services[i].Status = newStatus
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
if err := status.Save(path, page); err != nil {
|
||||
log.Printf("checker: save %s: %v", path, err)
|
||||
}
|
||||
} else {
|
||||
// Still update last_checked timestamp so the status page shows freshness
|
||||
page.LastChecked = time.Now().UTC()
|
||||
if err := status.Save(path, page); err != nil {
|
||||
log.Printf("checker: save %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func probe(client *http.Client, url string) string {
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return "down"
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch {
|
||||
case resp.StatusCode < 400:
|
||||
return "up"
|
||||
case resp.StatusCode >= 500:
|
||||
return "down"
|
||||
default:
|
||||
// 4xx could mean the service is up but the URL is wrong; treat as degraded
|
||||
return "degraded"
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"ridgwaysystems.org/website/internal/blog"
|
||||
"ridgwaysystems.org/website/internal/newsletter"
|
||||
"ridgwaysystems.org/website/internal/status"
|
||||
)
|
||||
|
||||
@@ -63,6 +64,13 @@ func (h *Handler) AdminRouter(w http.ResponseWriter, r *http.Request) {
|
||||
case path == "/admin/uploads":
|
||||
h.requireAuth(h.adminUploads)(w, r)
|
||||
|
||||
case path == "/admin/newsletter":
|
||||
if r.Method == http.MethodPost {
|
||||
h.requireAuth(h.adminNewsletterDelete)(w, r)
|
||||
} else {
|
||||
h.requireAuth(h.adminNewsletter)(w, r)
|
||||
}
|
||||
|
||||
default:
|
||||
h.renderErr(w, http.StatusNotFound, "Admin page not found.")
|
||||
}
|
||||
@@ -374,6 +382,40 @@ func (h *Handler) adminUploads(w http.ResponseWriter, r *http.Request) {
|
||||
h.render(w, "admin-uploads", uploadsData{Files: files, Flash: r.URL.Query().Get("flash")})
|
||||
}
|
||||
|
||||
// --- Newsletter admin ---
|
||||
|
||||
type adminNewsletterData struct {
|
||||
Subscribers []newsletter.Subscriber
|
||||
Flash string
|
||||
Count int
|
||||
}
|
||||
|
||||
func (h *Handler) adminNewsletter(w http.ResponseWriter, r *http.Request) {
|
||||
subs, err := h.news.All()
|
||||
if err != nil {
|
||||
h.renderErr(w, http.StatusInternalServerError, "Could not load subscribers.")
|
||||
return
|
||||
}
|
||||
h.render(w, "admin-newsletter", adminNewsletterData{
|
||||
Subscribers: subs,
|
||||
Count: len(subs),
|
||||
Flash: r.URL.Query().Get("flash"),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) adminNewsletterDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
h.renderErr(w, http.StatusBadRequest, "Bad form data.")
|
||||
return
|
||||
}
|
||||
email := r.FormValue("email")
|
||||
if err := h.news.Remove(email); err != nil {
|
||||
h.renderErr(w, http.StatusInternalServerError, "Remove failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/newsletter?flash=Removed", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// sanitizeSlug ensures a slug is filesystem-safe.
|
||||
func sanitizeSlug(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
|
||||
37
internal/handler/csrf.go
Normal file
37
internal/handler/csrf.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// csrfToken returns an HMAC token valid for the current and previous hour.
|
||||
// Reuses the session secret so no additional secret is required.
|
||||
func csrfToken() string {
|
||||
return csrfTokenForTime(time.Now().UTC())
|
||||
}
|
||||
|
||||
func csrfTokenForTime(t time.Time) string {
|
||||
bucket := t.Truncate(time.Hour).Unix()
|
||||
mac := hmac.New(sha256.New, sessionSecret())
|
||||
mac.Write([]byte(fmt.Sprintf("csrf:%d", bucket)))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// csrfValid returns true if token matches the current or previous hour's token.
|
||||
func csrfValid(token string) bool {
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for _, t := range []time.Time{now, now.Add(-time.Hour)} {
|
||||
expected := csrfTokenForTime(t)
|
||||
if hmac.Equal([]byte(token), []byte(expected)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -8,28 +8,35 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"ridgwaysystems.org/website/internal/blog"
|
||||
"ridgwaysystems.org/website/internal/newsletter"
|
||||
"ridgwaysystems.org/website/internal/ratelimit"
|
||||
)
|
||||
|
||||
// Handler holds shared dependencies for all HTTP handlers.
|
||||
type Handler struct {
|
||||
store *blog.Store
|
||||
news *newsletter.Store
|
||||
dataDir string
|
||||
templates map[string]*template.Template
|
||||
siteURL string
|
||||
contactEmail string
|
||||
devMode bool
|
||||
postLimit *ratelimit.Limiter // rate-limits contact + newsletter POSTs
|
||||
}
|
||||
|
||||
// New creates a Handler. dataDir is the path to the data/ directory.
|
||||
func New(store *blog.Store, dataDir string) *Handler {
|
||||
func New(store *blog.Store, news *newsletter.Store, dataDir string) *Handler {
|
||||
h := &Handler{
|
||||
store: store,
|
||||
news: news,
|
||||
dataDir: dataDir,
|
||||
siteURL: getenv("SITE_URL", "https://ridgwaysystems.org"),
|
||||
contactEmail: getenv("CONTACT_EMAIL", "hire@ridgwaysystems.org"),
|
||||
devMode: os.Getenv("DEV") == "1",
|
||||
postLimit: ratelimit.New(10*time.Minute, 5),
|
||||
}
|
||||
if !h.devMode {
|
||||
h.templates = mustLoadTemplates()
|
||||
@@ -74,12 +81,15 @@ func mustLoadTemplates() map[string]*template.Template {
|
||||
{"about", "templates/about.html"},
|
||||
{"hire", "templates/hire.html"},
|
||||
{"resume", "templates/resume.html"},
|
||||
{"uses", "templates/uses.html"},
|
||||
{"projects", "templates/projects.html"},
|
||||
{"error", "templates/error.html"},
|
||||
{"admin-login", "templates/admin/login.html"},
|
||||
{"admin-dashboard", "templates/admin/dashboard.html"},
|
||||
{"admin-editor", "templates/admin/editor.html"},
|
||||
{"admin-status", "templates/admin/status.html"},
|
||||
{"admin-uploads", "templates/admin/uploads.html"},
|
||||
{"admin-newsletter", "templates/admin/newsletter.html"},
|
||||
}
|
||||
|
||||
for _, p := range pages {
|
||||
|
||||
@@ -182,31 +182,57 @@ func (h *Handler) Resume(w http.ResponseWriter, r *http.Request) {
|
||||
h.render(w, "resume", nil)
|
||||
}
|
||||
|
||||
func (h *Handler) Uses(w http.ResponseWriter, r *http.Request) {
|
||||
h.render(w, "uses", nil)
|
||||
}
|
||||
|
||||
func (h *Handler) Projects(w http.ResponseWriter, r *http.Request) {
|
||||
h.render(w, "projects", nil)
|
||||
}
|
||||
|
||||
// --- Hire / Contact ---
|
||||
|
||||
type hireData struct {
|
||||
Name string
|
||||
Email string
|
||||
Company string
|
||||
Message string
|
||||
Error string
|
||||
Success bool
|
||||
Name string
|
||||
Email string
|
||||
Company string
|
||||
Message string
|
||||
Error string
|
||||
Success bool
|
||||
CSRFToken string
|
||||
}
|
||||
|
||||
func (h *Handler) Hire(w http.ResponseWriter, r *http.Request) {
|
||||
h.render(w, "hire", hireData{})
|
||||
h.render(w, "hire", hireData{CSRFToken: csrfToken()})
|
||||
}
|
||||
|
||||
func (h *Handler) HirePost(w http.ResponseWriter, r *http.Request) {
|
||||
// Rate limit
|
||||
if !h.postLimit.Allow(r.RemoteAddr) {
|
||||
h.renderErr(w, http.StatusTooManyRequests, "Too many requests. Please try again later.")
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
h.renderErr(w, http.StatusBadRequest, "Bad form data.")
|
||||
return
|
||||
}
|
||||
// Honeypot: bots fill hidden fields, humans don't
|
||||
if r.FormValue("website") != "" {
|
||||
// Silently succeed to not reveal the check
|
||||
h.render(w, "hire", hireData{Success: true})
|
||||
return
|
||||
}
|
||||
// CSRF
|
||||
if !csrfValid(r.FormValue("csrf_token")) {
|
||||
h.renderErr(w, http.StatusForbidden, "Invalid or expired form token. Please reload the page and try again.")
|
||||
return
|
||||
}
|
||||
d := hireData{
|
||||
Name: strings.TrimSpace(r.FormValue("name")),
|
||||
Email: strings.TrimSpace(r.FormValue("email")),
|
||||
Company: strings.TrimSpace(r.FormValue("company")),
|
||||
Message: strings.TrimSpace(r.FormValue("message")),
|
||||
Name: strings.TrimSpace(r.FormValue("name")),
|
||||
Email: strings.TrimSpace(r.FormValue("email")),
|
||||
Company: strings.TrimSpace(r.FormValue("company")),
|
||||
Message: strings.TrimSpace(r.FormValue("message")),
|
||||
CSRFToken: csrfToken(),
|
||||
}
|
||||
if d.Name == "" || d.Email == "" || d.Message == "" {
|
||||
d.Error = "Name, email, and message are required."
|
||||
@@ -234,6 +260,44 @@ func (h *Handler) HirePost(w http.ResponseWriter, r *http.Request) {
|
||||
h.render(w, "hire", d)
|
||||
}
|
||||
|
||||
// --- Newsletter ---
|
||||
|
||||
type newsletterData struct {
|
||||
Error string
|
||||
Success bool
|
||||
}
|
||||
|
||||
func (h *Handler) NewsletterPost(w http.ResponseWriter, r *http.Request) {
|
||||
// Rate limit
|
||||
if !h.postLimit.Allow(r.RemoteAddr) {
|
||||
http.Error(w, "Too many requests", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Honeypot
|
||||
if r.FormValue("url") != "" {
|
||||
http.Redirect(w, r, r.Referer(), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
|
||||
if email == "" || !strings.Contains(email, "@") {
|
||||
http.Redirect(w, r, r.Referer()+"?subscribe=invalid", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if _, err := h.news.Add(email); err != nil {
|
||||
log.Printf("newsletter add %s: %v", email, err)
|
||||
}
|
||||
// Redirect back with success param regardless (don't confirm existence)
|
||||
ref := r.Referer()
|
||||
if ref == "" {
|
||||
ref = "/"
|
||||
}
|
||||
http.Redirect(w, r, ref+"?subscribe=ok", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// --- Sitemap ---
|
||||
|
||||
type urlset struct {
|
||||
@@ -256,7 +320,9 @@ func (h *Handler) Sitemap(w http.ResponseWriter, r *http.Request) {
|
||||
{Loc: h.siteURL + "/", Freq: "weekly", Prio: "1.0"},
|
||||
{Loc: h.siteURL + "/blog", Freq: "weekly", Prio: "0.9"},
|
||||
{Loc: h.siteURL + "/hire", Freq: "monthly", Prio: "0.9"},
|
||||
{Loc: h.siteURL + "/resume", Freq: "monthly", Prio: "0.7"},
|
||||
{Loc: h.siteURL + "/resume", Freq: "monthly", Prio: "0.8"},
|
||||
{Loc: h.siteURL + "/projects", Freq: "monthly", Prio: "0.7"},
|
||||
{Loc: h.siteURL + "/uses", Freq: "monthly", Prio: "0.6"},
|
||||
{Loc: h.siteURL + "/infrastructure", Freq: "monthly", Prio: "0.7"},
|
||||
{Loc: h.siteURL + "/status", Freq: "daily", Prio: "0.6"},
|
||||
{Loc: h.siteURL + "/about", Freq: "monthly", Prio: "0.5"},
|
||||
|
||||
111
internal/newsletter/newsletter.go
Normal file
111
internal/newsletter/newsletter.go
Normal file
@@ -0,0 +1,111 @@
|
||||
// Package newsletter manages email subscriber storage as a flat JSON file.
|
||||
package newsletter
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Subscriber holds a single email subscription.
|
||||
type Subscriber struct {
|
||||
Email string `json:"email"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Store manages subscribers persisted to a JSON file.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
}
|
||||
|
||||
// NewStore returns a Store backed by path, creating the file if needed.
|
||||
func NewStore(path string) (*Store, error) {
|
||||
s := &Store{path: path}
|
||||
// Create empty file if it doesn't exist
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
if err := s.save(nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// All returns all current subscribers.
|
||||
func (s *Store) All() ([]Subscriber, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.load()
|
||||
}
|
||||
|
||||
// Add adds an email if it isn't already subscribed. Returns false if duplicate.
|
||||
func (s *Store) Add(email string) (bool, error) {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
if email == "" {
|
||||
return false, errors.New("email is required")
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
subs, err := s.load()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, sub := range subs {
|
||||
if sub.Email == email {
|
||||
return false, nil // already subscribed
|
||||
}
|
||||
}
|
||||
subs = append(subs, Subscriber{Email: email, CreatedAt: time.Now().UTC()})
|
||||
return true, s.save(subs)
|
||||
}
|
||||
|
||||
// Remove unsubscribes an email address.
|
||||
func (s *Store) Remove(email string) error {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
subs, err := s.load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filtered := subs[:0]
|
||||
for _, sub := range subs {
|
||||
if sub.Email != email {
|
||||
filtered = append(filtered, sub)
|
||||
}
|
||||
}
|
||||
return s.save(filtered)
|
||||
}
|
||||
|
||||
func (s *Store) load() ([]Subscriber, error) {
|
||||
raw, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var subs []Subscriber
|
||||
if err := json.Unmarshal(raw, &subs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return subs, nil
|
||||
}
|
||||
|
||||
func (s *Store) save(subs []Subscriber) error {
|
||||
if subs == nil {
|
||||
subs = []Subscriber{}
|
||||
}
|
||||
raw, err := json.MarshalIndent(subs, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(s.path, raw, 0600)
|
||||
}
|
||||
91
internal/ratelimit/ratelimit.go
Normal file
91
internal/ratelimit/ratelimit.go
Normal file
@@ -0,0 +1,91 @@
|
||||
// Package ratelimit provides a simple in-memory per-IP sliding-window rate limiter.
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Limiter tracks request counts per IP within a sliding window.
|
||||
type Limiter struct {
|
||||
mu sync.Mutex
|
||||
entries map[string][]time.Time
|
||||
window time.Duration
|
||||
max int
|
||||
}
|
||||
|
||||
// New creates a Limiter that allows at most max requests per window per IP.
|
||||
func New(window time.Duration, max int) *Limiter {
|
||||
l := &Limiter{
|
||||
entries: make(map[string][]time.Time),
|
||||
window: window,
|
||||
max: max,
|
||||
}
|
||||
go l.cleanup()
|
||||
return l
|
||||
}
|
||||
|
||||
// Allow returns true if the IP is within its rate limit, recording the attempt.
|
||||
func (l *Limiter) Allow(ip string) bool {
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-l.window)
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
times := l.entries[ip]
|
||||
recent := times[:0]
|
||||
for _, t := range times {
|
||||
if t.After(cutoff) {
|
||||
recent = append(recent, t)
|
||||
}
|
||||
}
|
||||
|
||||
if len(recent) >= l.max {
|
||||
l.entries[ip] = recent
|
||||
return false
|
||||
}
|
||||
|
||||
l.entries[ip] = append(recent, now)
|
||||
return true
|
||||
}
|
||||
|
||||
// Middleware wraps a handler, rejecting over-limit requests with 429.
|
||||
func (l *Limiter) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
ip = r.RemoteAddr
|
||||
}
|
||||
if !l.Allow(ip) {
|
||||
http.Error(w, "Too many requests. Please wait and try again.", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// cleanup periodically removes expired entries to prevent unbounded growth.
|
||||
func (l *Limiter) cleanup() {
|
||||
for {
|
||||
time.Sleep(5 * time.Minute)
|
||||
cutoff := time.Now().Add(-l.window)
|
||||
l.mu.Lock()
|
||||
for ip, times := range l.entries {
|
||||
recent := times[:0]
|
||||
for _, t := range times {
|
||||
if t.After(cutoff) {
|
||||
recent = append(recent, t)
|
||||
}
|
||||
}
|
||||
if len(recent) == 0 {
|
||||
delete(l.entries, ip)
|
||||
} else {
|
||||
l.entries[ip] = recent
|
||||
}
|
||||
}
|
||||
l.mu.Unlock()
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,19 @@ package status
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var mu sync.RWMutex
|
||||
|
||||
// Service represents a single monitored service.
|
||||
type Service struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Status string `json:"status"` // "up", "degraded", "down", "unknown"
|
||||
CheckURL string `json:"check_url,omitempty"` // HTTP URL probed automatically; empty = manual
|
||||
Status string `json:"status"` // "up", "degraded", "down", "unknown"
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
@@ -24,6 +28,12 @@ type Page struct {
|
||||
|
||||
// Load reads and parses the status JSON from path.
|
||||
func Load(path string) (*Page, error) {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
return load(path)
|
||||
}
|
||||
|
||||
func load(path string) (*Page, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -37,6 +47,8 @@ func Load(path string) (*Page, error) {
|
||||
|
||||
// Save writes the status page data back to path.
|
||||
func Save(path string, p *Page) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
p.LastChecked = time.Now().UTC()
|
||||
raw, err := json.MarshalIndent(p, "", " ")
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user