564 lines
18 KiB
Go
564 lines
18 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"scout/internal/notify"
|
||
"scout/internal/registry"
|
||
"scout/internal/users"
|
||
)
|
||
|
||
type Server struct {
|
||
Registry *registry.Registry
|
||
Projects *registry.ProjectRegistry
|
||
DeveloperToken string
|
||
MaxUploadBytes int64
|
||
Users *users.Store
|
||
Mailer notify.Sender
|
||
SessionDuration time.Duration
|
||
AllowedOrigins []string
|
||
AllowedHosts []string
|
||
ForceHTTPS bool
|
||
TrustProxy bool
|
||
AllowLegacyToken bool
|
||
RateLimitPerMinute int
|
||
Logger *log.Logger
|
||
resetMu sync.Mutex
|
||
resetTokens map[string]resetToken
|
||
projectScope string
|
||
}
|
||
|
||
type resetToken struct {
|
||
UserID string
|
||
ExpiresAt time.Time
|
||
}
|
||
|
||
type credentials struct {
|
||
Email string `json:"email"`
|
||
Password string `json:"password"`
|
||
Name string `json:"name"`
|
||
}
|
||
|
||
type updateUserRequest struct {
|
||
Role users.Role `json:"role"`
|
||
Active bool `json:"active"`
|
||
}
|
||
|
||
type userResponse struct {
|
||
User users.User `json:"user"`
|
||
Session string `json:"session,omitempty"`
|
||
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||
}
|
||
|
||
func (s *Server) Handler() http.Handler {
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("GET /healthz", s.health)
|
||
mux.HandleFunc("POST /api/v1/auth/register", s.register)
|
||
mux.HandleFunc("POST /api/v1/auth/login", s.login)
|
||
mux.HandleFunc("POST /api/v1/auth/logout", s.logout)
|
||
mux.HandleFunc("GET /api/v1/auth/me", s.me)
|
||
mux.HandleFunc("POST /api/v1/auth/password/request", s.requestPasswordReset)
|
||
mux.HandleFunc("POST /api/v1/auth/password/reset", s.resetPassword)
|
||
mux.HandleFunc("GET /api/v1/projects", s.projectList)
|
||
mux.HandleFunc("POST /api/v1/projects", s.projectCreate)
|
||
mux.HandleFunc("/api/v1/projects/", s.projectRoute)
|
||
mux.HandleFunc("GET /api/v1/admin/users", s.adminUsers)
|
||
mux.HandleFunc("PATCH /api/v1/admin/users/", s.adminUpdateUser)
|
||
mux.HandleFunc("POST /api/v1/packages/upload", s.upload)
|
||
mux.HandleFunc("GET /api/v1/updates/stream", s.stream)
|
||
mux.HandleFunc("GET /api/v1/packages", s.packageList)
|
||
mux.HandleFunc("GET /api/v1/packages/", s.packageRoute)
|
||
return securityHeaders(s.hostGuard(s.rateLimit(s.cors(logging(mux)))))
|
||
}
|
||
|
||
func (s *Server) health(writer http.ResponseWriter, _ *http.Request) {
|
||
writeJSON(writer, http.StatusOK, map[string]string{"status": "ok"})
|
||
}
|
||
|
||
func (s *Server) register(writer http.ResponseWriter, request *http.Request) {
|
||
if s.Users == nil {
|
||
writeError(writer, http.StatusServiceUnavailable, "用户管理尚未启用")
|
||
return
|
||
}
|
||
var input credentials
|
||
if err := decodeJSON(request, &input); err != nil {
|
||
writeError(writer, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
user, err := s.Users.Register(input.Email, input.Name, input.Password)
|
||
if err != nil {
|
||
writeError(writer, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
s.sendUserEmail(user, "Scout 账户已创建", fmt.Sprintf("你好,%s。你的 Scout 账户已创建成功。\n\n如果这不是你的操作,请立即联系管理员。", user.Name))
|
||
s.sendAdminsEmail("Scout 有新用户注册", fmt.Sprintf("新用户 %s(%s)已注册 Scout 账户。", user.Name, user.Email))
|
||
writeJSON(writer, http.StatusCreated, map[string]any{"user": user})
|
||
}
|
||
|
||
func (s *Server) login(writer http.ResponseWriter, request *http.Request) {
|
||
if s.Users == nil {
|
||
writeError(writer, http.StatusServiceUnavailable, "用户管理尚未启用")
|
||
return
|
||
}
|
||
var input credentials
|
||
if err := decodeJSON(request, &input); err != nil {
|
||
writeError(writer, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
user, err := s.Users.Authenticate(input.Email, input.Password)
|
||
if err != nil {
|
||
writeError(writer, http.StatusUnauthorized, err.Error())
|
||
return
|
||
}
|
||
duration := s.SessionDuration
|
||
if duration <= 0 {
|
||
duration = 24 * time.Hour
|
||
}
|
||
session, expiresAt, err := s.Users.CreateSession(user.ID, duration)
|
||
if err != nil {
|
||
writeError(writer, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
s.sendUserEmail(user, "Scout 登录安全提醒", fmt.Sprintf("你的 Scout 账户于 %s 登录。\n\n如果这不是你的操作,请立即修改密码。", time.Now().Format("2006-01-02 15:04:05")))
|
||
writeJSON(writer, http.StatusOK, userResponse{User: user, Session: session, ExpiresAt: expiresAt})
|
||
}
|
||
|
||
func (s *Server) logout(writer http.ResponseWriter, request *http.Request) {
|
||
if s.Users != nil {
|
||
s.Users.RevokeSession(bearerToken(request))
|
||
}
|
||
writeJSON(writer, http.StatusOK, map[string]string{"message": "已退出登录"})
|
||
}
|
||
|
||
func (s *Server) me(writer http.ResponseWriter, request *http.Request) {
|
||
user, ok := s.authenticatedUser(request)
|
||
if !ok {
|
||
writeError(writer, http.StatusUnauthorized, "请先登录")
|
||
return
|
||
}
|
||
writeJSON(writer, http.StatusOK, map[string]any{"user": user})
|
||
}
|
||
|
||
func (s *Server) requestPasswordReset(writer http.ResponseWriter, request *http.Request) {
|
||
var input struct {
|
||
Email string `json:"email"`
|
||
}
|
||
if err := decodeJSON(request, &input); err != nil {
|
||
writeError(writer, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
response := map[string]string{"message": "如果邮箱已注册,密码重置说明将发送到该邮箱"}
|
||
if s.Users == nil {
|
||
writeJSON(writer, http.StatusOK, response)
|
||
return
|
||
}
|
||
user, ok := s.Users.FindByEmail(input.Email)
|
||
if !ok || !user.Active {
|
||
writeJSON(writer, http.StatusOK, response)
|
||
return
|
||
}
|
||
rawToken := make([]byte, 32)
|
||
if _, err := rand.Read(rawToken); err != nil {
|
||
writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": "生成重置令牌失败"})
|
||
return
|
||
}
|
||
token := hex.EncodeToString(rawToken)
|
||
s.resetMu.Lock()
|
||
if s.resetTokens == nil {
|
||
s.resetTokens = make(map[string]resetToken)
|
||
}
|
||
s.resetTokens[token] = resetToken{UserID: user.ID, ExpiresAt: time.Now().UTC().Add(30 * time.Minute)}
|
||
s.resetMu.Unlock()
|
||
s.sendUserEmail(user, "Scout 密码重置", "你请求了 Scout 密码重置。\n\n请使用以下一次性令牌完成重置:\n"+token+"\n\n令牌 30 分钟内有效。如果这不是你的操作,请忽略此邮件。")
|
||
writeJSON(writer, http.StatusOK, response)
|
||
}
|
||
|
||
func (s *Server) resetPassword(writer http.ResponseWriter, request *http.Request) {
|
||
var input struct {
|
||
Token string `json:"token"`
|
||
NewPassword string `json:"new_password"`
|
||
}
|
||
if err := decodeJSON(request, &input); err != nil {
|
||
writeError(writer, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
s.resetMu.Lock()
|
||
entry, ok := s.resetTokens[strings.TrimSpace(input.Token)]
|
||
if ok {
|
||
delete(s.resetTokens, strings.TrimSpace(input.Token))
|
||
}
|
||
s.resetMu.Unlock()
|
||
if !ok || time.Now().UTC().After(entry.ExpiresAt) || s.Users == nil {
|
||
writeError(writer, http.StatusBadRequest, "密码重置令牌无效或已过期")
|
||
return
|
||
}
|
||
if err := s.Users.SetPassword(entry.UserID, input.NewPassword); err != nil {
|
||
writeError(writer, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
if user, ok := s.Users.Get(entry.UserID); ok {
|
||
s.sendUserEmail(user, "Scout 密码已修改", "你的 Scout 密码已修改成功。如果这不是你的操作,请立即联系管理员。")
|
||
}
|
||
writeJSON(writer, http.StatusOK, map[string]string{"message": "密码修改成功,请重新登录"})
|
||
}
|
||
|
||
func (s *Server) adminUsers(writer http.ResponseWriter, request *http.Request) {
|
||
if !s.requireRole(writer, request, users.RoleAdmin) {
|
||
return
|
||
}
|
||
writeJSON(writer, http.StatusOK, map[string]any{"users": s.Users.List()})
|
||
}
|
||
|
||
func (s *Server) adminUpdateUser(writer http.ResponseWriter, request *http.Request) {
|
||
if !s.requireRole(writer, request, users.RoleAdmin) {
|
||
return
|
||
}
|
||
id := strings.TrimPrefix(request.URL.Path, "/api/v1/admin/users/")
|
||
if id == "" {
|
||
writeError(writer, http.StatusBadRequest, "用户编号不能为空")
|
||
return
|
||
}
|
||
var input updateUserRequest
|
||
if err := decodeJSON(request, &input); err != nil {
|
||
writeError(writer, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
updated, err := s.Users.Update(id, input.Role, input.Active)
|
||
if err != nil {
|
||
writeError(writer, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
s.sendUserEmail(updated, "Scout 用户权限已更新", fmt.Sprintf("你的账户角色已调整为 %s,账户状态:%s。", updated.Role, statusText(updated.Active)))
|
||
writeJSON(writer, http.StatusOK, map[string]any{"user": updated})
|
||
}
|
||
|
||
func (s *Server) legacyPackageList(writer http.ResponseWriter, request *http.Request) {
|
||
items := s.Registry.List()
|
||
query := strings.ToLower(strings.TrimSpace(request.URL.Query().Get("query")))
|
||
if query != "" {
|
||
filtered := make([]registry.Package, 0, len(items))
|
||
for _, item := range items {
|
||
if strings.Contains(strings.ToLower(item.Name), query) || strings.Contains(strings.ToLower(item.Description), query) {
|
||
filtered = append(filtered, item)
|
||
}
|
||
}
|
||
items = filtered
|
||
}
|
||
total := len(items)
|
||
page := positiveInt(request.URL.Query().Get("page"), 1)
|
||
pageSize := positiveInt(request.URL.Query().Get("page_size"), 50)
|
||
if pageSize > 100 {
|
||
pageSize = 100
|
||
}
|
||
start := (page - 1) * pageSize
|
||
if start > len(items) {
|
||
start = len(items)
|
||
}
|
||
end := start + pageSize
|
||
if end > len(items) {
|
||
end = len(items)
|
||
}
|
||
writeJSON(writer, http.StatusOK, map[string]any{"packages": items[start:end], "total": total, "page": page, "page_size": pageSize})
|
||
}
|
||
|
||
func (s *Server) legacyUpload(writer http.ResponseWriter, request *http.Request) {
|
||
uploader, authorized := s.uploadUser(request)
|
||
if !authorized {
|
||
if _, loggedIn := s.authenticatedUser(request); loggedIn {
|
||
writeError(writer, http.StatusForbidden, "需要开发者或管理员权限")
|
||
} else {
|
||
writeError(writer, http.StatusUnauthorized, "请先登录开发者账户")
|
||
}
|
||
return
|
||
}
|
||
maxBytes := s.MaxUploadBytes
|
||
if maxBytes <= 0 {
|
||
maxBytes = 512 << 20
|
||
}
|
||
request.Body = http.MaxBytesReader(writer, request.Body, maxBytes)
|
||
if err := request.ParseMultipartForm(32 << 20); err != nil {
|
||
s.notifyUploadFailure(uploader, "上传表单无效: "+err.Error())
|
||
writeError(writer, http.StatusBadRequest, "上传表单无效: "+err.Error())
|
||
return
|
||
}
|
||
file, header, err := request.FormFile("file")
|
||
if err != nil {
|
||
s.notifyUploadFailure(uploader, "缺少包文件字段 file")
|
||
writeError(writer, http.StatusBadRequest, "缺少包文件字段 file")
|
||
return
|
||
}
|
||
defer file.Close()
|
||
fileName := header.Filename
|
||
if err := registry.ValidateFileName(fileName); err != nil {
|
||
s.notifyUploadFailure(uploader, err.Error())
|
||
writeError(writer, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
item, err := s.Registry.AddPackage(registry.Package{
|
||
Name: request.FormValue("name"),
|
||
Version: request.FormValue("version"),
|
||
Description: request.FormValue("description"),
|
||
Platform: request.FormValue("platform"),
|
||
Architecture: request.FormValue("architecture"),
|
||
FileName: fileName,
|
||
UploaderID: uploader.ID,
|
||
UploaderEmail: uploader.Email,
|
||
}, file)
|
||
if err != nil {
|
||
status := http.StatusBadRequest
|
||
if strings.Contains(err.Error(), "已经存在") {
|
||
status = http.StatusConflict
|
||
}
|
||
s.notifyUploadFailure(uploader, err.Error())
|
||
writeError(writer, status, err.Error())
|
||
return
|
||
}
|
||
s.sendUserEmail(uploader, "Scout 包上传成功", fmt.Sprintf("包 %s %s 已上传成功。\n\nSHA-256:%s\n大小:%d 字节", item.Name, item.Version, item.SHA256, item.Size))
|
||
writeJSON(writer, http.StatusCreated, item)
|
||
}
|
||
|
||
func (s *Server) legacyPackageRoute(writer http.ResponseWriter, request *http.Request) {
|
||
path := strings.TrimPrefix(request.URL.Path, "/api/v1/packages/")
|
||
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||
if len(parts) == 1 && parts[0] != "" {
|
||
item, ok := s.Registry.Latest(parts[0])
|
||
if !ok {
|
||
writeError(writer, http.StatusNotFound, "包不存在")
|
||
return
|
||
}
|
||
writeJSON(writer, http.StatusOK, item)
|
||
return
|
||
}
|
||
if len(parts) == 2 && parts[0] != "" {
|
||
if parts[1] == "versions" {
|
||
writeJSON(writer, http.StatusOK, map[string]any{"name": parts[0], "versions": s.Registry.Versions(parts[0])})
|
||
return
|
||
}
|
||
writeError(writer, http.StatusNotFound, "接口不存在")
|
||
return
|
||
}
|
||
if len(parts) == 3 && parts[2] == "download" {
|
||
s.download(writer, request, parts[0], parts[1])
|
||
return
|
||
}
|
||
writeError(writer, http.StatusNotFound, "接口不存在")
|
||
}
|
||
|
||
func (s *Server) legacyDownload(writer http.ResponseWriter, request *http.Request, name, version string) {
|
||
file, item, err := s.Registry.OpenPackage(name, version)
|
||
if err != nil {
|
||
writeError(writer, http.StatusNotFound, "包版本不存在")
|
||
return
|
||
}
|
||
defer file.Close()
|
||
writer.Header().Set("Content-Type", "application/octet-stream")
|
||
writer.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s-%s.pkg"`, name, version))
|
||
writer.Header().Set("X-Scout-SHA256", item.SHA256)
|
||
writer.Header().Set("Content-Length", strconv.FormatInt(item.Size, 10))
|
||
if request.Method == http.MethodHead {
|
||
return
|
||
}
|
||
_, _ = io.Copy(writer, file)
|
||
}
|
||
|
||
func (s *Server) legacyStream(writer http.ResponseWriter, request *http.Request) {
|
||
flusher, ok := writer.(http.Flusher)
|
||
if !ok {
|
||
writeError(writer, http.StatusInternalServerError, "当前服务器不支持实时通知")
|
||
return
|
||
}
|
||
wanted := make(map[string]bool)
|
||
for _, name := range strings.Split(request.URL.Query().Get("packages"), ",") {
|
||
if name = strings.TrimSpace(name); name != "" {
|
||
wanted[name] = true
|
||
}
|
||
}
|
||
updates, unsubscribe := s.Registry.Subscribe()
|
||
defer unsubscribe()
|
||
writer.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
||
writer.Header().Set("Cache-Control", "no-cache")
|
||
writer.Header().Set("Connection", "keep-alive")
|
||
fmt.Fprint(writer, ": 已连接\n\n")
|
||
flusher.Flush()
|
||
for {
|
||
select {
|
||
case <-request.Context().Done():
|
||
return
|
||
case update, ok := <-updates:
|
||
if !ok {
|
||
return
|
||
}
|
||
if len(wanted) > 0 && !wanted[update.Package.Name] {
|
||
continue
|
||
}
|
||
payload, _ := json.Marshal(update)
|
||
fmt.Fprintf(writer, "event: package_updated\ndata: %s\n\n", payload)
|
||
flusher.Flush()
|
||
}
|
||
}
|
||
}
|
||
|
||
func (s *Server) authenticatedUser(request *http.Request) (users.User, bool) {
|
||
if s.Users == nil {
|
||
return users.User{}, false
|
||
}
|
||
return s.Users.UserBySession(bearerToken(request))
|
||
}
|
||
|
||
func (s *Server) uploadUser(request *http.Request) (users.User, bool) {
|
||
if s.DeveloperToken != "" && (s.AllowLegacyToken || (s.AllowedHosts == nil && s.AllowedOrigins == nil)) {
|
||
value := request.Header.Get("Authorization")
|
||
if strings.HasPrefix(value, "Bearer ") && strings.TrimPrefix(value, "Bearer ") == s.DeveloperToken {
|
||
return users.User{ID: "legacy-token", Email: "", Name: "兼容令牌", Role: users.RoleDeveloper, Active: true}, true
|
||
}
|
||
if request.Header.Get("X-Developer-Token") == s.DeveloperToken {
|
||
return users.User{ID: "legacy-token", Email: "", Name: "兼容令牌", Role: users.RoleDeveloper, Active: true}, true
|
||
}
|
||
}
|
||
user, ok := s.authenticatedUser(request)
|
||
if !ok || (user.Role != users.RoleDeveloper && user.Role != users.RoleAdmin) {
|
||
return users.User{}, false
|
||
}
|
||
return user, true
|
||
}
|
||
|
||
func (s *Server) requireRole(writer http.ResponseWriter, request *http.Request, role users.Role) bool {
|
||
user, ok := s.authenticatedUser(request)
|
||
if !ok {
|
||
writeError(writer, http.StatusUnauthorized, "请先登录")
|
||
return false
|
||
}
|
||
if user.Role != role {
|
||
writeError(writer, http.StatusForbidden, "没有执行此操作的权限")
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
func (s *Server) sendUserEmail(user users.User, subject, body string) {
|
||
if user.Email == "" || s.mailer() == nil {
|
||
return
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
if err := s.mailer().Send(ctx, notify.Message{To: []string{user.Email}, Subject: subject, Body: body}); err != nil {
|
||
s.logger().Printf("发送用户通知失败:%v", err)
|
||
}
|
||
}
|
||
|
||
func (s *Server) sendAdminsEmail(subject, body string) {
|
||
if s.Users == nil || s.mailer() == nil {
|
||
return
|
||
}
|
||
admins := s.Users.ListByRole(users.RoleAdmin)
|
||
recipients := make([]string, 0, len(admins))
|
||
for _, admin := range admins {
|
||
if admin.Email != "" {
|
||
recipients = append(recipients, admin.Email)
|
||
}
|
||
}
|
||
if len(recipients) == 0 {
|
||
return
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
if err := s.mailer().Send(ctx, notify.Message{To: recipients, Subject: subject, Body: body}); err != nil {
|
||
s.logger().Printf("发送管理员通知失败:%v", err)
|
||
}
|
||
}
|
||
|
||
func (s *Server) notifyUploadFailure(user users.User, reason string) {
|
||
if user.Email == "" {
|
||
return
|
||
}
|
||
s.sendUserEmail(user, "Scout 包上传失败", "你的包上传操作失败。\n\n原因:"+reason)
|
||
}
|
||
|
||
func (s *Server) mailer() notify.Sender {
|
||
if s.Mailer != nil {
|
||
return s.Mailer
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Server) logger() *log.Logger {
|
||
if s.Logger != nil {
|
||
return s.Logger
|
||
}
|
||
return log.Default()
|
||
}
|
||
|
||
func bearerToken(request *http.Request) string {
|
||
value := strings.TrimSpace(request.Header.Get("Authorization"))
|
||
if strings.HasPrefix(value, "Bearer ") {
|
||
return strings.TrimSpace(strings.TrimPrefix(value, "Bearer "))
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func decodeJSON(request *http.Request, target any) error {
|
||
request.Body = io.NopCloser(io.LimitReader(request.Body, 1<<20+1))
|
||
decoder := json.NewDecoder(request.Body)
|
||
decoder.DisallowUnknownFields()
|
||
if err := decoder.Decode(target); err != nil {
|
||
return errors.New("请求 JSON 无效")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func positiveInt(value string, fallback int) int {
|
||
parsed, err := strconv.Atoi(value)
|
||
if err != nil || parsed <= 0 {
|
||
return fallback
|
||
}
|
||
return parsed
|
||
}
|
||
|
||
func statusText(active bool) string {
|
||
if active {
|
||
return "启用"
|
||
}
|
||
return "禁用"
|
||
}
|
||
|
||
func writeJSON(writer http.ResponseWriter, status int, value any) {
|
||
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
writer.WriteHeader(status)
|
||
_ = json.NewEncoder(writer).Encode(value)
|
||
}
|
||
|
||
func writeError(writer http.ResponseWriter, status int, message string) {
|
||
writeJSON(writer, status, map[string]string{"error": message})
|
||
}
|
||
|
||
func cors(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||
writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||
writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Developer-Token")
|
||
writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, OPTIONS")
|
||
if request.Method == http.MethodOptions {
|
||
writer.WriteHeader(http.StatusNoContent)
|
||
return
|
||
}
|
||
next.ServeHTTP(writer, request)
|
||
})
|
||
}
|
||
|
||
func logging(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||
next.ServeHTTP(writer, request)
|
||
})
|
||
}
|