Codevault initial commit
This commit is contained in:
4
internal/api/doc.go
Normal file
4
internal/api/doc.go
Normal file
@@ -0,0 +1,4 @@
|
||||
// Package api contains the OpenAPI-generated GIN strict-server code for CodeVault.
|
||||
package api
|
||||
|
||||
//go:generate oapi-codegen --config=oapi-codegen.yaml openapi.yaml
|
||||
363
internal/api/generated.go
Normal file
363
internal/api/generated.go
Normal file
@@ -0,0 +1,363 @@
|
||||
// Package api provides primitives to interact with the openapi HTTP API.
|
||||
//
|
||||
// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.1 DO NOT EDIT.
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/getkin/kin-openapi/openapi3"
|
||||
"github.com/gin-gonic/gin"
|
||||
strictgin "github.com/oapi-codegen/runtime/strictmiddleware/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
BasicAuthScopes = "basicAuth.Scopes"
|
||||
)
|
||||
|
||||
// GetValueRequest defines model for GetValueRequest.
|
||||
type GetValueRequest struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// GetValueResponse defines model for GetValueResponse.
|
||||
type GetValueResponse struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// SetValueRequest defines model for SetValueRequest.
|
||||
type SetValueRequest struct {
|
||||
Code string `json:"code"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// GetValueJSONRequestBody defines body for GetValue for application/json ContentType.
|
||||
type GetValueJSONRequestBody = GetValueRequest
|
||||
|
||||
// SetValueJSONRequestBody defines body for SetValue for application/json ContentType.
|
||||
type SetValueJSONRequestBody = SetValueRequest
|
||||
|
||||
// ServerInterface represents all server handlers.
|
||||
type ServerInterface interface {
|
||||
|
||||
// (POST /api/v1/getValue)
|
||||
GetValue(c *gin.Context)
|
||||
|
||||
// (POST /api/v1/setValue)
|
||||
SetValue(c *gin.Context)
|
||||
}
|
||||
|
||||
// ServerInterfaceWrapper converts contexts to parameters.
|
||||
type ServerInterfaceWrapper struct {
|
||||
Handler ServerInterface
|
||||
HandlerMiddlewares []MiddlewareFunc
|
||||
ErrorHandler func(*gin.Context, error, int)
|
||||
}
|
||||
|
||||
type MiddlewareFunc func(c *gin.Context)
|
||||
|
||||
// GetValue operation middleware
|
||||
func (siw *ServerInterfaceWrapper) GetValue(c *gin.Context) {
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
middleware(c)
|
||||
if c.IsAborted() {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
siw.Handler.GetValue(c)
|
||||
}
|
||||
|
||||
// SetValue operation middleware
|
||||
func (siw *ServerInterfaceWrapper) SetValue(c *gin.Context) {
|
||||
|
||||
c.Set(BasicAuthScopes, []string{})
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
middleware(c)
|
||||
if c.IsAborted() {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
siw.Handler.SetValue(c)
|
||||
}
|
||||
|
||||
// GinServerOptions provides options for the Gin server.
|
||||
type GinServerOptions struct {
|
||||
BaseURL string
|
||||
Middlewares []MiddlewareFunc
|
||||
ErrorHandler func(*gin.Context, error, int)
|
||||
}
|
||||
|
||||
// RegisterHandlers creates http.Handler with routing matching OpenAPI spec.
|
||||
func RegisterHandlers(router gin.IRouter, si ServerInterface) {
|
||||
RegisterHandlersWithOptions(router, si, GinServerOptions{})
|
||||
}
|
||||
|
||||
// RegisterHandlersWithOptions creates http.Handler with additional options
|
||||
func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options GinServerOptions) {
|
||||
errorHandler := options.ErrorHandler
|
||||
if errorHandler == nil {
|
||||
errorHandler = func(c *gin.Context, err error, statusCode int) {
|
||||
c.JSON(statusCode, gin.H{"msg": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
wrapper := ServerInterfaceWrapper{
|
||||
Handler: si,
|
||||
HandlerMiddlewares: options.Middlewares,
|
||||
ErrorHandler: errorHandler,
|
||||
}
|
||||
|
||||
router.POST(options.BaseURL+"/api/v1/getValue", wrapper.GetValue)
|
||||
router.POST(options.BaseURL+"/api/v1/setValue", wrapper.SetValue)
|
||||
}
|
||||
|
||||
type GetValueRequestObject struct {
|
||||
Body *GetValueJSONRequestBody
|
||||
}
|
||||
|
||||
type GetValueResponseObject interface {
|
||||
VisitGetValueResponse(w http.ResponseWriter) error
|
||||
}
|
||||
|
||||
type GetValue200JSONResponse GetValueResponse
|
||||
|
||||
func (response GetValue200JSONResponse) VisitGetValueResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
|
||||
return json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
type GetValue400Response struct {
|
||||
}
|
||||
|
||||
func (response GetValue400Response) VisitGetValueResponse(w http.ResponseWriter) error {
|
||||
w.WriteHeader(400)
|
||||
return nil
|
||||
}
|
||||
|
||||
type GetValue404Response struct {
|
||||
}
|
||||
|
||||
func (response GetValue404Response) VisitGetValueResponse(w http.ResponseWriter) error {
|
||||
w.WriteHeader(404)
|
||||
return nil
|
||||
}
|
||||
|
||||
type SetValueRequestObject struct {
|
||||
Body *SetValueJSONRequestBody
|
||||
}
|
||||
|
||||
type SetValueResponseObject interface {
|
||||
VisitSetValueResponse(w http.ResponseWriter) error
|
||||
}
|
||||
|
||||
type SetValue204Response struct {
|
||||
}
|
||||
|
||||
func (response SetValue204Response) VisitSetValueResponse(w http.ResponseWriter) error {
|
||||
w.WriteHeader(204)
|
||||
return nil
|
||||
}
|
||||
|
||||
type SetValue400Response struct {
|
||||
}
|
||||
|
||||
func (response SetValue400Response) VisitSetValueResponse(w http.ResponseWriter) error {
|
||||
w.WriteHeader(400)
|
||||
return nil
|
||||
}
|
||||
|
||||
type SetValue401Response struct {
|
||||
}
|
||||
|
||||
func (response SetValue401Response) VisitSetValueResponse(w http.ResponseWriter) error {
|
||||
w.WriteHeader(401)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StrictServerInterface represents all server handlers.
|
||||
type StrictServerInterface interface {
|
||||
|
||||
// (POST /api/v1/getValue)
|
||||
GetValue(ctx context.Context, request GetValueRequestObject) (GetValueResponseObject, error)
|
||||
|
||||
// (POST /api/v1/setValue)
|
||||
SetValue(ctx context.Context, request SetValueRequestObject) (SetValueResponseObject, error)
|
||||
}
|
||||
|
||||
type StrictHandlerFunc = strictgin.StrictGinHandlerFunc
|
||||
type StrictMiddlewareFunc = strictgin.StrictGinMiddlewareFunc
|
||||
|
||||
func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface {
|
||||
return &strictHandler{ssi: ssi, middlewares: middlewares}
|
||||
}
|
||||
|
||||
type strictHandler struct {
|
||||
ssi StrictServerInterface
|
||||
middlewares []StrictMiddlewareFunc
|
||||
}
|
||||
|
||||
// GetValue operation middleware
|
||||
func (sh *strictHandler) GetValue(ctx *gin.Context) {
|
||||
var request GetValueRequestObject
|
||||
|
||||
var body GetValueJSONRequestBody
|
||||
if err := ctx.ShouldBindJSON(&body); err != nil {
|
||||
ctx.Status(http.StatusBadRequest)
|
||||
ctx.Error(err)
|
||||
return
|
||||
}
|
||||
request.Body = &body
|
||||
|
||||
handler := func(ctx *gin.Context, request interface{}) (interface{}, error) {
|
||||
return sh.ssi.GetValue(ctx, request.(GetValueRequestObject))
|
||||
}
|
||||
for _, middleware := range sh.middlewares {
|
||||
handler = middleware(handler, "GetValue")
|
||||
}
|
||||
|
||||
response, err := handler(ctx, request)
|
||||
|
||||
if err != nil {
|
||||
ctx.Error(err)
|
||||
ctx.Status(http.StatusInternalServerError)
|
||||
} else if validResponse, ok := response.(GetValueResponseObject); ok {
|
||||
if err := validResponse.VisitGetValueResponse(ctx.Writer); err != nil {
|
||||
ctx.Error(err)
|
||||
}
|
||||
} else if response != nil {
|
||||
ctx.Error(fmt.Errorf("unexpected response type: %T", response))
|
||||
}
|
||||
}
|
||||
|
||||
// SetValue operation middleware
|
||||
func (sh *strictHandler) SetValue(ctx *gin.Context) {
|
||||
var request SetValueRequestObject
|
||||
|
||||
var body SetValueJSONRequestBody
|
||||
if err := ctx.ShouldBindJSON(&body); err != nil {
|
||||
ctx.Status(http.StatusBadRequest)
|
||||
ctx.Error(err)
|
||||
return
|
||||
}
|
||||
request.Body = &body
|
||||
|
||||
handler := func(ctx *gin.Context, request interface{}) (interface{}, error) {
|
||||
return sh.ssi.SetValue(ctx, request.(SetValueRequestObject))
|
||||
}
|
||||
for _, middleware := range sh.middlewares {
|
||||
handler = middleware(handler, "SetValue")
|
||||
}
|
||||
|
||||
response, err := handler(ctx, request)
|
||||
|
||||
if err != nil {
|
||||
ctx.Error(err)
|
||||
ctx.Status(http.StatusInternalServerError)
|
||||
} else if validResponse, ok := response.(SetValueResponseObject); ok {
|
||||
if err := validResponse.VisitSetValueResponse(ctx.Writer); err != nil {
|
||||
ctx.Error(err)
|
||||
}
|
||||
} else if response != nil {
|
||||
ctx.Error(fmt.Errorf("unexpected response type: %T", response))
|
||||
}
|
||||
}
|
||||
|
||||
// Base64 encoded, gzipped, json marshaled Swagger object
|
||||
var swaggerSpec = []string{
|
||||
|
||||
"H4sIAAAAAAAC/7STwW7bMAyGX8XgdjRiZ+3Jt26HIYcBwwL0EuSgSEzCwhVVkQ4QFH73QZKTLcuKbgV6",
|
||||
"siH95E9+pJ7B8mNgj14FumcQu8dHk3+/ot6bfsAf+DSgaDoKkQNGJcwCyw7TV48BoQPRSH4H41hDxKeB",
|
||||
"IjroVkW1rk8q3jygVRjr3/JLYC94bXBI1687FNnfLJZvbKH+V+scX79YwViDoB0i6XGZwBbTjRGyd4Pu",
|
||||
"z8BTTD6Fc4q9aoAxZSC/5VwJaZ9uvrDDezP0Wt19XyRzjELsoYP5rJ21qXoO6E0g6OBm1s5uoIZgdJ/N",
|
||||
"GxOoOcyb3YQmM+HCJpExSuwXDrrzfKC0jKKf2R0LNa/oc4gJoSebg5oHSVWcdij9fYy4hQ4+NL+WrJk2",
|
||||
"rPlzvcZLthoHzAdlOXLtn9r2Heyn7cv+DsVGClp4ZkG15cG7RPW22F9qFv5genLVhKjaJEZZfHstLgk9",
|
||||
"6ylpNj2NRF4dyfJ9R7J8y0hebFOUU9j/g5tfi7+RCPldxbGiKc5GdOiVTC8XDw261cUTW63HdeL8MwAA",
|
||||
"//98/iCR6gQAAA==",
|
||||
}
|
||||
|
||||
// GetSwagger returns the content of the embedded swagger specification file
|
||||
// or error if failed to decode
|
||||
func decodeSpec() ([]byte, error) {
|
||||
zipped, err := base64.StdEncoding.DecodeString(strings.Join(swaggerSpec, ""))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error base64 decoding spec: %w", err)
|
||||
}
|
||||
zr, err := gzip.NewReader(bytes.NewReader(zipped))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error decompressing spec: %w", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
_, err = buf.ReadFrom(zr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error decompressing spec: %w", err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
var rawSpec = decodeSpecCached()
|
||||
|
||||
// a naive cached of a decoded swagger spec
|
||||
func decodeSpecCached() func() ([]byte, error) {
|
||||
data, err := decodeSpec()
|
||||
return func() ([]byte, error) {
|
||||
return data, err
|
||||
}
|
||||
}
|
||||
|
||||
// Constructs a synthetic filesystem for resolving external references when loading openapi specifications.
|
||||
func PathToRawSpec(pathToFile string) map[string]func() ([]byte, error) {
|
||||
res := make(map[string]func() ([]byte, error))
|
||||
if len(pathToFile) > 0 {
|
||||
res[pathToFile] = rawSpec
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// GetSwagger returns the Swagger specification corresponding to the generated code
|
||||
// in this file. The external references of Swagger specification are resolved.
|
||||
// The logic of resolving external references is tightly connected to "import-mapping" feature.
|
||||
// Externally referenced files must be embedded in the corresponding golang packages.
|
||||
// Urls can be supported but this task was out of the scope.
|
||||
func GetSwagger() (swagger *openapi3.T, err error) {
|
||||
resolvePath := PathToRawSpec("")
|
||||
|
||||
loader := openapi3.NewLoader()
|
||||
loader.IsExternalRefsAllowed = true
|
||||
loader.ReadFromURIFunc = func(loader *openapi3.Loader, url *url.URL) ([]byte, error) {
|
||||
pathToFile := url.String()
|
||||
pathToFile = path.Clean(pathToFile)
|
||||
getSpec, ok := resolvePath[pathToFile]
|
||||
if !ok {
|
||||
err1 := fmt.Errorf("path not found: %s", pathToFile)
|
||||
return nil, err1
|
||||
}
|
||||
return getSpec()
|
||||
}
|
||||
var specData []byte
|
||||
specData, err = rawSpec()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
swagger, err = loader.LoadFromData(specData)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
55
internal/api/handlers.go
Normal file
55
internal/api/handlers.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
|
||||
"github.com/kanootoko/codevault/internal/db"
|
||||
"github.com/kanootoko/codevault/internal/observability/metrics"
|
||||
)
|
||||
|
||||
// StrictHandler implements StrictServerInterface.
|
||||
type StrictHandler struct {
|
||||
Secrets *db.SecretsRepository
|
||||
Metrics metrics.ValueMetrics
|
||||
}
|
||||
|
||||
func NewHandler(secrets *db.SecretsRepository, m metrics.ValueMetrics) *StrictHandler {
|
||||
return &StrictHandler{Secrets: secrets, Metrics: m}
|
||||
}
|
||||
|
||||
func (h *StrictHandler) GetValue(ctx context.Context, request GetValueRequestObject) (GetValueResponseObject, error) {
|
||||
if request.Body == nil || request.Body.Code == "" {
|
||||
return GetValue400Response{}, nil
|
||||
}
|
||||
|
||||
value, err := h.Secrets.Get(ctx, request.Body.Code)
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
h.Metrics.GetFailedTotal.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", "not_found")))
|
||||
return GetValue404Response{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
h.Metrics.GetSuccessTotal.Add(ctx, 1, metric.WithAttributes(attribute.String("code", request.Body.Code)))
|
||||
return GetValue200JSONResponse{Value: value}, nil
|
||||
}
|
||||
|
||||
func (h *StrictHandler) SetValue(ctx context.Context, request SetValueRequestObject) (SetValueResponseObject, error) {
|
||||
if request.Body == nil || request.Body.Code == "" || request.Body.Value == "" || len(request.Body.Value) > 10*1024*1024 {
|
||||
h.Metrics.SetFailedTotal.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", "bad_request")))
|
||||
return SetValue400Response{}, nil
|
||||
}
|
||||
|
||||
if err := h.Secrets.Set(ctx, request.Body.Code, request.Body.Value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
login, _ := LoginFromContext(ctx)
|
||||
h.Metrics.SetSuccessTotal.Add(ctx, 1, metric.WithAttributes(attribute.String("user", login)))
|
||||
return SetValue204Response{}, nil
|
||||
}
|
||||
81
internal/api/middleware.go
Normal file
81
internal/api/middleware.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
|
||||
"github.com/kanootoko/codevault/internal/db"
|
||||
"github.com/kanootoko/codevault/internal/observability/metrics"
|
||||
"github.com/kanootoko/codevault/internal/ratelimit"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const loginContextKey contextKey = "login"
|
||||
|
||||
// LoginFromContext returns the Basic Auth login stashed by BasicAuthMiddleware,
|
||||
// if any. Strict-server handlers only see context.Context, not *gin.Context, so
|
||||
// this is how they recover the authenticated login.
|
||||
func LoginFromContext(ctx context.Context) (string, bool) {
|
||||
login, ok := ctx.Value(loginContextKey).(string)
|
||||
return login, ok
|
||||
}
|
||||
|
||||
// BasicAuthMiddleware checks the request's HTTP Basic Auth credentials against
|
||||
// the DB-backed user store.
|
||||
func BasicAuthMiddleware(users *db.UsersRepository, vm metrics.ValueMetrics) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
login, password, ok := c.Request.BasicAuth()
|
||||
if !ok || users.Verify(c.Request.Context(), login, password) != nil {
|
||||
vm.SetFailedTotal.Add(c.Request.Context(), 1, metric.WithAttributes(attribute.String("reason", "unauthorized")))
|
||||
c.Header("WWW-Authenticate", `Basic realm="codevault"`)
|
||||
c.AbortWithStatus(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), loginContextKey, login))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RateLimitMiddleware short-circuits to 404 once the client IP has accumulated
|
||||
// too many failures, without touching the handler (and thus the database). A
|
||||
// short-circuited request counts as a failure itself, same as a genuine 404 from
|
||||
// the handler.
|
||||
func RateLimitMiddleware(limiter *ratelimit.Limiter, vm metrics.ValueMetrics) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ip := c.ClientIP()
|
||||
if !limiter.Allowed(ip) {
|
||||
limiter.RecordFailure(ip)
|
||||
vm.GetFailedTotal.Add(c.Request.Context(), 1, metric.WithAttributes(attribute.String("reason", "rate_limited")))
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
|
||||
if c.Writer.Status() == http.StatusNotFound {
|
||||
limiter.RecordFailure(ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// InstrumentMiddleware records request count and duration metrics per route.
|
||||
func InstrumentMiddleware(route string, m metrics.HTTPMetrics) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
c.Next()
|
||||
duration := time.Since(start).Seconds()
|
||||
|
||||
attrs := metric.WithAttributes(
|
||||
attribute.String("route", route),
|
||||
attribute.Int("status", c.Writer.Status()),
|
||||
)
|
||||
m.RequestsTotal.Add(c.Request.Context(), 1, attrs)
|
||||
m.RequestDuration.Record(c.Request.Context(), duration, metric.WithAttributes(attribute.String("route", route)))
|
||||
}
|
||||
}
|
||||
7
internal/api/oapi-codegen.yaml
Normal file
7
internal/api/oapi-codegen.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
package: api
|
||||
generate:
|
||||
gin-server: true
|
||||
strict-server: true
|
||||
models: true
|
||||
embedded-spec: true
|
||||
output: generated.go
|
||||
73
internal/api/openapi.yaml
Normal file
73
internal/api/openapi.yaml
Normal file
@@ -0,0 +1,73 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: CodeVault API
|
||||
version: "1.0.0"
|
||||
paths:
|
||||
/api/v1/getValue:
|
||||
post:
|
||||
operationId: getValue
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GetValueRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Value found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GetValueResponse'
|
||||
'400':
|
||||
description: Invalid request body
|
||||
'404':
|
||||
description: Value not found
|
||||
/api/v1/setValue:
|
||||
post:
|
||||
operationId: setValue
|
||||
security:
|
||||
- basicAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SetValueRequest'
|
||||
responses:
|
||||
'204':
|
||||
description: Value stored
|
||||
'400':
|
||||
description: Invalid request body
|
||||
'401':
|
||||
description: Missing or invalid credentials
|
||||
components:
|
||||
securitySchemes:
|
||||
basicAuth:
|
||||
type: http
|
||||
scheme: basic
|
||||
schemas:
|
||||
GetValueRequest:
|
||||
type: object
|
||||
required:
|
||||
- code
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
GetValueResponse:
|
||||
type: object
|
||||
required:
|
||||
- value
|
||||
properties:
|
||||
value:
|
||||
type: string
|
||||
SetValueRequest:
|
||||
type: object
|
||||
required:
|
||||
- code
|
||||
- value
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
value:
|
||||
type: string
|
||||
35
internal/api/swagger.go
Normal file
35
internal/api/swagger.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
swaggerFiles "github.com/swaggo/files"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
)
|
||||
|
||||
// RegisterSwaggerHandlers serves the OpenAPI spec and an interactive Swagger UI
|
||||
// (embedded swagger-ui-dist assets, no CDN) under /api.
|
||||
func RegisterSwaggerHandlers(router *gin.Engine) error {
|
||||
spec, err := GetSwagger()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error on building swagger specification: %w", err)
|
||||
}
|
||||
specBody, err := spec.MarshalJSON()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error on marshaling swagger specification: %w", err)
|
||||
}
|
||||
|
||||
// /api/docs/*any (rather than /api/*any) so this wildcard doesn't collide with
|
||||
// the static /api/openapi.json route at the same tree level.
|
||||
router.GET("/api/openapi.json", func(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "application/json", specBody)
|
||||
})
|
||||
router.GET("/api", func(c *gin.Context) {
|
||||
c.Redirect(http.StatusFound, "/api/docs/index.html")
|
||||
})
|
||||
router.GET("/api/docs/*any", ginSwagger.WrapHandler(swaggerFiles.Handler, ginSwagger.URL("/api/openapi.json")))
|
||||
|
||||
return nil
|
||||
}
|
||||
37
internal/auth/password.go
Normal file
37
internal/auth/password.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// saltedInput mixes the login into the password material before bcrypt hashing, using
|
||||
// the login as a per-user salt: a leaked hash can't be replayed against a different
|
||||
// login, and pre-hashing keeps the input short regardless of login/password length
|
||||
// (bcrypt only considers the first 72 bytes of its input).
|
||||
func saltedInput(login, password string) []byte {
|
||||
sum := sha256.Sum256([]byte(login + "\x00" + password))
|
||||
return []byte(base64.StdEncoding.EncodeToString(sum[:]))
|
||||
}
|
||||
|
||||
// dummyHash is compared against on unknown logins so that a failed lookup takes
|
||||
// roughly the same time as a real password comparison, avoiding user enumeration.
|
||||
var dummyHash, _ = bcrypt.GenerateFromPassword(saltedInput("dummy-login", "dummy-password"), bcrypt.DefaultCost)
|
||||
|
||||
func Hash(login, password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword(saltedInput(login, password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
func Compare(login, hash, password string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), saltedInput(login, password)) == nil
|
||||
}
|
||||
|
||||
func CompareDummy() {
|
||||
_ = bcrypt.CompareHashAndPassword(dummyHash, saltedInput("dummy-login", "dummy-password"))
|
||||
}
|
||||
74
internal/config/config.go
Normal file
74
internal/config/config.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/multierr"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var ErrLoggingDisabled error = errors.New("logging is disabled")
|
||||
|
||||
type Config struct {
|
||||
Environment Environment `yaml:"environment"`
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
RateLimit RateLimitConfig `yaml:"rate_limit"`
|
||||
Observability ObservabilityConfig `yaml:"observability"`
|
||||
}
|
||||
|
||||
func (c *Config) Validate() error {
|
||||
var err error
|
||||
|
||||
err = addValidationError(err, "environment validation error", c.Environment.PreprocessAndValidate())
|
||||
err = addValidationError(err, "server section validation error", c.Server.Validate())
|
||||
err = addValidationError(err, "database section validation error", c.Database.Validate())
|
||||
err = addValidationError(err, "rate_limit section validation error", c.RateLimit.Validate())
|
||||
err = addValidationError(err, "observability section validation error", c.Observability.Validate())
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func ReadConfig(path string) (Config, error) {
|
||||
var c Config
|
||||
yamlFile, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return c, fmt.Errorf("could not read config: %w", err)
|
||||
}
|
||||
err = yaml.Unmarshal(yamlFile, &c)
|
||||
if err != nil {
|
||||
return c, fmt.Errorf("could not parse yaml: %w", err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
type Environment string
|
||||
|
||||
var (
|
||||
ProductionEnvironment Environment = "production"
|
||||
DevelopmentEnvironment Environment = "development"
|
||||
)
|
||||
|
||||
func (e *Environment) PreprocessAndValidate() error {
|
||||
preprocessed := strings.ToLower(string(*e))
|
||||
switch preprocessed {
|
||||
case string(ProductionEnvironment), "prod":
|
||||
*e = ProductionEnvironment
|
||||
return nil
|
||||
case string(DevelopmentEnvironment), "dev":
|
||||
*e = DevelopmentEnvironment
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid Environment value: '%v'", *e)
|
||||
}
|
||||
}
|
||||
|
||||
func addValidationError(err error, text string, validationError error) error {
|
||||
if validationError == nil {
|
||||
return err
|
||||
}
|
||||
return multierr.Append(err, fmt.Errorf("%s: %w", text, validationError))
|
||||
}
|
||||
14
internal/config/database.go
Normal file
14
internal/config/database.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package config
|
||||
|
||||
import "fmt"
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Path string `yaml:"path"`
|
||||
}
|
||||
|
||||
func (dc *DatabaseConfig) Validate() error {
|
||||
if dc.Path == "" {
|
||||
return fmt.Errorf("path is missing")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
131
internal/config/observability.go
Normal file
131
internal/config/observability.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
type ObservabilityConfig struct {
|
||||
Logging LoggingConfig `yaml:"logging"`
|
||||
Metrics MetricsConfig `yaml:"metrics"`
|
||||
}
|
||||
|
||||
func (oc *ObservabilityConfig) Validate() error {
|
||||
var err error
|
||||
|
||||
err = addValidationError(err, "logging section validation error", oc.Logging.Validate())
|
||||
err = addValidationError(err, "metrics section validation error", oc.Metrics.Validate())
|
||||
return err
|
||||
}
|
||||
|
||||
type LoggingConfig struct {
|
||||
Stderr LoggingLevel `yaml:"stderr"`
|
||||
Files map[LoggingLevel]string `yaml:"files"`
|
||||
Exporter LogsExporterConfig `yaml:"exporter"`
|
||||
}
|
||||
|
||||
func (lc *LoggingConfig) Validate() error {
|
||||
var err error
|
||||
|
||||
_, loggingErr := lc.Stderr.ToZapLevel()
|
||||
if !errors.Is(loggingErr, ErrLoggingDisabled) {
|
||||
err = addValidationError(err, "stderr logging level validation error", loggingErr)
|
||||
}
|
||||
for level, filename := range lc.Files {
|
||||
_, loggingErr = level.ToZapLevel()
|
||||
err = addValidationError(err, fmt.Sprintf("logging level validation error for file '%s'", filename), loggingErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type LogsExporterConfig struct {
|
||||
Level LoggingLevel `yaml:"level"`
|
||||
Endpoint string `yaml:"endpoint"`
|
||||
Transport OTLPTransportType `yaml:"transport"`
|
||||
}
|
||||
|
||||
func (lec *LogsExporterConfig) Validate() error {
|
||||
var err error
|
||||
|
||||
_, loggingErr := lec.Level.ToZapLevel()
|
||||
if errors.Is(loggingErr, ErrLoggingDisabled) {
|
||||
return nil
|
||||
}
|
||||
err = addValidationError(err, "logging level validation error", loggingErr)
|
||||
if !strings.HasPrefix(lec.Endpoint, "http") {
|
||||
return fmt.Errorf("endpoint must has http(s) prefix")
|
||||
}
|
||||
err = addValidationError(err, "transport type validation error", lec.Transport.PreprocessAndValidate())
|
||||
return err
|
||||
}
|
||||
|
||||
type OTLPTransportType string
|
||||
|
||||
var (
|
||||
GRPCTransportType OTLPTransportType = "grpc"
|
||||
HTTPTransportType OTLPTransportType = "http"
|
||||
)
|
||||
|
||||
func (otp *OTLPTransportType) PreprocessAndValidate() error {
|
||||
if *otp == "" {
|
||||
*otp = GRPCTransportType
|
||||
return nil
|
||||
}
|
||||
preprocessed := OTLPTransportType(strings.ToLower(string(*otp)))
|
||||
switch preprocessed {
|
||||
case GRPCTransportType, HTTPTransportType:
|
||||
*otp = preprocessed
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid OTLPTransportType: '%v'", *otp)
|
||||
}
|
||||
}
|
||||
|
||||
type MetricsConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
}
|
||||
|
||||
func (mc *MetricsConfig) Validate() error {
|
||||
if mc.Host == "" {
|
||||
return nil
|
||||
}
|
||||
if mc.Port < 1 || mc.Port > 65535 {
|
||||
return fmt.Errorf("port value is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type LoggingLevel string
|
||||
|
||||
var (
|
||||
DebugLoggingLevel LoggingLevel = "DEBUG"
|
||||
InfoLoggingLevel LoggingLevel = "INFO"
|
||||
WarningLoggingLevel LoggingLevel = "WARNING"
|
||||
ErrorLoggingLevel LoggingLevel = "ERROR"
|
||||
PanicLoggingLevel LoggingLevel = "PANIC"
|
||||
FatalLoggingLevel LoggingLevel = "FATAL"
|
||||
)
|
||||
|
||||
func (l LoggingLevel) ToZapLevel() (zapcore.Level, error) {
|
||||
if l == "" {
|
||||
return zapcore.InvalidLevel, ErrLoggingDisabled
|
||||
}
|
||||
levelString := strings.ToLower(string(l))
|
||||
mapping := map[string]zapcore.Level{
|
||||
"debug": zapcore.DebugLevel,
|
||||
"info": zapcore.InfoLevel,
|
||||
"warn": zapcore.WarnLevel,
|
||||
"warning": zapcore.WarnLevel,
|
||||
"error": zapcore.ErrorLevel,
|
||||
"panic": zapcore.PanicLevel,
|
||||
"fatal": zapcore.FatalLevel,
|
||||
}
|
||||
if level, ok := mapping[levelString]; ok {
|
||||
return level, nil
|
||||
}
|
||||
return zapcore.InvalidLevel, fmt.Errorf("invalid logging level value: %v", l)
|
||||
}
|
||||
21
internal/config/ratelimit.go
Normal file
21
internal/config/ratelimit.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RateLimitConfig struct {
|
||||
MaxFailures int `yaml:"max_failures"`
|
||||
Window time.Duration `yaml:"window"`
|
||||
}
|
||||
|
||||
func (rc *RateLimitConfig) Validate() error {
|
||||
if rc.MaxFailures <= 0 {
|
||||
return fmt.Errorf("max_failures must be positive")
|
||||
}
|
||||
if rc.Window <= 0 {
|
||||
return fmt.Errorf("window must be positive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
37
internal/config/server.go
Normal file
37
internal/config/server.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
|
||||
"go.uber.org/multierr"
|
||||
)
|
||||
|
||||
type ServerConfig struct {
|
||||
Address string `yaml:"address"`
|
||||
|
||||
// TrustedProxies lists IPs/CIDRs (e.g. of a fronting nginx) allowed to set
|
||||
// X-Forwarded-For/X-Real-IP when gin resolves the client IP. Empty means no
|
||||
// forwarded header is trusted, and the raw remote address is used instead.
|
||||
TrustedProxies []string `yaml:"trusted_proxies"`
|
||||
}
|
||||
|
||||
func (sc *ServerConfig) Validate() error {
|
||||
var err error
|
||||
|
||||
if sc.Address == "" {
|
||||
err = multierr.Append(err, fmt.Errorf("address is missing"))
|
||||
}
|
||||
|
||||
for _, proxy := range sc.TrustedProxies {
|
||||
if _, parseErr := netip.ParsePrefix(proxy); parseErr == nil {
|
||||
continue
|
||||
}
|
||||
if _, parseErr := netip.ParseAddr(proxy); parseErr == nil {
|
||||
continue
|
||||
}
|
||||
err = multierr.Append(err, fmt.Errorf("invalid trusted_proxies entry %q: must be an IP or CIDR", proxy))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
30
internal/db/db.go
Normal file
30
internal/db/db.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
//go:embed schema.sql
|
||||
var schema string
|
||||
|
||||
// Open opens (creating the file if it does not exist) the SQLite database at path
|
||||
// and applies the embedded schema.
|
||||
func Open(path string) (*sql.DB, error) {
|
||||
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)", path)
|
||||
database, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not open database: %w", err)
|
||||
}
|
||||
database.SetMaxOpenConns(1)
|
||||
|
||||
if _, err := database.Exec(schema); err != nil {
|
||||
_ = database.Close()
|
||||
return nil, fmt.Errorf("could not apply schema: %w", err)
|
||||
}
|
||||
|
||||
return database, nil
|
||||
}
|
||||
12
internal/db/schema.sql
Normal file
12
internal/db/schema.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
login TEXT PRIMARY KEY,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS secrets (
|
||||
code TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
42
internal/db/secrets.go
Normal file
42
internal/db/secrets.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("secret not found")
|
||||
|
||||
type SecretsRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewSecretsRepository(db *sql.DB) *SecretsRepository {
|
||||
return &SecretsRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *SecretsRepository) Get(ctx context.Context, code string) (string, error) {
|
||||
var value string
|
||||
err := r.db.QueryRowContext(ctx, "SELECT value FROM secrets WHERE code = ?", code).Scan(&value)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not get secret: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (r *SecretsRepository) Set(ctx context.Context, code, value string) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO secrets (code, value, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(code) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP
|
||||
`, code, value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not set secret: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
51
internal/db/users.go
Normal file
51
internal/db/users.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/kanootoko/codevault/internal/auth"
|
||||
)
|
||||
|
||||
var ErrInvalidCredentials = errors.New("invalid login or password")
|
||||
|
||||
type UsersRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewUsersRepository(db *sql.DB) *UsersRepository {
|
||||
return &UsersRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *UsersRepository) Upsert(ctx context.Context, login, passwordHash string) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO users (login, password_hash)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(login) DO UPDATE SET password_hash = excluded.password_hash
|
||||
`, login, passwordHash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not upsert user: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verify checks the given login/password pair against the stored hash. A dummy hash
|
||||
// comparison is performed for unknown logins so that lookup timing does not reveal
|
||||
// whether the login exists.
|
||||
func (r *UsersRepository) Verify(ctx context.Context, login, password string) error {
|
||||
var passwordHash string
|
||||
err := r.db.QueryRowContext(ctx, "SELECT password_hash FROM users WHERE login = ?", login).Scan(&passwordHash)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
auth.CompareDummy()
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not get user: %w", err)
|
||||
}
|
||||
if !auth.Compare(login, passwordHash, password) {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
return nil
|
||||
}
|
||||
116
internal/httpserver/server.go
Normal file
116
internal/httpserver/server.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// Package httpserver assembles the gin engine (API routes + web UI) and runs it
|
||||
// with graceful shutdown.
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/kanootoko/codevault/internal/api"
|
||||
"github.com/kanootoko/codevault/internal/db"
|
||||
"github.com/kanootoko/codevault/internal/observability/logging"
|
||||
"github.com/kanootoko/codevault/internal/observability/metrics"
|
||||
"github.com/kanootoko/codevault/internal/ratelimit"
|
||||
"github.com/kanootoko/codevault/internal/utils/gshutdown"
|
||||
"github.com/kanootoko/codevault/internal/webui"
|
||||
)
|
||||
|
||||
const shutdownTimeout = 10 * time.Second
|
||||
|
||||
const cleanupInterval = time.Minute
|
||||
|
||||
func New(
|
||||
secrets *db.SecretsRepository,
|
||||
users *db.UsersRepository,
|
||||
limiter *ratelimit.Limiter,
|
||||
m metrics.Metrics,
|
||||
trustedProxies []string,
|
||||
) (*gin.Engine, error) {
|
||||
router := gin.New()
|
||||
// Strict-server handlers only see context.Context (via *gin.Context, which
|
||||
// implements it), not *gin.Context directly. Without this flag, gin.Context.Value
|
||||
// never falls back to c.Request.Context(), so values stashed there by middleware
|
||||
// (e.g. LoginFromContext) would be invisible to handlers.
|
||||
router.ContextWithFallback = true
|
||||
router.Use(gin.Recovery())
|
||||
if err := router.SetTrustedProxies(trustedProxies); err != nil {
|
||||
return nil, fmt.Errorf("error on setting trusted proxies: %w", err)
|
||||
}
|
||||
|
||||
router.GET("/", func(c *gin.Context) { serveStatic(c, webui.GetPagePath) })
|
||||
router.GET("/set", func(c *gin.Context) { serveStatic(c, webui.SetPagePath) })
|
||||
|
||||
if err := api.RegisterSwaggerHandlers(router); err != nil {
|
||||
return nil, fmt.Errorf("error on registering swagger handlers: %w", err)
|
||||
}
|
||||
|
||||
handler := api.NewHandler(secrets, m.Value)
|
||||
strictHandler := api.NewStrictHandler(handler, nil)
|
||||
wrapper := api.ServerInterfaceWrapper{Handler: strictHandler}
|
||||
|
||||
router.POST("/api/v1/getValue",
|
||||
api.InstrumentMiddleware("getValue", m.HTTP),
|
||||
api.RateLimitMiddleware(limiter, m.Value),
|
||||
wrapper.GetValue,
|
||||
)
|
||||
router.POST("/api/v1/setValue",
|
||||
api.InstrumentMiddleware("setValue", m.HTTP),
|
||||
api.BasicAuthMiddleware(users, m.Value),
|
||||
wrapper.SetValue,
|
||||
)
|
||||
|
||||
return router, nil
|
||||
}
|
||||
|
||||
func serveStatic(c *gin.Context, path string) {
|
||||
data, err := webui.Static.ReadFile(path)
|
||||
if err != nil {
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
|
||||
}
|
||||
|
||||
// Run starts the HTTP server and the rate limiter's cleanup goroutine, both
|
||||
// tracked as gshutdown subtasks, and blocks until ctx is done.
|
||||
func Run(ctx context.Context, address string, router *gin.Engine, limiter *ratelimit.Limiter) error {
|
||||
logger := logging.LoggerMustFromContext(ctx)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: address,
|
||||
Handler: router,
|
||||
}
|
||||
|
||||
_ = gshutdown.SubtaskStarted(ctx, "http-server")
|
||||
go func() {
|
||||
defer func() { _ = gshutdown.SubtaskFinished(ctx, "http-server") }()
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
logger.Error("error on http server shutdown", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
cleanupDone := make(chan struct{})
|
||||
_ = gshutdown.SubtaskStarted(ctx, "ratelimit-cleanup")
|
||||
go func() {
|
||||
defer func() { _ = gshutdown.SubtaskFinished(ctx, "ratelimit-cleanup") }()
|
||||
limiter.Run(cleanupDone, cleanupInterval)
|
||||
}()
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
close(cleanupDone)
|
||||
}()
|
||||
|
||||
logger.Info("starting http server", zap.String("address", address))
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
return fmt.Errorf("http server error: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
139
internal/observability/logging/configure.go
Normal file
139
internal/observability/logging/configure.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/contrib/bridges/otelzap"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp"
|
||||
"go.opentelemetry.io/otel/log/global"
|
||||
"go.opentelemetry.io/otel/sdk/log"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
|
||||
"github.com/kanootoko/codevault/internal"
|
||||
"github.com/kanootoko/codevault/internal/config"
|
||||
"github.com/kanootoko/codevault/internal/observability"
|
||||
)
|
||||
|
||||
func ConfigureLogger(ctx context.Context, cfg config.LoggingConfig, isProductionEnv bool) (logger *zap.Logger, closeFunc func(), err error) {
|
||||
stderrLevel, err := cfg.Stderr.ToZapLevel()
|
||||
if err != nil && !errors.Is(err, config.ErrLoggingDisabled) {
|
||||
return nil, nil, fmt.Errorf("error on getting stderr logging level: %w", err)
|
||||
}
|
||||
fileLoggers := make(map[string]zapcore.Level)
|
||||
for level, file := range cfg.Files {
|
||||
l, err := level.ToZapLevel()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("error on getting file logging level: %w", err)
|
||||
}
|
||||
fileLoggers[file] = l
|
||||
}
|
||||
var stderrEncoder zapcore.Encoder
|
||||
var fileEncoder zapcore.Encoder
|
||||
{
|
||||
var fileLoggerCfg zapcore.EncoderConfig
|
||||
var stderrLoggerCfg zapcore.EncoderConfig
|
||||
if isProductionEnv {
|
||||
fileLoggerCfg = zap.NewProductionEncoderConfig()
|
||||
stderrLoggerCfg = zap.NewProductionEncoderConfig()
|
||||
} else {
|
||||
fileLoggerCfg = zap.NewDevelopmentEncoderConfig()
|
||||
stderrLoggerCfg = zap.NewDevelopmentEncoderConfig()
|
||||
}
|
||||
fileLoggerCfg.TimeKey = "timestamp"
|
||||
fileLoggerCfg.EncodeTime = zapcore.ISO8601TimeEncoder
|
||||
|
||||
stderrLoggerCfg.EncodeLevel = zapcore.CapitalColorLevelEncoder
|
||||
|
||||
stderrEncoder = zapcore.NewConsoleEncoder(stderrLoggerCfg)
|
||||
fileEncoder = zapcore.NewJSONEncoder(fileLoggerCfg)
|
||||
}
|
||||
|
||||
fileHandlers := make([]*os.File, 0, len(fileLoggers))
|
||||
zapWriters := make([]zapcore.Core, 0, len(fileLoggers)+1)
|
||||
for filename, level := range fileLoggers {
|
||||
fileHandler, err := os.Create(filename)
|
||||
if err != nil {
|
||||
for _, file := range fileHandlers[:len(fileHandlers)-1] {
|
||||
_ = file.Close()
|
||||
}
|
||||
return nil, nil, fmt.Errorf("error on log file creation: %w", err)
|
||||
}
|
||||
fileHandlers = append(fileHandlers, fileHandler)
|
||||
fileSync := zapcore.AddSync(fileHandler)
|
||||
zapWriters = append(zapWriters, zapcore.NewCore(fileEncoder, fileSync, zap.NewAtomicLevelAt(level)))
|
||||
}
|
||||
|
||||
stderrSync := zapcore.AddSync(os.Stderr)
|
||||
if stderrLevel != zapcore.InvalidLevel {
|
||||
zapWriters = append(zapWriters, zapcore.NewCore(stderrEncoder, stderrSync, zap.NewAtomicLevelAt(stderrLevel)))
|
||||
}
|
||||
|
||||
var otlpCloseFunc func()
|
||||
if cfg.Exporter.Level != "" {
|
||||
var otlpWriter zapcore.Core
|
||||
var err error
|
||||
otlpWriter, otlpCloseFunc, err = configureOtelLogging(ctx, cfg.Exporter)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("cannot configure OpenTelemetry log exporter: %w", err)
|
||||
}
|
||||
zapWriters = append(zapWriters, otlpWriter)
|
||||
}
|
||||
|
||||
core := zapcore.NewTee(
|
||||
zapWriters...,
|
||||
)
|
||||
|
||||
logger = zap.New(core)
|
||||
|
||||
closeFunc = func() {
|
||||
_ = logger.Sync()
|
||||
for _, file := range fileHandlers {
|
||||
_ = file.Close()
|
||||
}
|
||||
if otlpCloseFunc != nil {
|
||||
otlpCloseFunc()
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("configured logging", zap.Any("config", cfg))
|
||||
return
|
||||
}
|
||||
|
||||
func configureOtelLogging(ctx context.Context, cfg config.LogsExporterConfig) (zapcore.Core, func(), error) {
|
||||
var exporter log.Exporter
|
||||
var err error
|
||||
if cfg.Transport == config.HTTPTransportType {
|
||||
exporter, err = otlploghttp.New(ctx, otlploghttp.WithEndpointURL(cfg.Endpoint))
|
||||
} else {
|
||||
exporter, err = otlploggrpc.New(ctx, otlploggrpc.WithEndpointURL(cfg.Endpoint))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("cannot create OpenTelemetry logs exporter: %w", err)
|
||||
}
|
||||
res, err := observability.GetOtelResource()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("cannot get OpenTelemetry resource: %w", err)
|
||||
}
|
||||
|
||||
provider := log.NewLoggerProvider(
|
||||
log.WithProcessor(log.NewBatchProcessor(exporter)),
|
||||
log.WithResource(res),
|
||||
)
|
||||
|
||||
closeFunc := func() {
|
||||
shutdownContext, cancel := context.WithTimeout(context.Background(), time.Millisecond*300)
|
||||
defer cancel()
|
||||
_ = provider.ForceFlush(shutdownContext)
|
||||
_ = provider.Shutdown(shutdownContext)
|
||||
<-shutdownContext.Done()
|
||||
}
|
||||
global.SetLoggerProvider(provider)
|
||||
|
||||
return otelzap.NewCore(internal.PackageName, otelzap.WithLoggerProvider(provider)), closeFunc, nil
|
||||
}
|
||||
41
internal/observability/logging/ctx.go
Normal file
41
internal/observability/logging/ctx.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type loggerKeyType int
|
||||
|
||||
const (
|
||||
loggerKey loggerKeyType = iota
|
||||
)
|
||||
|
||||
var ErrNotInitialized = errors.New("logger has not been initialized in the given context")
|
||||
|
||||
func CtxWithLogger(ctx context.Context, logger *zap.Logger) context.Context {
|
||||
if loggerCtx, ok := ctx.Value(loggerKey).(*zap.Logger); ok {
|
||||
if loggerCtx == logger {
|
||||
return ctx
|
||||
}
|
||||
}
|
||||
return context.WithValue(ctx, loggerKey, logger)
|
||||
}
|
||||
|
||||
func LoggerFromCtx(ctx context.Context) (*zap.Logger, error) {
|
||||
logger := ctx.Value(loggerKey)
|
||||
if logger == nil {
|
||||
return nil, ErrNotInitialized
|
||||
}
|
||||
return logger.(*zap.Logger), nil
|
||||
}
|
||||
|
||||
func LoggerMustFromContext(ctx context.Context) *zap.Logger {
|
||||
logger, err := LoggerFromCtx(ctx)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return logger
|
||||
}
|
||||
83
internal/observability/metrics/configure.go
Normal file
83
internal/observability/metrics/configure.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/exporters/prometheus"
|
||||
otelmetric "go.opentelemetry.io/otel/metric"
|
||||
"go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
|
||||
"github.com/kanootoko/codevault/internal"
|
||||
"github.com/kanootoko/codevault/internal/config"
|
||||
"github.com/kanootoko/codevault/internal/observability"
|
||||
"github.com/kanootoko/codevault/internal/utils/gshutdown"
|
||||
)
|
||||
|
||||
// Configure starts a Prometheus server which serves metrics and finishes on context finish
|
||||
func Configure(ctx context.Context, cfg config.MetricsConfig) error {
|
||||
if cfg.Host == "" {
|
||||
return nil
|
||||
}
|
||||
res, err := observability.GetOtelResource()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot get OpenTelemetry resource: %w", err)
|
||||
}
|
||||
|
||||
meterProvider, err := newMeterProvider(res)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot instanciate OpenTelemetry meter provider: %w", err)
|
||||
}
|
||||
|
||||
otel.SetMeterProvider(meterProvider)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/metrics", promhttp.Handler())
|
||||
server := http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Port),
|
||||
Handler: mux,
|
||||
ReadTimeout: time.Second * 20,
|
||||
ReadHeaderTimeout: time.Second * 10,
|
||||
WriteTimeout: time.Second * 20,
|
||||
}
|
||||
listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", cfg.Host, cfg.Port))
|
||||
if err != nil {
|
||||
return fmt.Errorf("error on creating listener for prometheus metrics: %w", err)
|
||||
}
|
||||
go func() { _ = server.Serve(listener) }()
|
||||
_ = gshutdown.SubtaskStarted(ctx, "prometheus")
|
||||
go func() {
|
||||
defer func() {
|
||||
_ = listener.Close()
|
||||
_ = gshutdown.SubtaskFinished(ctx, "prometheus")
|
||||
}()
|
||||
<-ctx.Done()
|
||||
_ = server.Shutdown(context.Background())
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetMeter() otelmetric.Meter {
|
||||
mp := otel.GetMeterProvider()
|
||||
return mp.Meter(internal.PackageName, otelmetric.WithInstrumentationVersion(internal.Version))
|
||||
}
|
||||
|
||||
func newMeterProvider(res *resource.Resource) (*metric.MeterProvider, error) {
|
||||
metricExporter, err := prometheus.New()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
meterProvider := metric.NewMeterProvider(
|
||||
metric.WithResource(res),
|
||||
metric.WithReader(metricExporter),
|
||||
)
|
||||
return meterProvider, nil
|
||||
}
|
||||
110
internal/observability/metrics/metrics.go
Normal file
110
internal/observability/metrics/metrics.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
|
||||
"github.com/kanootoko/codevault/internal"
|
||||
)
|
||||
|
||||
type Metrics struct {
|
||||
HTTP HTTPMetrics
|
||||
Value ValueMetrics
|
||||
}
|
||||
|
||||
func NewMetrics(meter metric.Meter) Metrics {
|
||||
metrics := Metrics{
|
||||
HTTP: newHTTPMetrics(meter),
|
||||
Value: newValueMetrics(meter),
|
||||
}
|
||||
addObservableMetrics(meter)
|
||||
return metrics
|
||||
}
|
||||
|
||||
type HTTPMetrics struct {
|
||||
// Total HTTP requests with [route, status]
|
||||
RequestsTotal metric.Int64Counter
|
||||
|
||||
// Request duration histogram in seconds with [route]
|
||||
RequestDuration metric.Float64Histogram
|
||||
}
|
||||
|
||||
func newHTTPMetrics(meter metric.Meter) HTTPMetrics {
|
||||
requestsTotal, _ := meter.Int64Counter(
|
||||
"http_requests_total",
|
||||
metric.WithDescription("Total number of HTTP requests"),
|
||||
)
|
||||
requestDuration, _ := meter.Float64Histogram(
|
||||
"http_request_duration_seconds",
|
||||
metric.WithDescription("HTTP request duration histogram in seconds"),
|
||||
metric.WithExplicitBucketBoundaries(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0),
|
||||
metric.WithUnit("s"),
|
||||
)
|
||||
return HTTPMetrics{
|
||||
RequestsTotal: requestsTotal,
|
||||
RequestDuration: requestDuration,
|
||||
}
|
||||
}
|
||||
|
||||
type ValueMetrics struct {
|
||||
// Successful getValue lookups, with [code]. Only successful lookups carry a
|
||||
// code label since the set of stored codes is bounded, unlike guessed/failed ones.
|
||||
GetSuccessTotal metric.Int64Counter
|
||||
|
||||
// Failed getValue lookups, with [reason] (not_found|rate_limited).
|
||||
GetFailedTotal metric.Int64Counter
|
||||
|
||||
// Successful setValue writes, with [user].
|
||||
SetSuccessTotal metric.Int64Counter
|
||||
|
||||
// Failed setValue writes, with [reason] (bad_request|unauthorized).
|
||||
SetFailedTotal metric.Int64Counter
|
||||
}
|
||||
|
||||
func newValueMetrics(meter metric.Meter) ValueMetrics {
|
||||
getSuccessTotal, _ := meter.Int64Counter(
|
||||
"codevault_get_value_success_total",
|
||||
metric.WithDescription("Total number of successful getValue lookups"),
|
||||
)
|
||||
getFailedTotal, _ := meter.Int64Counter(
|
||||
"codevault_get_value_failed_total",
|
||||
metric.WithDescription("Total number of failed getValue lookups"),
|
||||
)
|
||||
setSuccessTotal, _ := meter.Int64Counter(
|
||||
"codevault_set_value_success_total",
|
||||
metric.WithDescription("Total number of successful setValue writes"),
|
||||
)
|
||||
setFailedTotal, _ := meter.Int64Counter(
|
||||
"codevault_set_value_failed_total",
|
||||
metric.WithDescription("Total number of failed setValue writes"),
|
||||
)
|
||||
return ValueMetrics{
|
||||
GetSuccessTotal: getSuccessTotal,
|
||||
GetFailedTotal: getFailedTotal,
|
||||
SetSuccessTotal: setSuccessTotal,
|
||||
SetFailedTotal: setFailedTotal,
|
||||
}
|
||||
}
|
||||
|
||||
func addObservableMetrics(meter metric.Meter) {
|
||||
timeStarted := time.Now()
|
||||
_, _ = meter.Int64ObservableGauge(
|
||||
"startup_time",
|
||||
metric.WithDescription("Startup timestamp"),
|
||||
metric.WithInt64Callback(func(ctx context.Context, io metric.Int64Observer) error {
|
||||
io.Observe(timeStarted.Unix(), metric.WithAttributes(attribute.String("version", internal.Version)))
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
_, _ = meter.Int64ObservableGauge(
|
||||
"last_update_time",
|
||||
metric.WithDescription("Current timestamp"),
|
||||
metric.WithInt64Callback(func(ctx context.Context, io metric.Int64Observer) error {
|
||||
io.Observe(time.Now().Unix(), metric.WithAttributes(attribute.String("version", internal.Version)))
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
}
|
||||
18
internal/observability/otel.go
Normal file
18
internal/observability/otel.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"github.com/kanootoko/codevault/internal"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.43.0"
|
||||
)
|
||||
|
||||
func GetOtelResource() (*resource.Resource, error) {
|
||||
return resource.Merge(
|
||||
resource.Default(),
|
||||
resource.NewWithAttributes(
|
||||
semconv.SchemaURL,
|
||||
semconv.ServiceName(internal.PackageName),
|
||||
semconv.ServiceVersion(internal.Version),
|
||||
),
|
||||
)
|
||||
}
|
||||
145
internal/ratelimit/limiter.go
Normal file
145
internal/ratelimit/limiter.go
Normal file
@@ -0,0 +1,145 @@
|
||||
// Package ratelimit implements a per-IP sliding-window failure counter used to
|
||||
// hide the database behind a cap on failed lookups.
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type bucket struct {
|
||||
index int64
|
||||
count int
|
||||
}
|
||||
|
||||
type ipState struct {
|
||||
current bucket
|
||||
previous bucket
|
||||
}
|
||||
|
||||
// Limiter is a two-bucket sliding-window counter per IP. Allowed is a pure read
|
||||
// (RLock) that estimates the failure count in the trailing window from whatever
|
||||
// is already stored, without mutating it. RecordFailure takes the write lock and
|
||||
// performs the actual window roll and increment. Keeping the previous window's
|
||||
// count means a client blocked near the end of one window stays throttled into
|
||||
// the next, instead of getting an instant reset at the window boundary.
|
||||
type Limiter struct {
|
||||
mu sync.RWMutex
|
||||
state map[string]ipState
|
||||
window time.Duration
|
||||
max int
|
||||
}
|
||||
|
||||
func NewLimiter(max int, window time.Duration) *Limiter {
|
||||
return &Limiter{
|
||||
state: make(map[string]ipState),
|
||||
window: window,
|
||||
max: max,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Limiter) windowIndex(t time.Time) int64 {
|
||||
return t.UnixNano() / int64(l.window)
|
||||
}
|
||||
|
||||
// Allowed reports whether ip is currently under the failure threshold. It never
|
||||
// mutates the limiter's state.
|
||||
func (l *Limiter) Allowed(ip string) bool {
|
||||
now := time.Now()
|
||||
idx := l.windowIndex(now)
|
||||
|
||||
l.mu.RLock()
|
||||
state, ok := l.state[ip]
|
||||
l.mu.RUnlock()
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
current, previous := effectiveBuckets(state, idx)
|
||||
|
||||
overlap := overlapFraction(now, l.window, previous.index)
|
||||
estimate := float64(current.count) + float64(previous.count)*overlap
|
||||
|
||||
return estimate < float64(l.max)
|
||||
}
|
||||
|
||||
// RecordFailure increments the failure count for ip in the current window,
|
||||
// rolling the previous stored window forward if it has become stale.
|
||||
func (l *Limiter) RecordFailure(ip string) {
|
||||
now := time.Now()
|
||||
idx := l.windowIndex(now)
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
state := l.state[ip]
|
||||
current, previous := effectiveBuckets(state, idx)
|
||||
current.count++
|
||||
l.state[ip] = ipState{current: current, previous: previous}
|
||||
}
|
||||
|
||||
// Cleanup removes IPs whose state is more than 2 windows stale, bounding memory.
|
||||
func (l *Limiter) Cleanup() {
|
||||
idx := l.windowIndex(time.Now())
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
for ip, state := range l.state {
|
||||
if idx-state.current.index > 2 {
|
||||
delete(l.state, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run periodically calls Cleanup until ctx is done.
|
||||
func (l *Limiter) Run(done <-chan struct{}, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
l.Cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// effectiveBuckets derives the current/previous buckets as seen from window idx,
|
||||
// without mutating the stored state: if the stored current bucket is stale by
|
||||
// exactly one window it becomes the effective previous bucket; if it is older
|
||||
// than that, both buckets are treated as empty.
|
||||
func effectiveBuckets(state ipState, idx int64) (current, previous bucket) {
|
||||
switch {
|
||||
case state.current.index == idx:
|
||||
current = state.current
|
||||
if state.previous.index == idx-1 {
|
||||
previous = state.previous
|
||||
} else {
|
||||
previous = bucket{index: idx - 1}
|
||||
}
|
||||
case state.current.index == idx-1:
|
||||
current = bucket{index: idx}
|
||||
previous = state.current
|
||||
default:
|
||||
current = bucket{index: idx}
|
||||
previous = bucket{index: idx - 1}
|
||||
}
|
||||
return current, previous
|
||||
}
|
||||
|
||||
// overlapFraction returns how much of the previous window (identified by
|
||||
// previousIdx) still falls within the trailing lookback window of length
|
||||
// windowSize measured back from now.
|
||||
func overlapFraction(now time.Time, windowSize time.Duration, previousIdx int64) float64 {
|
||||
previousWindowEnd := time.Unix(0, (previousIdx+1)*int64(windowSize))
|
||||
elapsedSincePreviousEnd := now.Sub(previousWindowEnd)
|
||||
if elapsedSincePreviousEnd >= windowSize {
|
||||
return 0
|
||||
}
|
||||
if elapsedSincePreviousEnd <= 0 {
|
||||
return 1
|
||||
}
|
||||
return 1 - float64(elapsedSincePreviousEnd)/float64(windowSize)
|
||||
}
|
||||
101
internal/ratelimit/limiter_test.go
Normal file
101
internal/ratelimit/limiter_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLimiter_AllowsUnderThreshold(t *testing.T) {
|
||||
l := NewLimiter(10, time.Minute)
|
||||
for i := range 9 {
|
||||
if !l.Allowed("1.2.3.4") {
|
||||
t.Fatalf("expected allowed at failure %d", i)
|
||||
}
|
||||
l.RecordFailure("1.2.3.4")
|
||||
}
|
||||
if !l.Allowed("1.2.3.4") {
|
||||
t.Fatalf("expected allowed after 9 failures")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimiter_BlocksAtThreshold(t *testing.T) {
|
||||
l := NewLimiter(10, time.Minute)
|
||||
for range 10 {
|
||||
l.RecordFailure("1.2.3.4")
|
||||
}
|
||||
if l.Allowed("1.2.3.4") {
|
||||
t.Fatalf("expected blocked after 10 failures")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimiter_BlockedClientStaysThrottledAcrossWindowBoundary(t *testing.T) {
|
||||
window := 300 * time.Millisecond
|
||||
l := NewLimiter(10, window)
|
||||
|
||||
for range 20 {
|
||||
l.RecordFailure("1.2.3.4")
|
||||
}
|
||||
if l.Allowed("1.2.3.4") {
|
||||
t.Fatalf("expected blocked immediately after 20 failures")
|
||||
}
|
||||
|
||||
idx := l.windowIndex(time.Now())
|
||||
windowEnd := time.Unix(0, (idx+1)*int64(window))
|
||||
// Sleep just past the window boundary so the failures roll into the previous
|
||||
// bucket, then check that their weighted contribution still blocks the client.
|
||||
time.Sleep(time.Until(windowEnd) + window/10)
|
||||
|
||||
if l.Allowed("1.2.3.4") {
|
||||
t.Fatalf("expected still blocked shortly after the window boundary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimiter_ResetsAfterIdlePeriod(t *testing.T) {
|
||||
window := 50 * time.Millisecond
|
||||
l := NewLimiter(10, window)
|
||||
|
||||
for range 10 {
|
||||
l.RecordFailure("1.2.3.4")
|
||||
}
|
||||
if l.Allowed("1.2.3.4") {
|
||||
t.Fatalf("expected blocked immediately after 10 failures")
|
||||
}
|
||||
|
||||
time.Sleep(window * 3)
|
||||
|
||||
if !l.Allowed("1.2.3.4") {
|
||||
t.Fatalf("expected allowed after idling for 2+ windows")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimiter_Cleanup(t *testing.T) {
|
||||
window := 20 * time.Millisecond
|
||||
l := NewLimiter(10, window)
|
||||
l.RecordFailure("1.2.3.4")
|
||||
|
||||
time.Sleep(window * 4)
|
||||
l.Cleanup()
|
||||
|
||||
l.mu.RLock()
|
||||
_, ok := l.state["1.2.3.4"]
|
||||
l.mu.RUnlock()
|
||||
if ok {
|
||||
t.Fatalf("expected stale IP to be cleaned up")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlapFraction(t *testing.T) {
|
||||
window := 100 * time.Millisecond
|
||||
previousIdx := int64(1000)
|
||||
previousWindowEnd := time.Unix(0, (previousIdx+1)*int64(window))
|
||||
|
||||
if got := overlapFraction(previousWindowEnd.Add(-10*time.Millisecond), window, previousIdx); got != 1 {
|
||||
t.Fatalf("expected overlap 1 before window end, got %v", got)
|
||||
}
|
||||
if got := overlapFraction(previousWindowEnd.Add(20*time.Millisecond), window, previousIdx); got < 0.79 || got > 0.81 {
|
||||
t.Fatalf("expected overlap ~0.8 at 20ms into next window, got %v", got)
|
||||
}
|
||||
if got := overlapFraction(previousWindowEnd.Add(200*time.Millisecond), window, previousIdx); got != 0 {
|
||||
t.Fatalf("expected overlap 0 once fully elapsed, got %v", got)
|
||||
}
|
||||
}
|
||||
32
internal/runcmd/add_user.go
Normal file
32
internal/runcmd/add_user.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package runcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/kanootoko/codevault/internal/auth"
|
||||
"github.com/kanootoko/codevault/internal/db"
|
||||
)
|
||||
|
||||
// AddUser opens (creating if necessary) the database at dbPath and upserts a
|
||||
// user with the given login/password. It intentionally does not depend on the
|
||||
// application config.
|
||||
func AddUser(dbPath, login, password string) error {
|
||||
database, err := db.Open(dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error on opening database: %w", err)
|
||||
}
|
||||
defer func() { _ = database.Close() }()
|
||||
|
||||
hash, err := auth.Hash(login, password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error on hashing password: %w", err)
|
||||
}
|
||||
|
||||
users := db.NewUsersRepository(database)
|
||||
if err := users.Upsert(context.Background(), login, hash); err != nil {
|
||||
return fmt.Errorf("error on upserting user: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
59
internal/runcmd/config_example.go
Normal file
59
internal/runcmd/config_example.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package runcmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/kanootoko/codevault/internal/config"
|
||||
)
|
||||
|
||||
func ConfigExample(configPath string) error {
|
||||
configExample := config.Config{
|
||||
Environment: config.DevelopmentEnvironment,
|
||||
Server: config.ServerConfig{
|
||||
Address: ":8080",
|
||||
// Example: a Docker-network CIDR in front of a reverse proxy nginx.
|
||||
// Replace with the actual proxy's IP/CIDR, or leave empty to trust
|
||||
// no forwarded-for headers.
|
||||
TrustedProxies: []string{"172.16.0.0/12"},
|
||||
},
|
||||
Database: config.DatabaseConfig{
|
||||
Path: "./codevault.db",
|
||||
},
|
||||
RateLimit: config.RateLimitConfig{
|
||||
MaxFailures: 10,
|
||||
Window: time.Minute,
|
||||
},
|
||||
Observability: config.ObservabilityConfig{
|
||||
Logging: config.LoggingConfig{
|
||||
Stderr: config.InfoLoggingLevel,
|
||||
Files: map[config.LoggingLevel]string{config.InfoLoggingLevel: "./info.log"},
|
||||
Exporter: config.LogsExporterConfig{
|
||||
Level: config.InfoLoggingLevel,
|
||||
Endpoint: "http://127.0.0.1:4317/v1/logs",
|
||||
Transport: "grpc",
|
||||
},
|
||||
},
|
||||
Metrics: config.MetricsConfig{
|
||||
Host: "127.0.0.1",
|
||||
Port: 9092,
|
||||
},
|
||||
},
|
||||
}
|
||||
configBytesBuffer := bytes.NewBuffer(nil)
|
||||
encoder := yaml.NewEncoder(configBytesBuffer)
|
||||
encoder.SetIndent(2)
|
||||
err := encoder.Encode(configExample)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot marshal example config: %w", err)
|
||||
}
|
||||
err = os.WriteFile(configPath, configBytesBuffer.Bytes(), os.ModePerm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot write config file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
54
internal/runcmd/run.go
Normal file
54
internal/runcmd/run.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package runcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/kanootoko/codevault/internal/config"
|
||||
"github.com/kanootoko/codevault/internal/db"
|
||||
"github.com/kanootoko/codevault/internal/httpserver"
|
||||
"github.com/kanootoko/codevault/internal/observability/logging"
|
||||
"github.com/kanootoko/codevault/internal/observability/metrics"
|
||||
"github.com/kanootoko/codevault/internal/ratelimit"
|
||||
"github.com/kanootoko/codevault/internal/utils/gshutdown"
|
||||
)
|
||||
|
||||
func RunServer(ctx context.Context, appConfig config.Config) error {
|
||||
logger, err := logging.LoggerFromCtx(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error on getting logger: %w", err)
|
||||
}
|
||||
|
||||
appMetrics := metrics.NewMetrics(metrics.GetMeter())
|
||||
|
||||
database, err := db.Open(appConfig.Database.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error on opening database: %w", err)
|
||||
}
|
||||
_ = gshutdown.SubtaskStarted(ctx, "database")
|
||||
go func() {
|
||||
defer func() { _ = gshutdown.SubtaskFinished(ctx, "database") }()
|
||||
<-ctx.Done()
|
||||
if err := database.Close(); err != nil {
|
||||
logger.Error("error on closing database", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
secrets := db.NewSecretsRepository(database)
|
||||
users := db.NewUsersRepository(database)
|
||||
|
||||
limiter := ratelimit.NewLimiter(appConfig.RateLimit.MaxFailures, appConfig.RateLimit.Window)
|
||||
|
||||
router, err := httpserver.New(secrets, users, limiter, appMetrics, appConfig.Server.TrustedProxies)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error on building http router: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("starting codevault server")
|
||||
if err := httpserver.Run(ctx, appConfig.Server.Address, router, limiter); err != nil {
|
||||
return fmt.Errorf("error on running http server: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
17
internal/utils/env.go
Normal file
17
internal/utils/env.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TryFlagFromEnv(cmd *cobra.Command, flag string, envvar string, required bool) {
|
||||
if value, ok := os.LookupEnv(envvar); ok {
|
||||
_ = cmd.Flags().Set(flag, value)
|
||||
return
|
||||
}
|
||||
if required {
|
||||
cmd.MarkFlagRequired(flag)
|
||||
}
|
||||
}
|
||||
174
internal/utils/gshutdown/graceful_shutdown.go
Normal file
174
internal/utils/gshutdown/graceful_shutdown.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package gshutdown
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotInitialized = errors.New("seems that graceful shutdown has not been initialized")
|
||||
ErrTimeout = errors.New("timeout reached")
|
||||
ErrContextFinished = errors.New("context is finished, unable to add subtasks")
|
||||
ErrContextNotFinished = errors.New("context is not finished, unable to await subtasks")
|
||||
)
|
||||
|
||||
func CtxWithGracefulShutdown(ctx context.Context, logger *zap.Logger) context.Context {
|
||||
if _, ok := ctx.Value(gracefulShutdownKey).(*gracefulShutdownData); ok {
|
||||
return ctx
|
||||
}
|
||||
if logger == nil {
|
||||
logger = zap.L().Named("graceful_shutdown")
|
||||
}
|
||||
data := gracefulShutdownData{
|
||||
subtasks: make(map[string]int),
|
||||
operationsChan: make(chan operation, 1),
|
||||
finishedChan: make(chan struct{}),
|
||||
logger: logger,
|
||||
}
|
||||
ctx = context.WithValue(ctx, gracefulShutdownKey, &data)
|
||||
|
||||
go data.run(ctx)
|
||||
data.logger.Info("initialized")
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
func SubtaskStarted(ctx context.Context, subtaskName string) error {
|
||||
if ctx.Err() != nil {
|
||||
return ErrContextFinished
|
||||
}
|
||||
gs, ok := ctx.Value(gracefulShutdownKey).(*gracefulShutdownData)
|
||||
if !ok {
|
||||
return ErrNotInitialized
|
||||
}
|
||||
gs.operationsChan <- operation{Type: startOperationType, SubtaskName: subtaskName}
|
||||
return nil
|
||||
}
|
||||
|
||||
func SubtaskFinished(ctx context.Context, subtaskName string) error {
|
||||
gs, ok := ctx.Value(gracefulShutdownKey).(*gracefulShutdownData)
|
||||
if !ok {
|
||||
return ErrNotInitialized
|
||||
}
|
||||
gs.operationsChan <- operation{Type: finishOperationType, SubtaskName: subtaskName}
|
||||
return nil
|
||||
}
|
||||
|
||||
func AwaitWithTimeout(ctx context.Context, timeout time.Duration) error {
|
||||
if ctx.Err() == nil {
|
||||
return fmt.Errorf("context is not finished yet")
|
||||
}
|
||||
gs, ok := ctx.Value(gracefulShutdownKey).(*gracefulShutdownData)
|
||||
if !ok {
|
||||
return ErrNotInitialized
|
||||
}
|
||||
timer := time.NewTimer(timeout)
|
||||
select {
|
||||
case <-timer.C:
|
||||
return ErrTimeout
|
||||
case <-gs.finishedChan:
|
||||
return gs.err
|
||||
}
|
||||
}
|
||||
|
||||
type gracefulShutdownKeyType int
|
||||
|
||||
const gracefulShutdownKey gracefulShutdownKeyType = iota
|
||||
|
||||
type gracefulShutdownData struct {
|
||||
mu sync.Mutex
|
||||
subtasks map[string]int
|
||||
operationsChan chan operation
|
||||
finishedChan chan struct{}
|
||||
err error
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
type operationType string
|
||||
|
||||
var (
|
||||
startOperationType operationType = "start"
|
||||
finishOperationType operationType = "finish"
|
||||
)
|
||||
|
||||
type operation struct {
|
||||
Type operationType
|
||||
SubtaskName string
|
||||
}
|
||||
|
||||
func (data *gracefulShutdownData) run(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if data.isDone() {
|
||||
close(data.finishedChan)
|
||||
data.logger.Info("no tasks to be awaited")
|
||||
return
|
||||
}
|
||||
case op := <-data.operationsChan:
|
||||
switch op.Type {
|
||||
case startOperationType:
|
||||
data.acknowledgeSubtask(op.SubtaskName)
|
||||
default:
|
||||
data.finishSubtask(op.SubtaskName)
|
||||
}
|
||||
if data.isDone() {
|
||||
close(data.finishedChan)
|
||||
data.logger.Info("all tasks successfully awaited")
|
||||
return
|
||||
}
|
||||
data.logger.Debug("awaiting in progress", zap.Strings("to_be_awaited", data.toBeAwaited()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (data *gracefulShutdownData) acknowledgeSubtask(subtaskName string) {
|
||||
data.mu.Lock()
|
||||
defer data.mu.Unlock()
|
||||
|
||||
data.subtasks[subtaskName]++
|
||||
data.logger.Info(
|
||||
"successful acknowledge",
|
||||
zap.String("subtask", subtaskName),
|
||||
zap.Int("total_count", data.subtasks[subtaskName]),
|
||||
)
|
||||
}
|
||||
|
||||
func (data *gracefulShutdownData) finishSubtask(subtaskName string) {
|
||||
data.mu.Lock()
|
||||
defer data.mu.Unlock()
|
||||
|
||||
data.subtasks[subtaskName]--
|
||||
data.logger.Info(
|
||||
"successful finish",
|
||||
zap.String("subtask", subtaskName),
|
||||
zap.Int("left_count", data.subtasks[subtaskName]),
|
||||
)
|
||||
if data.subtasks[subtaskName] == 0 {
|
||||
delete(data.subtasks, subtaskName)
|
||||
}
|
||||
}
|
||||
|
||||
func (data *gracefulShutdownData) isDone() bool {
|
||||
data.mu.Lock()
|
||||
defer data.mu.Unlock()
|
||||
return len(data.subtasks) == 0
|
||||
}
|
||||
|
||||
func (data *gracefulShutdownData) toBeAwaited() []string {
|
||||
data.mu.Lock()
|
||||
defer data.mu.Unlock()
|
||||
|
||||
res := make([]string, 0, len(data.subtasks))
|
||||
for subtaskName, count := range data.subtasks {
|
||||
for range count {
|
||||
res = append(res, subtaskName)
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
4
internal/version.go
Normal file
4
internal/version.go
Normal file
@@ -0,0 +1,4 @@
|
||||
package internal
|
||||
|
||||
const Version = "0.1.0"
|
||||
const PackageName = "codevault"
|
||||
12
internal/webui/embed.go
Normal file
12
internal/webui/embed.go
Normal file
@@ -0,0 +1,12 @@
|
||||
// Package webui embeds the static HTML+JS pages served for the get/set flows.
|
||||
package webui
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed static/get.html static/set.html
|
||||
var Static embed.FS
|
||||
|
||||
const (
|
||||
GetPagePath = "static/get.html"
|
||||
SetPagePath = "static/set.html"
|
||||
)
|
||||
48
internal/webui/static/get.html
Normal file
48
internal/webui/static/get.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>CodeVault - Get value</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; max-width: 480px; margin: 4rem auto; }
|
||||
input, button { font-size: 1rem; padding: 0.4rem; }
|
||||
input { width: 100%; box-sizing: border-box; margin-bottom: 0.75rem; }
|
||||
#result { margin-top: 1rem; padding: 0.75rem; border-radius: 4px; white-space: pre-wrap; word-break: break-all; }
|
||||
#result.ok { background: #e6ffed; border: 1px solid #4caf50; }
|
||||
#result.err { background: #ffecec; border: 1px solid #e53935; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Get value by code</h1>
|
||||
<input id="code" type="text" placeholder="Access code" autofocus>
|
||||
<button id="submit">Get value</button>
|
||||
<div id="result"></div>
|
||||
<p><a href="/set">Set a value instead</a></p>
|
||||
<script>
|
||||
document.getElementById('submit').addEventListener('click', async () => {
|
||||
const code = document.getElementById('code').value;
|
||||
const result = document.getElementById('result');
|
||||
result.className = '';
|
||||
result.textContent = '';
|
||||
|
||||
const response = await fetch('/api/v1/getValue', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
result.className = 'ok';
|
||||
result.textContent = data.value;
|
||||
} else if (response.status === 404) {
|
||||
result.className = 'err';
|
||||
result.textContent = 'Not found';
|
||||
} else {
|
||||
result.className = 'err';
|
||||
result.textContent = 'Error: ' + response.status;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
59
internal/webui/static/set.html
Normal file
59
internal/webui/static/set.html
Normal file
@@ -0,0 +1,59 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>CodeVault - Set value</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; max-width: 480px; margin: 4rem auto; }
|
||||
input, button, textarea { font-size: 1rem; padding: 0.4rem; }
|
||||
input, textarea { width: 100%; box-sizing: border-box; margin-bottom: 0.75rem; }
|
||||
#result { margin-top: 1rem; padding: 0.75rem; border-radius: 4px; white-space: pre-wrap; word-break: break-all; }
|
||||
#result.ok { background: #e6ffed; border: 1px solid #4caf50; }
|
||||
#result.err { background: #ffecec; border: 1px solid #e53935; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Set value for a code</h1>
|
||||
<input id="login" type="text" placeholder="Login" autocomplete="username">
|
||||
<input id="password" type="password" placeholder="Password" autocomplete="current-password">
|
||||
<input id="code" type="text" placeholder="Access code">
|
||||
<textarea id="value" rows="4" placeholder="Value"></textarea>
|
||||
<button id="submit">Save</button>
|
||||
<div id="result"></div>
|
||||
<p><a href="/">Get a value instead</a></p>
|
||||
<script>
|
||||
document.getElementById('submit').addEventListener('click', async () => {
|
||||
const login = document.getElementById('login').value;
|
||||
const password = document.getElementById('password').value;
|
||||
const code = document.getElementById('code').value;
|
||||
const value = document.getElementById('value').value;
|
||||
const result = document.getElementById('result');
|
||||
result.className = '';
|
||||
result.textContent = '';
|
||||
|
||||
const response = await fetch('/api/v1/setValue', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Basic ' + btoa(login + ':' + password),
|
||||
},
|
||||
body: JSON.stringify({ code, value }),
|
||||
});
|
||||
|
||||
if (response.status === 204) {
|
||||
result.className = 'ok';
|
||||
result.textContent = 'Saved';
|
||||
} else if (response.status === 401) {
|
||||
result.className = 'err';
|
||||
result.textContent = 'Invalid login or password';
|
||||
} else if (response.status === 400) {
|
||||
result.className = 'err';
|
||||
result.textContent = 'Code and value are required';
|
||||
} else {
|
||||
result.className = 'err';
|
||||
result.textContent = 'Error: ' + response.status;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user