feat: signed releases, release feed, notification (read-only)

This commit is contained in:
fsylvain committed 2026-10-01 15:01:53 +02:00
1 parent a49eec909c
commit 834493a4f5
49 files changed
+4191 -18

No files matched your search

+15 -1
View File
@@ -5,16 +5,19 @@ import (
domainImage "RenewCMS/internal/domain/image"
domainInvite "RenewCMS/internal/domain/invite"
domainMail "RenewCMS/internal/domain/mail"
domainUpdate "RenewCMS/internal/domain/update"
domainUser "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/hashing"
"RenewCMS/internal/infrastructure/mailer"
"RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/update"
"RenewCMS/internal/infrastructure/useCases"
"fmt"
"os"
"path/filepath"
"time"
"github.com/glebarez/sqlite"
"github.com/go-chi/jwtauth/v5"
@@ -51,6 +54,8 @@ type UseCases struct {
MergeMediaUseCase *useCases.MergeMediaUseCase
GetMediaUsageUseCase *useCases.GetMediaUsageUseCase
GetSettingsUseCase *useCases.GetSettingsUseCase
GetUpdateStatusUseCase *useCases.GetUpdateStatusUseCase
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
}
type Repositories struct {
@@ -59,9 +64,14 @@ type Repositories struct {
ImageRepo domainImage.Repository
InviteRepo domainInvite.Repository
MailRepo domainMail.Repository
ReleaseFeed domainUpdate.ReleaseFeed
UpdateStore domainUpdate.StatusStore
}
const sqliteBusyTimeoutMs = 5000
const (
sqliteBusyTimeoutMs = 5000
updateStatusFileName = "update-status.json"
)
func SQLiteDSN(dbName string) string {
return fmt.Sprintf("%s?_pragma=busy_timeout(%d)", dbName, sqliteBusyTimeoutMs)
@@ -98,6 +108,8 @@ func getRepositories(db *gorm.DB, settings config.Settings) *Repositories {
ImageRepo: persistence.NewImageRepository(db, settings.UploadDir),
InviteRepo: persistence.NewInviteRepository(db),
MailRepo: mailer.NewMailRepository(settings.Smtp),
ReleaseFeed: update.NewGiteaReleaseFeed(settings.UpdateFeedURL),
UpdateStore: update.NewJSONStatusStore(filepath.Join(filepath.Dir(settings.DBFile), updateStatusFileName)),
}
}
@@ -134,6 +146,8 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
MergeMediaUseCase: useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo),
GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version),
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(repos.UpdateStore, settings.Version),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(repos.ReleaseFeed, repos.UpdateStore, settings.Version, time.Now),
}
}
+39
View File
@@ -0,0 +1,39 @@
package update
import (
domain "RenewCMS/internal/domain/update"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"net/http"
)
type Handler struct {
GetUpdateStatusUseCase *useCases.GetUpdateStatusUseCase
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
}
func NewHandler(getStatus *useCases.GetUpdateStatusUseCase, checkForUpdate *useCases.CheckForUpdateUseCase) *Handler {
return &Handler{GetUpdateStatusUseCase: getStatus, CheckForUpdateUseCase: checkForUpdate}
}
func (h *Handler) GetStatus(w http.ResponseWriter, _ *http.Request) {
writeStatus(w, h.GetUpdateStatusUseCase.GetStatus())
}
func (h *Handler) Check(w http.ResponseWriter, _ *http.Request) {
status, err := h.CheckForUpdateUseCase.Check()
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
writeStatus(w, status)
}
func writeStatus(w http.ResponseWriter, status domain.UpdateStatus) {
body, err := json.Marshal(status)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
_, _ = w.Write(body)
}
+15 -1
View File
@@ -2,6 +2,7 @@ package middleware
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
"net/http"
"strconv"
"sync"
@@ -14,12 +15,25 @@ type rateLimitWindow struct {
}
func NewRateLimiter(maxAttempts int, window time.Duration) func(http.Handler) http.Handler {
return newKeyedRateLimiter(maxAttempts, window, func(r *http.Request) string { return r.RemoteAddr })
}
func NewUserRateLimiter(maxAttempts int, window time.Duration) func(http.Handler) http.Handler {
return newKeyedRateLimiter(maxAttempts, window, userRateLimitKey)
}
func userRateLimitKey(r *http.Request) string {
userId, _ := claims.UserId(r)
return strconv.FormatUint(uint64(userId), 10)
}
func newKeyedRateLimiter(maxAttempts int, window time.Duration, keyOf func(*http.Request) string) func(http.Handler) http.Handler {
var mu sync.Mutex
windows := make(map[string]*rateLimitWindow)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
retryAfter, allowed := recordAttempt(&mu, windows, r.RemoteAddr, maxAttempts, window)
retryAfter, allowed := recordAttempt(&mu, windows, keyOf(r), maxAttempts, window)
if !allowed {
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
http.Error(w, apperr.TooManyRequestsErrorMessage, http.StatusTooManyRequests)
+26
View File
@@ -7,11 +7,13 @@ import (
"RenewCMS/api/handlers/media"
"RenewCMS/api/handlers/settings"
"RenewCMS/api/handlers/staticimages"
"RenewCMS/api/handlers/update"
"RenewCMS/api/handlers/user"
"RenewCMS/api/middleware"
"RenewCMS/internal/infrastructure/config"
"encoding/json"
"net/http"
"time"
"github.com/MadAppGang/httplog"
"github.com/go-chi/chi/v5"
@@ -129,6 +131,29 @@ func NewSettingsRouter(container *UseCases) http.Handler {
return r
}
const (
updateCheckMaxAttempts = 5
updateCheckWindow = time.Minute
)
func NewSystemRouter(container *UseCases) http.Handler {
r := chi.NewRouter()
updateHandler := update.NewHandler(container.GetUpdateStatusUseCase, container.CheckForUpdateUseCase)
r.Use(jwtauth.Verifier(container.Token))
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/update", updateHandler.GetStatus)
r.Group(func(r chi.Router) {
r.Use(middleware.RequireOwner(container.GetUserUseCase))
r.Use(middleware.NewUserRateLimiter(updateCheckMaxAttempts, updateCheckWindow))
r.Post("/update/check", updateHandler.Check)
})
return r
}
func NewMediaRouter(container *UseCases) http.Handler {
r := chi.NewRouter()
@@ -162,6 +187,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies))
r.Mount("/settings", NewSettingsRouter(container))
r.Mount("/media", NewMediaRouter(container))
r.Mount("/system", NewSystemRouter(container))
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
return r