initial commit: Artifacto v0.1
Self-hosted HTML artifact publisher. Single Go binary, SQLite metadata, HTML on disk. Features: password-protected artifacts, per-artifact expiration, admin dashboard with view metrics (privacy-preserving daily-salt visitor hash, sparklines, 30-day SVG chart, top referrers), rate-limited unlock endpoint. Packaged as a Docker image (two-stage, CGO_ENABLED=0, pure-Go SQLite). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
133
internal/auth/admin.go
Normal file
133
internal/auth/admin.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
AdminCookie = "artifacto_admin"
|
||||
adminLifetime = 30 * 24 * time.Hour
|
||||
)
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const ctxAdmin ctxKey = 1
|
||||
|
||||
type Admin struct {
|
||||
passwordHash []byte
|
||||
secret []byte
|
||||
secure bool
|
||||
}
|
||||
|
||||
func NewAdmin(password, secretHex string, secure bool) (*Admin, error) {
|
||||
if password == "" {
|
||||
return nil, errors.New("ADMIN_PASSWORD required")
|
||||
}
|
||||
if len(secretHex) < 32 {
|
||||
return nil, errors.New("SESSION_SECRET must be >= 32 chars")
|
||||
}
|
||||
h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Admin{passwordHash: h, secret: []byte(secretHex), secure: secure}, nil
|
||||
}
|
||||
|
||||
func (a *Admin) VerifyPassword(p string) bool {
|
||||
return bcrypt.CompareHashAndPassword(a.passwordHash, []byte(p)) == nil
|
||||
}
|
||||
|
||||
// Issue returns a signed cookie value "issued_at.sig".
|
||||
func (a *Admin) Issue() string {
|
||||
issued := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
return issued + "." + a.sign("admin|"+issued)
|
||||
}
|
||||
|
||||
func (a *Admin) Verify(raw string) bool {
|
||||
parts := strings.SplitN(raw, ".", 2)
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
issued, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if time.Since(time.Unix(issued, 0)) > adminLifetime {
|
||||
return false
|
||||
}
|
||||
expected := a.sign("admin|" + parts[0])
|
||||
return hmac.Equal([]byte(expected), []byte(parts[1]))
|
||||
}
|
||||
|
||||
func (a *Admin) sign(s string) string {
|
||||
m := hmac.New(sha256.New, a.secret)
|
||||
m.Write([]byte(s))
|
||||
return base64.RawURLEncoding.EncodeToString(m.Sum(nil))
|
||||
}
|
||||
|
||||
func (a *Admin) SetCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: AdminCookie,
|
||||
Value: a.Issue(),
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: a.secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Now().Add(adminLifetime),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Admin) ClearCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: AdminCookie,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: a.secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
// Middleware allows the request through if the admin cookie is valid; otherwise
|
||||
// redirects to /login (for HTML nav) or returns 401 (for API/HTMX).
|
||||
func (a *Admin) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie(AdminCookie)
|
||||
if err != nil || !a.Verify(c.Value) {
|
||||
if isAPI(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/login?next="+r.URL.RequestURI(), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), ctxAdmin, true)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func isAPI(r *http.Request) bool {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
return true
|
||||
}
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func IsAdmin(ctx context.Context) bool {
|
||||
v, _ := ctx.Value(ctxAdmin).(bool)
|
||||
return v
|
||||
}
|
||||
80
internal/auth/artifact.go
Normal file
80
internal/auth/artifact.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const unlockLifetime = 24 * time.Hour
|
||||
|
||||
type Artifact struct {
|
||||
secret []byte
|
||||
secure bool
|
||||
}
|
||||
|
||||
func NewArtifact(secretHex string, secure bool) *Artifact {
|
||||
return &Artifact{secret: []byte(secretHex), secure: secure}
|
||||
}
|
||||
|
||||
func HashPassword(p string) (string, error) {
|
||||
b, err := bcrypt.GenerateFromPassword([]byte(p), bcrypt.DefaultCost)
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
func VerifyPassword(hash, p string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(p)) == nil
|
||||
}
|
||||
|
||||
func unlockCookieName(slug string) string {
|
||||
return "artifacto_unlock_" + slug
|
||||
}
|
||||
|
||||
func (a *Artifact) Issue(slug string) string {
|
||||
issued := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
return issued + "." + a.sign(slug+"|"+issued)
|
||||
}
|
||||
|
||||
func (a *Artifact) SetCookie(w http.ResponseWriter, slug string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: unlockCookieName(slug),
|
||||
Value: a.Issue(slug),
|
||||
Path: "/p/" + slug,
|
||||
HttpOnly: true,
|
||||
Secure: a.secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Now().Add(unlockLifetime),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Artifact) Verify(r *http.Request, slug string) bool {
|
||||
c, err := r.Cookie(unlockCookieName(slug))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
parts := strings.SplitN(c.Value, ".", 2)
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
issued, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if time.Since(time.Unix(issued, 0)) > unlockLifetime {
|
||||
return false
|
||||
}
|
||||
expected := a.sign(slug + "|" + parts[0])
|
||||
return hmac.Equal([]byte(expected), []byte(parts[1]))
|
||||
}
|
||||
|
||||
func (a *Artifact) sign(s string) string {
|
||||
m := hmac.New(sha256.New, a.secret)
|
||||
m.Write([]byte(s))
|
||||
return base64.RawURLEncoding.EncodeToString(m.Sum(nil))
|
||||
}
|
||||
Reference in New Issue
Block a user