server auth
This commit is contained in:
23
apps/server/internal/httpserver/auth_context.go
Normal file
23
apps/server/internal/httpserver/auth_context.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/tim/cairnquire/apps/server/internal/auth"
|
||||
)
|
||||
|
||||
type authContextKey struct{}
|
||||
|
||||
func withPrincipal(ctx context.Context, principal auth.Principal) context.Context {
|
||||
return context.WithValue(ctx, authContextKey{}, principal)
|
||||
}
|
||||
|
||||
func principalFromContext(ctx context.Context) (auth.Principal, bool) {
|
||||
principal, ok := ctx.Value(authContextKey{}).(auth.Principal)
|
||||
return principal, ok
|
||||
}
|
||||
|
||||
func requirePrincipal(r *http.Request) (auth.Principal, bool) {
|
||||
return principalFromContext(r.Context())
|
||||
}
|
||||
477
apps/server/internal/httpserver/auth_handlers.go
Normal file
477
apps/server/internal/httpserver/auth_handlers.go
Normal file
@@ -0,0 +1,477 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/tim/cairnquire/apps/server/internal/auth"
|
||||
)
|
||||
|
||||
type passwordRegisterRequest struct {
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
type passwordLoginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type passkeyBeginRequest struct {
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
type passkeyFinishRequest struct {
|
||||
ChallengeID string `json:"challengeId"`
|
||||
}
|
||||
|
||||
type createTokenRequest struct {
|
||||
Name string `json:"name"`
|
||||
Scopes []auth.Scope `json:"scopes"`
|
||||
ExpiresAt *time.Time `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type deviceStartRequest struct {
|
||||
Name string `json:"name"`
|
||||
Scopes []auth.Scope `json:"scopes"`
|
||||
}
|
||||
|
||||
type changePasswordRequest struct {
|
||||
CurrentPassword string `json:"currentPassword"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
|
||||
type deleteAccountRequest struct {
|
||||
CurrentPassword string `json:"currentPassword"`
|
||||
}
|
||||
|
||||
type updateUserRoleRequest struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requirePrincipal(r); ok {
|
||||
http.Redirect(w, r, "/account", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
nextPath := r.URL.Query().Get("next")
|
||||
if !strings.HasPrefix(nextPath, "/") || strings.HasPrefix(nextPath, "//") {
|
||||
nextPath = "/account"
|
||||
}
|
||||
s.renderTemplate(w, http.StatusOK, "login.gohtml", layoutData{
|
||||
Title: "Sign in",
|
||||
WebEnabled: s.webEnabled,
|
||||
BodyClass: "page-auth",
|
||||
BodyTemplate: "login_content",
|
||||
Data: struct {
|
||||
Next string
|
||||
}{Next: nextPath},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountPage(w http.ResponseWriter, r *http.Request) {
|
||||
principal, ok := requirePrincipal(r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.renderTemplate(w, http.StatusOK, "account.gohtml", layoutData{
|
||||
Title: "Account",
|
||||
WebEnabled: s.webEnabled,
|
||||
BodyClass: "page-account",
|
||||
BodyTemplate: "account_content",
|
||||
Data: principal,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request) {
|
||||
principal, ok := requirePrincipal(r)
|
||||
if !ok {
|
||||
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"principal": principal})
|
||||
}
|
||||
|
||||
func (s *Server) handlePasswordRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.allowAuthAttempt(w, r) {
|
||||
return
|
||||
}
|
||||
var req passwordRegisterRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
user, err := s.auth.RegisterPasswordUser(r.Context(), req.Email, req.DisplayName, req.Password, req.Role)
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSONWithStatus(w, http.StatusCreated, map[string]any{"user": publicUser(user)})
|
||||
}
|
||||
|
||||
func (s *Server) handlePasswordLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.allowAuthAttempt(w, r) {
|
||||
return
|
||||
}
|
||||
var req passwordLoginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
principal, token, err := s.auth.LoginPassword(r.Context(), req.Email, req.Password, requestIP(r), r.UserAgent())
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
setSessionCookie(w, r, token, principal.ExpiresAt)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"principal": principal})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
principal, _ := requirePrincipal(r)
|
||||
if principal.SessionID != "" {
|
||||
_ = s.auth.RevokeSession(r.Context(), principal.SessionID)
|
||||
}
|
||||
clearSessionCookie(w)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "logged_out"})
|
||||
}
|
||||
|
||||
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
principal, _ := requirePrincipal(r)
|
||||
if !requireSessionPrincipal(w, principal) {
|
||||
return
|
||||
}
|
||||
var req changePasswordRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
if err := s.auth.ChangePassword(r.Context(), principal, req.CurrentPassword, req.NewPassword); err != nil {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "password_changed"})
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
|
||||
principal, _ := requirePrincipal(r)
|
||||
if !requireSessionPrincipal(w, principal) {
|
||||
return
|
||||
}
|
||||
var req deleteAccountRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
if err := s.auth.DeleteAccount(r.Context(), principal, req.CurrentPassword); err != nil {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
clearSessionCookie(w)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "account_deleted"})
|
||||
}
|
||||
|
||||
func (s *Server) handlePasskeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.allowAuthAttempt(w, r) {
|
||||
return
|
||||
}
|
||||
var req passkeyBeginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
options, challengeID, err := s.auth.BeginPasskeyRegistration(r.Context(), req.Email, req.DisplayName, req.Role)
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"challengeId": challengeID, "options": options})
|
||||
}
|
||||
|
||||
func (s *Server) handlePasskeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.allowAuthAttempt(w, r) {
|
||||
return
|
||||
}
|
||||
challengeID := r.URL.Query().Get("challengeId")
|
||||
if challengeID == "" {
|
||||
var req passkeyFinishRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
challengeID = req.ChallengeID
|
||||
}
|
||||
if challengeID == "" {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "challengeId is required"})
|
||||
return
|
||||
}
|
||||
user, err := s.auth.FinishPasskeyRegistration(r.Context(), challengeID, r)
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"user": publicUser(user)})
|
||||
}
|
||||
|
||||
func (s *Server) handlePasskeyLoginBegin(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.allowAuthAttempt(w, r) {
|
||||
return
|
||||
}
|
||||
var req passkeyBeginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
options, challengeID, err := s.auth.BeginPasskeyLogin(r.Context(), req.Email)
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"challengeId": challengeID, "options": options})
|
||||
}
|
||||
|
||||
func (s *Server) handlePasskeyLoginFinish(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.allowAuthAttempt(w, r) {
|
||||
return
|
||||
}
|
||||
challengeID := r.URL.Query().Get("challengeId")
|
||||
if challengeID == "" {
|
||||
var req passkeyFinishRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
challengeID = req.ChallengeID
|
||||
}
|
||||
if challengeID == "" {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "challengeId is required"})
|
||||
return
|
||||
}
|
||||
principal, token, err := s.auth.FinishPasskeyLogin(r.Context(), challengeID, r)
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
setSessionCookie(w, r, token, principal.ExpiresAt)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"principal": principal})
|
||||
}
|
||||
|
||||
func (s *Server) handleListAPITokens(w http.ResponseWriter, r *http.Request) {
|
||||
principal, _ := requirePrincipal(r)
|
||||
if !requireSessionPrincipal(w, principal) {
|
||||
return
|
||||
}
|
||||
keys, err := s.auth.ListAPIKeys(r.Context(), principal.UserID)
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"tokens": keys})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||
principal, _ := requirePrincipal(r)
|
||||
if !requireSessionPrincipal(w, principal) {
|
||||
return
|
||||
}
|
||||
var req createTokenRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
created, err := s.auth.CreateAPIKey(r.Context(), principal.UserID, req.Name, req.Scopes, req.ExpiresAt)
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSONWithStatus(w, http.StatusCreated, created)
|
||||
}
|
||||
|
||||
func (s *Server) handleRevokeAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||
principal, _ := requirePrincipal(r)
|
||||
if !requireSessionPrincipal(w, principal) {
|
||||
return
|
||||
}
|
||||
keyID := chi.URLParam(r, "id")
|
||||
if !strings.HasPrefix(keyID, "api:") {
|
||||
keyID = "api:" + keyID
|
||||
}
|
||||
if err := s.auth.RevokeAPIKey(r.Context(), principal.UserID, keyID); err != nil {
|
||||
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "token not found"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
|
||||
}
|
||||
|
||||
func (s *Server) handleDeviceStart(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.allowAuthAttempt(w, r) {
|
||||
return
|
||||
}
|
||||
var req deviceStartRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
start, err := s.auth.StartDeviceFlow(r.Context(), req.Name, req.Scopes)
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, start)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeviceApprove(w http.ResponseWriter, r *http.Request) {
|
||||
principal, _ := requirePrincipal(r)
|
||||
if !requireSessionPrincipal(w, principal) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
UserCode string `json:"userCode"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
device, err := s.auth.ApproveDeviceFlow(r.Context(), principal, req.UserCode)
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid or expired code"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "approved", "device": map[string]any{"name": device.Name, "scopes": device.Scopes}})
|
||||
}
|
||||
|
||||
func (s *Server) handleDeviceToken(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.allowAuthAttempt(w, r) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
DeviceCode string `json:"deviceCode"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
created, code, err := s.auth.PollDeviceFlow(r.Context(), req.DeviceCode)
|
||||
if err != nil {
|
||||
status := http.StatusBadRequest
|
||||
if code == "authorization_pending" {
|
||||
status = http.StatusAccepted
|
||||
}
|
||||
writeJSONWithStatus(w, status, map[string]string{"error": code})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, created)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeviceVerify(w http.ResponseWriter, r *http.Request) {
|
||||
userCode := template.HTMLEscapeString(r.URL.Query().Get("user_code"))
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`<!doctype html><html><body><main><h1>Approve Device</h1><p>Sign in, then approve this code with <code>POST /api/device/approve</code>.</p><p>User code: <strong>` + userCode + `</strong></p></main></body></html>`))
|
||||
}
|
||||
|
||||
func (s *Server) handleDeviceVerifyPage(w http.ResponseWriter, r *http.Request) {
|
||||
userCode := strings.TrimSpace(r.URL.Query().Get("user_code"))
|
||||
s.renderTemplate(w, http.StatusOK, "device_verify.gohtml", layoutData{
|
||||
Title: "Approve device",
|
||||
WebEnabled: s.webEnabled,
|
||||
BodyClass: "page-auth",
|
||||
BodyTemplate: "device_verify_content",
|
||||
Data: struct {
|
||||
UserCode string
|
||||
}{UserCode: userCode},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminUpdateUserRole(w http.ResponseWriter, r *http.Request) {
|
||||
principal, _ := requirePrincipal(r)
|
||||
userID := chi.URLParam(r, "id")
|
||||
var req updateUserRoleRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
user, err := s.auth.UpdateUserRole(r.Context(), principal, userID, req.Role)
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"user": publicUser(user)})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminAuthUsers(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := s.auth.ListUsers(r.Context())
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
public := make([]map[string]any, 0, len(users))
|
||||
for _, user := range users {
|
||||
public = append(public, publicUser(user))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"users": public})
|
||||
}
|
||||
|
||||
func (s *Server) allowAuthAttempt(w http.ResponseWriter, r *http.Request) bool {
|
||||
if s.authLimiter.Allow(authRateLimitKey(r)) {
|
||||
return true
|
||||
}
|
||||
writeJSONWithStatus(w, http.StatusTooManyRequests, map[string]string{"error": "rate limited"})
|
||||
return false
|
||||
}
|
||||
|
||||
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expires time.Time) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: auth.SessionCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
Expires: expires,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Secure: isSecureRequest(r),
|
||||
})
|
||||
}
|
||||
|
||||
func clearSessionCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: auth.SessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
func publicUser(user auth.User) map[string]any {
|
||||
return map[string]any{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"displayName": user.DisplayName,
|
||||
"role": user.Role,
|
||||
"createdAt": user.CreatedAt,
|
||||
"lastSeenAt": user.LastSeenAt,
|
||||
}
|
||||
}
|
||||
|
||||
func requireSessionPrincipal(w http.ResponseWriter, principal auth.Principal) bool {
|
||||
if principal.Method == "session" {
|
||||
return true
|
||||
}
|
||||
writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "browser session required"})
|
||||
return false
|
||||
}
|
||||
|
||||
func requestIP(r *http.Request) string {
|
||||
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
||||
return strings.TrimSpace(strings.Split(forwarded, ",")[0])
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
func isSecureRequest(r *http.Request) bool {
|
||||
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||
}
|
||||
@@ -137,6 +137,10 @@ func (s *Server) handleDocsIndex(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) {
|
||||
pagePath := chi.URLParam(r, "*")
|
||||
if isContentAssetPath(pagePath) {
|
||||
s.handleContentAsset(w, r, pagePath)
|
||||
return
|
||||
}
|
||||
if editPath, ok := trimEditSuffix(pagePath); ok {
|
||||
s.renderDocumentEditor(w, r, editPath)
|
||||
return
|
||||
@@ -144,6 +148,64 @@ func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderDocumentPage(w, r, pagePath)
|
||||
}
|
||||
|
||||
func (s *Server) handleContentAsset(w http.ResponseWriter, r *http.Request, assetPath string) {
|
||||
cleanPath := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(assetPath, "/")))
|
||||
if cleanPath == "." || strings.HasPrefix(cleanPath, ".."+string(filepath.Separator)) || filepath.IsAbs(cleanPath) {
|
||||
s.renderError(w, r, http.StatusNotFound, "asset not found")
|
||||
return
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(s.config.Content.SourceDir, cleanPath)
|
||||
sourceRoot, err := filepath.Abs(s.config.Content.SourceDir)
|
||||
if err != nil {
|
||||
s.renderError(w, r, http.StatusInternalServerError, "resolve content root")
|
||||
return
|
||||
}
|
||||
assetAbs, err := filepath.Abs(fullPath)
|
||||
if err != nil {
|
||||
s.renderError(w, r, http.StatusNotFound, "asset not found")
|
||||
return
|
||||
}
|
||||
relative, err := filepath.Rel(sourceRoot, assetAbs)
|
||||
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||
s.renderError(w, r, http.StatusNotFound, "asset not found")
|
||||
return
|
||||
}
|
||||
|
||||
file, err := os.Open(assetAbs)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
s.renderError(w, r, http.StatusNotFound, "asset not found")
|
||||
return
|
||||
}
|
||||
s.renderError(w, r, http.StatusInternalServerError, "read asset")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil || info.IsDir() {
|
||||
s.renderError(w, r, http.StatusNotFound, "asset not found")
|
||||
return
|
||||
}
|
||||
|
||||
buffer := make([]byte, 512)
|
||||
n, _ := file.Read(buffer)
|
||||
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||
s.renderError(w, r, http.StatusInternalServerError, "read asset")
|
||||
return
|
||||
}
|
||||
contentType := http.DetectContentType(buffer[:n])
|
||||
if !isAllowedContentType(contentType) {
|
||||
s.renderError(w, r, http.StatusNotFound, "asset not found")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size()))
|
||||
http.ServeContent(w, r, info.Name(), info.ModTime(), file)
|
||||
}
|
||||
|
||||
func (s *Server) renderDocumentEditor(w http.ResponseWriter, r *http.Request, pagePath string) {
|
||||
page, err := s.documents.LoadSourcePage(r.Context(), pagePath)
|
||||
if err != nil {
|
||||
@@ -469,6 +531,11 @@ func trimEditSuffix(path string) (string, bool) {
|
||||
return strings.TrimSuffix(path, "/edit"), true
|
||||
}
|
||||
|
||||
func isContentAssetPath(path string) bool {
|
||||
extension := strings.ToLower(filepath.Ext(path))
|
||||
return extension != "" && extension != ".md"
|
||||
}
|
||||
|
||||
func buildBrowser(records []docs.DocumentRecord, activePath string) browserData {
|
||||
paths := make([]string, 0, len(records))
|
||||
titleByPath := make(map[string]string, len(records))
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tim/cairnquire/apps/server/internal/config"
|
||||
"github.com/tim/cairnquire/apps/server/internal/docs"
|
||||
)
|
||||
|
||||
@@ -63,3 +68,56 @@ func TestTrimEditSuffix(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsContentAssetPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{path: "logo.webp", want: true},
|
||||
{path: "guide/logo.png", want: true},
|
||||
{path: "guide/page", want: false},
|
||||
{path: "guide/page.md", want: false},
|
||||
{path: "guide/page/edit", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
if got := isContentAssetPath(tt.path); got != tt.want {
|
||||
t.Fatalf("isContentAssetPath(%q) = %t, want %t", tt.path, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleContentAssetServesImageFromSourceDir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
image := []byte{
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
|
||||
0x89,
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "logo.png"), image, 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
|
||||
server := &Server{
|
||||
config: config.Config{
|
||||
Content: config.ContentConfig{SourceDir: root},
|
||||
},
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/docs/logo.png", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.handleContentAsset(recorder, request, "logo.png")
|
||||
|
||||
response := recorder.Result()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusOK)
|
||||
}
|
||||
if got := response.Header.Get("Content-Type"); got != "image/png" {
|
||||
t.Fatalf("content-type = %q, want image/png", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,12 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"github.com/tim/cairnquire/apps/server/internal/auth"
|
||||
)
|
||||
|
||||
func (s *Server) timeoutExceptWebSocket(timeout time.Duration) func(http.Handler) http.Handler {
|
||||
@@ -49,6 +52,88 @@ func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, ok := s.authenticateRequest(r)
|
||||
if ok {
|
||||
r = r.WithContext(withPrincipal(r.Context(), principal))
|
||||
}
|
||||
|
||||
required, protected := requiredScopeForPath(r)
|
||||
if !protected {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||
return
|
||||
}
|
||||
if !auth.Allows(principal, required) {
|
||||
writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) authenticateRequest(r *http.Request) (auth.Principal, bool) {
|
||||
if header := r.Header.Get("Authorization"); strings.HasPrefix(header, "Bearer ") {
|
||||
principal, err := s.auth.ValidateBearerToken(r.Context(), strings.TrimSpace(strings.TrimPrefix(header, "Bearer ")))
|
||||
if err == nil {
|
||||
return principal, true
|
||||
}
|
||||
s.logger.Warn("invalid bearer token", "error", err)
|
||||
return auth.Principal{}, false
|
||||
}
|
||||
cookie, err := r.Cookie(auth.SessionCookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return auth.Principal{}, false
|
||||
}
|
||||
principal, err := s.auth.ValidateSessionToken(r.Context(), cookie.Value)
|
||||
if err != nil {
|
||||
return auth.Principal{}, false
|
||||
}
|
||||
return principal, true
|
||||
}
|
||||
|
||||
func requiredScopeForPath(r *http.Request) (auth.Scope, bool) {
|
||||
path := r.URL.Path
|
||||
method := r.Method
|
||||
switch {
|
||||
case strings.HasPrefix(path, "/api/auth/"):
|
||||
if path == "/api/auth/me" || path == "/api/auth/logout" || path == "/api/auth/password/change" || path == "/api/auth/account" {
|
||||
return auth.ScopeDocsRead, true
|
||||
}
|
||||
return "", false
|
||||
case strings.HasPrefix(path, "/api/device/"):
|
||||
if path == "/api/device/approve" || path == "/api/device/verify" {
|
||||
return auth.ScopeDocsRead, true
|
||||
}
|
||||
return "", false
|
||||
case strings.HasPrefix(path, "/api/tokens"):
|
||||
return auth.ScopeDocsRead, true
|
||||
case path == "/ws":
|
||||
return auth.ScopeDocsRead, true
|
||||
case strings.HasPrefix(path, "/api/admin/"):
|
||||
return auth.ScopeAdmin, true
|
||||
case strings.HasPrefix(path, "/api/documents/") && method != http.MethodGet:
|
||||
return auth.ScopeDocsWrite, true
|
||||
case strings.HasPrefix(path, "/api/uploads"):
|
||||
return auth.ScopeDocsWrite, true
|
||||
case strings.HasPrefix(path, "/api/sync/"):
|
||||
if method == http.MethodPost {
|
||||
return auth.ScopeSyncWrite, true
|
||||
}
|
||||
return auth.ScopeSyncRead, true
|
||||
case strings.HasPrefix(path, "/api/content/"):
|
||||
return auth.ScopeSyncRead, true
|
||||
case strings.HasSuffix(path, "/edit"):
|
||||
return auth.ScopeDocsWrite, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
type statusWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
|
||||
49
apps/server/internal/httpserver/rate_limit.go
Normal file
49
apps/server/internal/httpserver/rate_limit.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type rateLimiter struct {
|
||||
mu sync.Mutex
|
||||
window time.Duration
|
||||
limit int
|
||||
attempts map[string][]time.Time
|
||||
}
|
||||
|
||||
func newRateLimiter(limit int, window time.Duration) *rateLimiter {
|
||||
return &rateLimiter{
|
||||
window: window,
|
||||
limit: limit,
|
||||
attempts: make(map[string][]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
func (l *rateLimiter) Allow(key string) bool {
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-l.window)
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
attempts := l.attempts[key]
|
||||
kept := attempts[:0]
|
||||
for _, attempt := range attempts {
|
||||
if attempt.After(cutoff) {
|
||||
kept = append(kept, attempt)
|
||||
}
|
||||
}
|
||||
if len(kept) >= l.limit {
|
||||
l.attempts[key] = kept
|
||||
return false
|
||||
}
|
||||
kept = append(kept, now)
|
||||
l.attempts[key] = kept
|
||||
return true
|
||||
}
|
||||
|
||||
func authRateLimitKey(r *http.Request) string {
|
||||
return requestIP(r) + ":" + r.URL.Path
|
||||
}
|
||||
19
apps/server/internal/httpserver/rate_limit_test.go
Normal file
19
apps/server/internal/httpserver/rate_limit_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRateLimiterRejectsAfterLimit(t *testing.T) {
|
||||
limiter := newRateLimiter(2, time.Minute)
|
||||
if !limiter.Allow("ip:path") {
|
||||
t.Fatal("first attempt should be allowed")
|
||||
}
|
||||
if !limiter.Allow("ip:path") {
|
||||
t.Fatal("second attempt should be allowed")
|
||||
}
|
||||
if limiter.Allow("ip:path") {
|
||||
t.Fatal("third attempt should be rate limited")
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"github.com/tim/cairnquire/apps/server/internal/auth"
|
||||
"github.com/tim/cairnquire/apps/server/internal/config"
|
||||
"github.com/tim/cairnquire/apps/server/internal/docs"
|
||||
"github.com/tim/cairnquire/apps/server/internal/realtime"
|
||||
@@ -31,6 +32,7 @@ type Dependencies struct {
|
||||
Hub *realtime.Hub
|
||||
SyncService *sync.Service
|
||||
SyncRepo *sync.Repository
|
||||
Auth *auth.Service
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -42,6 +44,8 @@ type Server struct {
|
||||
hub *realtime.Hub
|
||||
syncService *sync.Service
|
||||
syncRepo *sync.Repository
|
||||
auth *auth.Service
|
||||
authLimiter *rateLimiter
|
||||
templates *template.Template
|
||||
webEnabled bool
|
||||
}
|
||||
@@ -68,6 +72,8 @@ func New(deps Dependencies) (http.Handler, error) {
|
||||
hub: deps.Hub,
|
||||
syncService: deps.SyncService,
|
||||
syncRepo: deps.SyncRepo,
|
||||
auth: deps.Auth,
|
||||
authLimiter: newRateLimiter(5, time.Minute),
|
||||
templates: templates,
|
||||
}
|
||||
|
||||
@@ -82,15 +88,37 @@ func New(deps Dependencies) (http.Handler, error) {
|
||||
router.Use(server.timeoutExceptWebSocket(30 * time.Second))
|
||||
router.Use(server.requestLogger)
|
||||
router.Use(server.securityHeaders)
|
||||
router.Use(server.apiKeyMiddleware)
|
||||
router.Use(server.authMiddleware)
|
||||
|
||||
router.Get("/", server.handleIndex)
|
||||
router.Get("/login", server.handleLoginPage)
|
||||
router.Get("/account", server.handleAccountPage)
|
||||
router.Get("/device/verify", server.handleDeviceVerifyPage)
|
||||
router.Get("/health", server.handleHealth)
|
||||
router.Get("/sw.js", server.handleServiceWorker)
|
||||
router.Get("/ws", server.handleWebSocket)
|
||||
router.Get("/api/auth/me", server.handleAuthMe)
|
||||
router.Post("/api/auth/register/password", server.handlePasswordRegister)
|
||||
router.Post("/api/auth/login/password", server.handlePasswordLogin)
|
||||
router.Post("/api/auth/logout", server.handleLogout)
|
||||
router.Post("/api/auth/password/change", server.handleChangePassword)
|
||||
router.Delete("/api/auth/account", server.handleDeleteAccount)
|
||||
router.Post("/api/auth/passkeys/register/begin", server.handlePasskeyRegisterBegin)
|
||||
router.Post("/api/auth/passkeys/register/finish", server.handlePasskeyRegisterFinish)
|
||||
router.Post("/api/auth/passkeys/login/begin", server.handlePasskeyLoginBegin)
|
||||
router.Post("/api/auth/passkeys/login/finish", server.handlePasskeyLoginFinish)
|
||||
router.Get("/api/tokens", server.handleListAPITokens)
|
||||
router.Post("/api/tokens", server.handleCreateAPIToken)
|
||||
router.Delete("/api/tokens/{id}", server.handleRevokeAPIToken)
|
||||
router.Post("/api/device/start", server.handleDeviceStart)
|
||||
router.Post("/api/device/approve", server.handleDeviceApprove)
|
||||
router.Post("/api/device/token", server.handleDeviceToken)
|
||||
router.Get("/api/device/verify", server.handleDeviceVerify)
|
||||
router.Post("/api/admin/login", server.handleAdminLogin)
|
||||
router.Get("/api/admin/users", server.handleAdminUsers)
|
||||
router.Post("/api/admin/users", server.handleAdminCreateUser)
|
||||
router.Patch("/api/admin/users/{id}/role", server.handleAdminUpdateUserRole)
|
||||
router.Get("/api/admin/auth-users", server.handleAdminAuthUsers)
|
||||
router.Get("/api/admin/workspace", server.handleAdminWorkspace)
|
||||
router.Post("/api/admin/workspace/sync", server.handleAdminWorkspaceSync)
|
||||
router.Get("/docs", server.handleDocsIndex)
|
||||
|
||||
262
apps/server/internal/httpserver/static/auth.js
Normal file
262
apps/server/internal/httpserver/static/auth.js
Normal file
@@ -0,0 +1,262 @@
|
||||
(() => {
|
||||
const jsonHeaders = { "Content-Type": "application/json" };
|
||||
|
||||
const postJSON = async (url, body, options = {}) => {
|
||||
const response = await fetch(url, {
|
||||
method: options.method || "POST",
|
||||
headers: jsonHeaders,
|
||||
credentials: "same-origin",
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || `Request failed (${response.status})`);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const setStatus = (selector, message, isError = false) => {
|
||||
const target = document.querySelector(selector);
|
||||
if (!target) return;
|
||||
target.textContent = message;
|
||||
target.dataset.state = isError ? "error" : "ok";
|
||||
};
|
||||
|
||||
const authNext = () => document.querySelector("[data-auth-next]")?.value || "/account";
|
||||
|
||||
const formJSON = (form) => Object.fromEntries(new FormData(form).entries());
|
||||
|
||||
const base64URLToBuffer = (value) => {
|
||||
const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
|
||||
const binary = atob(padded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes.buffer;
|
||||
};
|
||||
|
||||
const bufferToBase64URL = (value) => {
|
||||
const bytes = new Uint8Array(value);
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||
};
|
||||
|
||||
const credentialToJSON = (credential) => {
|
||||
if (credential instanceof ArrayBuffer) return bufferToBase64URL(credential);
|
||||
if (ArrayBuffer.isView(credential)) return bufferToBase64URL(credential.buffer);
|
||||
if (Array.isArray(credential)) return credential.map(credentialToJSON);
|
||||
if (credential && typeof credential === "object") {
|
||||
const next = {};
|
||||
for (const [key, value] of Object.entries(credential)) next[key] = credentialToJSON(value);
|
||||
return next;
|
||||
}
|
||||
return credential;
|
||||
};
|
||||
|
||||
const normalizeCreateOptions = (options) => {
|
||||
const publicKey = { ...options.publicKey };
|
||||
publicKey.challenge = base64URLToBuffer(publicKey.challenge);
|
||||
publicKey.user = { ...publicKey.user, id: base64URLToBuffer(publicKey.user.id) };
|
||||
publicKey.excludeCredentials = (publicKey.excludeCredentials || []).map((credential) => ({
|
||||
...credential,
|
||||
id: base64URLToBuffer(credential.id),
|
||||
}));
|
||||
return { publicKey };
|
||||
};
|
||||
|
||||
const normalizeRequestOptions = (options) => {
|
||||
const publicKey = { ...options.publicKey };
|
||||
publicKey.challenge = base64URLToBuffer(publicKey.challenge);
|
||||
publicKey.allowCredentials = (publicKey.allowCredentials || []).map((credential) => ({
|
||||
...credential,
|
||||
id: base64URLToBuffer(credential.id),
|
||||
}));
|
||||
return { publicKey };
|
||||
};
|
||||
|
||||
document.querySelector("[data-auth-login]")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await postJSON("/api/auth/login/password", formJSON(event.currentTarget));
|
||||
window.location.href = authNext();
|
||||
} catch (error) {
|
||||
setStatus("[data-login-status]", error.message, true);
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("[data-auth-register]")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await postJSON("/api/auth/register/password", formJSON(event.currentTarget));
|
||||
setStatus("[data-register-status]", "Account created. Sign in with your password.");
|
||||
event.currentTarget.reset();
|
||||
} catch (error) {
|
||||
setStatus("[data-register-status]", error.message, true);
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("[data-passkey-register]")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (!window.PublicKeyCredential) {
|
||||
setStatus("[data-passkey-register-status]", "Passkeys are not available in this browser.", true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const begin = await postJSON("/api/auth/passkeys/register/begin", formJSON(event.currentTarget));
|
||||
const credential = await navigator.credentials.create(normalizeCreateOptions(begin.options));
|
||||
await postJSON(`/api/auth/passkeys/register/finish?challengeId=${encodeURIComponent(begin.challengeId)}`, credentialToJSON(credential));
|
||||
setStatus("[data-passkey-register-status]", "Passkey created. Sign in with your passkey.");
|
||||
} catch (error) {
|
||||
setStatus("[data-passkey-register-status]", error.message, true);
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("[data-passkey-login]")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (!window.PublicKeyCredential) {
|
||||
setStatus("[data-passkey-login-status]", "Passkeys are not available in this browser.", true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const begin = await postJSON("/api/auth/passkeys/login/begin", formJSON(event.currentTarget));
|
||||
const credential = await navigator.credentials.get(normalizeRequestOptions(begin.options));
|
||||
await postJSON(`/api/auth/passkeys/login/finish?challengeId=${encodeURIComponent(begin.challengeId)}`, credentialToJSON(credential));
|
||||
window.location.href = authNext();
|
||||
} catch (error) {
|
||||
setStatus("[data-passkey-login-status]", error.message, true);
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("[data-auth-logout]")?.addEventListener("click", async () => {
|
||||
await postJSON("/api/auth/logout", {});
|
||||
window.location.href = "/login";
|
||||
});
|
||||
|
||||
const loadTokens = async () => {
|
||||
const list = document.querySelector("[data-token-list]");
|
||||
if (!list) return;
|
||||
const data = await fetch("/api/tokens", { credentials: "same-origin" }).then((response) => response.json());
|
||||
list.replaceChildren(
|
||||
...(data.tokens || []).map((token) => {
|
||||
const item = document.createElement("li");
|
||||
const meta = document.createElement("span");
|
||||
meta.textContent = `${token.name} - ${token.scopes.join(", ")}${token.revokedAt ? " - revoked" : ""}`;
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = "Revoke";
|
||||
button.disabled = Boolean(token.revokedAt);
|
||||
button.addEventListener("click", async () => {
|
||||
await fetch(`/api/tokens/${encodeURIComponent(token.id.replace(/^api:/, ""))}`, {
|
||||
method: "DELETE",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
await loadTokens();
|
||||
});
|
||||
item.append(meta, button);
|
||||
return item;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
document.querySelector("[data-token-create]")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const scopes = [...form.querySelectorAll('input[name="scope"]:checked')].map((input) => input.value);
|
||||
try {
|
||||
const created = await postJSON("/api/tokens", { name: form.elements.name.value, scopes });
|
||||
const output = document.querySelector("[data-token-output]");
|
||||
if (output) {
|
||||
output.hidden = false;
|
||||
output.textContent = created.token;
|
||||
}
|
||||
setStatus("[data-token-status]", "Token created. Copy it now; it will not be shown again.");
|
||||
form.reset();
|
||||
await loadTokens();
|
||||
} catch (error) {
|
||||
setStatus("[data-token-status]", error.message, true);
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("[data-password-change]")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await postJSON("/api/auth/password/change", formJSON(event.currentTarget));
|
||||
setStatus("[data-password-status]", "Password changed.");
|
||||
event.currentTarget.reset();
|
||||
} catch (error) {
|
||||
setStatus("[data-password-status]", error.message, true);
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("[data-account-delete]")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (!confirm("Delete this account permanently?")) return;
|
||||
try {
|
||||
await postJSON("/api/auth/account", formJSON(event.currentTarget), { method: "DELETE" });
|
||||
window.location.href = "/login";
|
||||
} catch (error) {
|
||||
setStatus("[data-delete-status]", error.message, true);
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("[data-device-approve]")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await postJSON("/api/device/approve", formJSON(event.currentTarget));
|
||||
setStatus("[data-device-status]", "Device approved.");
|
||||
} catch (error) {
|
||||
if (error.message === "authentication required") {
|
||||
window.location.href = `/login?next=${encodeURIComponent(window.location.pathname + window.location.search)}`;
|
||||
return;
|
||||
}
|
||||
setStatus("[data-device-status]", error.message, true);
|
||||
}
|
||||
});
|
||||
|
||||
const loadAdminUsers = async () => {
|
||||
const section = document.querySelector("[data-admin-users-section]");
|
||||
const list = document.querySelector("[data-admin-users]");
|
||||
if (!section || !list) return;
|
||||
const me = await fetch("/api/auth/me", { credentials: "same-origin" }).then((response) => response.json()).catch(() => null);
|
||||
if (me?.principal?.role !== "admin") return;
|
||||
section.hidden = false;
|
||||
const data = await fetch("/api/admin/auth-users", { credentials: "same-origin" }).then((response) => response.json());
|
||||
list.replaceChildren(
|
||||
...(data.users || []).map((user) => {
|
||||
const row = document.createElement("form");
|
||||
row.className = "user-row";
|
||||
const identity = document.createElement("span");
|
||||
const name = document.createElement("strong");
|
||||
name.textContent = user.displayName;
|
||||
const email = document.createElement("small");
|
||||
email.textContent = user.email;
|
||||
identity.append(name, email);
|
||||
const select = document.createElement("select");
|
||||
for (const role of ["viewer", "editor", "admin"]) {
|
||||
const option = document.createElement("option");
|
||||
option.value = role;
|
||||
option.textContent = role;
|
||||
option.selected = user.role === role;
|
||||
select.append(option);
|
||||
}
|
||||
const button = document.createElement("button");
|
||||
button.type = "submit";
|
||||
button.textContent = "Save";
|
||||
row.append(identity, select, button);
|
||||
row.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await postJSON(`/api/admin/users/${encodeURIComponent(user.id)}/role`, { role: select.value }, { method: "PATCH" });
|
||||
setStatus("[data-admin-users-status]", "Role updated.");
|
||||
} catch (error) {
|
||||
setStatus("[data-admin-users-status]", error.message, true);
|
||||
}
|
||||
});
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
loadTokens().catch(() => {});
|
||||
loadAdminUsers().catch(() => {});
|
||||
})();
|
||||
@@ -5,6 +5,7 @@
|
||||
const STORE_CONTENT = "content";
|
||||
const STORE_PAGES = "pages";
|
||||
const STORE_PENDING_EDITS = "pending_edits";
|
||||
const CACHE_ENTRY_LIMIT = 100;
|
||||
|
||||
function openDB() {
|
||||
return new Promise(function (resolve, reject) {
|
||||
@@ -39,6 +40,27 @@
|
||||
return path.replace(/\/$/, "") || "/";
|
||||
}
|
||||
|
||||
function pruneStore(store, limit) {
|
||||
const request = store.getAll();
|
||||
request.onsuccess = function () {
|
||||
const records = request.result || [];
|
||||
if (records.length <= limit) {
|
||||
return;
|
||||
}
|
||||
|
||||
records
|
||||
.sort(function (a, b) {
|
||||
return (a.cachedAt || 0) - (b.cachedAt || 0);
|
||||
})
|
||||
.slice(0, records.length - limit)
|
||||
.forEach(function (record) {
|
||||
if (record && record.path) {
|
||||
store.delete(record.path);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function cacheDocuments(documents) {
|
||||
return openDB().then(function (db) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
@@ -63,6 +85,7 @@
|
||||
const tx = db.transaction(STORE_CONTENT, "readwrite");
|
||||
const store = tx.objectStore(STORE_CONTENT);
|
||||
store.put({ path: path, html: html, title: title, cachedAt: Date.now() });
|
||||
pruneStore(store, CACHE_ENTRY_LIMIT);
|
||||
tx.oncomplete = function () {
|
||||
resolve();
|
||||
};
|
||||
@@ -117,6 +140,7 @@
|
||||
title: title,
|
||||
cachedAt: Date.now(),
|
||||
});
|
||||
pruneStore(store, CACHE_ENTRY_LIMIT);
|
||||
tx.oncomplete = function () {
|
||||
resolve();
|
||||
};
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 415 KiB After Width: | Height: | Size: 134 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 4.4 KiB |
@@ -10,7 +10,9 @@
|
||||
pre.replaceWith(container);
|
||||
});
|
||||
mermaid.initialize({ startOnLoad: false });
|
||||
mermaid.run({ querySelector: ".mermaid" });
|
||||
mermaid.run({ querySelector: ".mermaid:not([data-processed])" }).catch(function (error) {
|
||||
console.warn("Mermaid render failed:", error);
|
||||
});
|
||||
}
|
||||
|
||||
function renderMath() {
|
||||
@@ -66,8 +68,8 @@
|
||||
}
|
||||
|
||||
function init() {
|
||||
renderMermaid();
|
||||
renderMath();
|
||||
renderMermaid();
|
||||
}
|
||||
|
||||
window.renderMermaid = renderMermaid;
|
||||
|
||||
@@ -152,6 +152,229 @@ code {
|
||||
height: calc(100vh - 64px);
|
||||
}
|
||||
|
||||
.auth-shell,
|
||||
.account-shell {
|
||||
min-height: 100%;
|
||||
overflow: auto;
|
||||
padding: clamp(1rem, 3vw, 2.5rem);
|
||||
}
|
||||
|
||||
.auth-shell {
|
||||
display: grid;
|
||||
align-items: start;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.auth-panel,
|
||||
.account-section {
|
||||
width: min(100%, 920px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
padding: clamp(1rem, 3vw, 2rem);
|
||||
}
|
||||
|
||||
.auth-panel__header,
|
||||
.account-header {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.auth-panel h1,
|
||||
.account-header h1 {
|
||||
margin: 0.1rem 0 0;
|
||||
font-size: clamp(2rem, 4vw, 3.25rem);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font: 700 0.72rem/1.2 ui-monospace, SFMono-Regular, monospace;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.auth-grid,
|
||||
.account-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.auth-form h2,
|
||||
.account-section h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.auth-form label,
|
||||
.scope-grid {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.auth-form input,
|
||||
.auth-form select,
|
||||
.user-row select {
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
background: var(--panel-strong);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.auth-form button,
|
||||
.account-header button,
|
||||
.token-list button,
|
||||
.user-row button {
|
||||
min-height: 2.35rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--panel-strong);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-form button[type="submit"],
|
||||
.account-header button {
|
||||
border-color: color-mix(in srgb, var(--accent) 50%, var(--border));
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.auth-details {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.auth-details summary {
|
||||
cursor: pointer;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.form-status {
|
||||
min-height: 1.2rem;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.form-status[data-state="error"] {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.form-status[data-state="ok"] {
|
||||
color: #067647;
|
||||
}
|
||||
|
||||
.auth-note {
|
||||
margin: 1rem 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.account-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
width: min(100%, 1120px);
|
||||
}
|
||||
|
||||
.account-header p {
|
||||
margin: 0.35rem 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.account-grid {
|
||||
width: min(100%, 1120px);
|
||||
}
|
||||
|
||||
.account-section {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.account-section--danger {
|
||||
border-color: color-mix(in srgb, #b42318 40%, var(--border));
|
||||
}
|
||||
|
||||
.scope-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.scope-grid legend {
|
||||
padding: 0 0.25rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.scope-grid label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.token-output {
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel-strong);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.token-list,
|
||||
.user-list {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
margin: 1rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.token-list li,
|
||||
.user-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel-strong);
|
||||
}
|
||||
|
||||
.user-row {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(8rem, 10rem) auto;
|
||||
}
|
||||
|
||||
.user-row span {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.user-row small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Workspace layout - adaptive grid */
|
||||
.workspace-shell {
|
||||
display: grid;
|
||||
@@ -583,7 +806,9 @@ code {
|
||||
}
|
||||
|
||||
.markdown-body img {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
max-width: min(85%, 100%);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.version-notice {
|
||||
@@ -640,6 +865,96 @@ code {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sync-queue {
|
||||
position: fixed;
|
||||
left: 1rem;
|
||||
bottom: 7.9rem;
|
||||
z-index: 30;
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
width: min(28rem, calc(100vw - 2rem));
|
||||
padding: 0.75rem 0.85rem;
|
||||
border: 1px solid rgba(146, 64, 14, 0.24);
|
||||
background: rgba(255, 251, 235, 0.97);
|
||||
color: #78350f;
|
||||
box-shadow: 0 18px 48px rgba(24, 32, 42, 0.12);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.sync-queue[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sync-queue__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.sync-queue__header strong,
|
||||
.sync-queue__header p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sync-queue__header p {
|
||||
color: #92400e;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.sync-queue button {
|
||||
min-height: 2rem;
|
||||
flex: 0 0 auto;
|
||||
padding: 0 0.65rem;
|
||||
border: 1px solid rgba(146, 64, 14, 0.25);
|
||||
background: #fff;
|
||||
color: #78350f;
|
||||
cursor: pointer;
|
||||
font: 700 0.82rem/1 ui-monospace, SFMono-Regular, monospace;
|
||||
}
|
||||
|
||||
.sync-queue button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.sync-queue ul {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.sync-queue li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
color: #78350f;
|
||||
}
|
||||
|
||||
.sync-queue li span:first-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
}
|
||||
|
||||
.sync-queue li span:last-child {
|
||||
flex: 0 0 auto;
|
||||
color: #92400e;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sync-queue li[data-state="conflict"] span:last-child {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.offline-icon {
|
||||
display: inline-block;
|
||||
width: 0.6rem;
|
||||
@@ -1231,6 +1546,29 @@ code {
|
||||
background: oklch(0.60 0.15 250);
|
||||
}
|
||||
|
||||
.sync-queue {
|
||||
border-color: oklch(0.65 0.12 75 / 0.32);
|
||||
background: oklch(0.21 0.035 75 / 0.96);
|
||||
color: oklch(0.75 0.12 75);
|
||||
box-shadow: 0 18px 48px oklch(0.08 0.02 55 / 0.32);
|
||||
}
|
||||
|
||||
.sync-queue__header p,
|
||||
.sync-queue li,
|
||||
.sync-queue li span:last-child {
|
||||
color: oklch(0.72 0.11 75);
|
||||
}
|
||||
|
||||
.sync-queue button {
|
||||
border-color: oklch(0.65 0.12 75 / 0.28);
|
||||
background: oklch(0.17 0.018 55);
|
||||
color: oklch(0.75 0.12 75);
|
||||
}
|
||||
|
||||
.sync-queue li[data-state="conflict"] span:last-child {
|
||||
color: oklch(0.68 0.16 25);
|
||||
}
|
||||
|
||||
.version-notice button {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const STATIC_CACHE = "md-hub-static-v1";
|
||||
const STATIC_CACHE = "md-hub-static-v2";
|
||||
const DB_NAME = "md-hub-cache";
|
||||
const DB_VERSION = 3;
|
||||
const STORE_PAGES = "pages";
|
||||
|
||||
@@ -3,11 +3,107 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const syncQueue = document.querySelector("[data-sync-queue]");
|
||||
const syncQueueTitle = document.querySelector("[data-sync-queue-title]");
|
||||
const syncQueueSummary = document.querySelector("[data-sync-queue-summary]");
|
||||
const syncQueueList = document.querySelector("[data-sync-queue-list]");
|
||||
const syncNow = document.querySelector("[data-sync-now]");
|
||||
|
||||
function normalizeDocPath(path) {
|
||||
if (!path) return "";
|
||||
return path.replace(/^\/+/, "");
|
||||
}
|
||||
|
||||
function formatPath(path) {
|
||||
return (path || "").replace(/^\/docs\//, "") || "/";
|
||||
}
|
||||
|
||||
function pluralize(count, singular, plural) {
|
||||
return count === 1 ? singular : plural;
|
||||
}
|
||||
|
||||
function emitSyncState(detail) {
|
||||
window.dispatchEvent(new CustomEvent("mdhub:sync-state", { detail: detail }));
|
||||
}
|
||||
|
||||
function renderPendingEdits(pending, state) {
|
||||
if (!syncQueue || !syncQueueTitle || !syncQueueSummary || !syncQueueList) {
|
||||
return;
|
||||
}
|
||||
|
||||
const conflicts = pending.filter(function (edit) {
|
||||
return Boolean(edit.conflict);
|
||||
});
|
||||
const clean = pending.filter(function (edit) {
|
||||
return !edit.conflict;
|
||||
});
|
||||
|
||||
syncQueue.hidden = pending.length === 0;
|
||||
if (pending.length === 0) {
|
||||
syncQueueList.replaceChildren();
|
||||
syncQueueTitle.textContent = "Synced";
|
||||
syncQueueSummary.textContent = "No pending changes.";
|
||||
if (syncNow) {
|
||||
syncNow.disabled = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const countLabel = pending.length + " pending " + pluralize(pending.length, "change", "changes");
|
||||
syncQueueTitle.textContent = conflicts.length > 0 ? countLabel + " with conflicts" : countLabel;
|
||||
if (!navigator.onLine) {
|
||||
syncQueueSummary.textContent = "Offline. Changes will sync when the connection returns.";
|
||||
} else if (state === "syncing") {
|
||||
syncQueueSummary.textContent = "Syncing queued edits...";
|
||||
} else if (conflicts.length > 0) {
|
||||
syncQueueSummary.textContent = conflicts.length + " " + pluralize(conflicts.length, "file needs", "files need") + " conflict resolution.";
|
||||
} else {
|
||||
syncQueueSummary.textContent = "Queued edits are ready to sync.";
|
||||
}
|
||||
|
||||
syncQueueList.replaceChildren();
|
||||
pending.slice(0, 5).forEach(function (edit) {
|
||||
const item = document.createElement("li");
|
||||
item.dataset.state = edit.conflict ? "conflict" : "queued";
|
||||
|
||||
const path = document.createElement("span");
|
||||
path.textContent = formatPath(edit.path);
|
||||
item.appendChild(path);
|
||||
|
||||
const status = document.createElement("span");
|
||||
status.textContent = edit.conflict ? "Conflict" : "Queued";
|
||||
item.appendChild(status);
|
||||
|
||||
syncQueueList.appendChild(item);
|
||||
});
|
||||
|
||||
if (pending.length > 5) {
|
||||
const overflow = document.createElement("li");
|
||||
overflow.textContent = "+" + (pending.length - 5) + " more";
|
||||
syncQueueList.appendChild(overflow);
|
||||
}
|
||||
|
||||
if (syncNow) {
|
||||
syncNow.disabled = !navigator.onLine || clean.length === 0 || state === "syncing";
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSyncQueue(state) {
|
||||
const pending = await window.MDHubCache.getPendingEdits();
|
||||
pending.sort(function (a, b) {
|
||||
return (b.updatedAt || 0) - (a.updatedAt || 0);
|
||||
});
|
||||
renderPendingEdits(pending, state || "idle");
|
||||
emitSyncState({
|
||||
state: state || "idle",
|
||||
pending: pending.length,
|
||||
conflicts: pending.filter(function (edit) {
|
||||
return Boolean(edit.conflict);
|
||||
}).length,
|
||||
});
|
||||
return pending;
|
||||
}
|
||||
|
||||
async function postDocument(path, content, baseHash) {
|
||||
const response = await fetch("/api/documents/" + normalizeDocPath(path), {
|
||||
method: "POST",
|
||||
@@ -40,6 +136,7 @@
|
||||
try {
|
||||
const result = await postDocument(path, content, baseHash);
|
||||
await window.MDHubCache.deletePendingEdit(normalizedPath);
|
||||
await refreshSyncQueue("saved");
|
||||
return {
|
||||
status: "saved",
|
||||
result: result,
|
||||
@@ -48,12 +145,14 @@
|
||||
if (error.name === "DocumentConflictError") {
|
||||
await window.MDHubCache.putPendingEdit(normalizedPath, content, baseHash);
|
||||
await window.MDHubCache.markPendingEditConflict(normalizedPath, error.details);
|
||||
await refreshSyncQueue("conflict");
|
||||
return {
|
||||
status: "conflict",
|
||||
conflict: error.details,
|
||||
};
|
||||
}
|
||||
await window.MDHubCache.putPendingEdit(normalizedPath, content, baseHash);
|
||||
await refreshSyncQueue("queued");
|
||||
return {
|
||||
status: "queued",
|
||||
};
|
||||
@@ -62,15 +161,19 @@
|
||||
|
||||
async function syncPending() {
|
||||
if (!navigator.onLine) {
|
||||
await refreshSyncQueue("offline");
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = await window.MDHubCache.getPendingEdits();
|
||||
const pending = await refreshSyncQueue("syncing");
|
||||
pending.sort(function (a, b) {
|
||||
return (a.updatedAt || 0) - (b.updatedAt || 0);
|
||||
});
|
||||
|
||||
for (const edit of pending) {
|
||||
if (edit.conflict) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await postDocument(edit.path.replace(/^\/docs\//, ""), edit.content, edit.baseHash);
|
||||
await window.MDHubCache.deletePendingEdit(edit.path);
|
||||
@@ -81,16 +184,31 @@
|
||||
break;
|
||||
}
|
||||
}
|
||||
await refreshSyncQueue("idle");
|
||||
}
|
||||
|
||||
window.addEventListener("online", function () {
|
||||
syncPending().catch(function () {});
|
||||
});
|
||||
|
||||
window.addEventListener("offline", function () {
|
||||
refreshSyncQueue("offline").catch(function () {});
|
||||
});
|
||||
|
||||
if (syncNow) {
|
||||
syncNow.addEventListener("click", function () {
|
||||
syncPending().catch(function () {
|
||||
refreshSyncQueue("queued").catch(function () {});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
syncPending().catch(function () {});
|
||||
refreshSyncQueue("idle").catch(function () {});
|
||||
|
||||
window.MDHubSync = {
|
||||
saveDocument: saveDocument,
|
||||
syncPending: syncPending,
|
||||
refreshSyncQueue: refreshSyncQueue,
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
@@ -24,9 +22,13 @@ func (s *Server) handleSyncInit(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
userID := r.Context().Value("userID").(string)
|
||||
principal, ok := requirePrincipal(r)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||
return
|
||||
}
|
||||
|
||||
snapshot, err := s.syncService.InitSync(r.Context(), req.DeviceID, userID)
|
||||
snapshot, err := s.syncService.InitSync(r.Context(), req.DeviceID, principal.UserID)
|
||||
if err != nil {
|
||||
s.logger.Error("sync init failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to initialize sync"})
|
||||
@@ -112,30 +114,3 @@ func (s *Server) handleContentFetch(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(content)
|
||||
}
|
||||
|
||||
func (s *Server) apiKeyMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Skip for non-sync routes
|
||||
if !strings.HasPrefix(r.URL.Path, "/api/sync/") && !strings.HasPrefix(r.URL.Path, "/api/content/") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
apiKey := r.Header.Get("X-API-Key")
|
||||
if apiKey == "" {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing API key"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate API key against database
|
||||
record, err := s.syncRepo.ValidateAPIKey(r.Context(), apiKey)
|
||||
if err != nil {
|
||||
s.logger.Warn("invalid api key", "error", err)
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid API key"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), "userID", record.UserID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
72
apps/server/internal/httpserver/templates/account.gohtml
Normal file
72
apps/server/internal/httpserver/templates/account.gohtml
Normal file
@@ -0,0 +1,72 @@
|
||||
{{ define "account.gohtml" }}{{ template "base" . }}{{ end }}
|
||||
|
||||
{{ define "account_content" }}
|
||||
<section class="account-shell">
|
||||
<header class="account-header">
|
||||
<div>
|
||||
<p class="eyebrow">Account</p>
|
||||
<h1>{{ .DisplayName }}</h1>
|
||||
<p>{{ .Email }} · {{ .Role }}</p>
|
||||
</div>
|
||||
<button type="button" data-auth-logout>Sign out</button>
|
||||
</header>
|
||||
|
||||
<div class="account-grid">
|
||||
<section class="account-section">
|
||||
<h2>API Tokens</h2>
|
||||
<form class="auth-form" data-token-create>
|
||||
<label>
|
||||
Name
|
||||
<input type="text" name="name" placeholder="CLI on laptop" required />
|
||||
</label>
|
||||
<fieldset class="scope-grid">
|
||||
<legend>Scopes</legend>
|
||||
<label><input type="checkbox" name="scope" value="docs:read" checked /> Docs read</label>
|
||||
<label><input type="checkbox" name="scope" value="docs:write" /> Docs write</label>
|
||||
<label><input type="checkbox" name="scope" value="sync:read" checked /> Sync read</label>
|
||||
<label><input type="checkbox" name="scope" value="sync:write" /> Sync write</label>
|
||||
<label><input type="checkbox" name="scope" value="admin" /> Admin</label>
|
||||
</fieldset>
|
||||
<button type="submit">Create token</button>
|
||||
<p class="form-status" data-token-status></p>
|
||||
<pre class="token-output" data-token-output hidden></pre>
|
||||
</form>
|
||||
<ul class="token-list" data-token-list></ul>
|
||||
</section>
|
||||
|
||||
<section class="account-section">
|
||||
<h2>Password</h2>
|
||||
<form class="auth-form" data-password-change>
|
||||
<label>
|
||||
Current password
|
||||
<input type="password" name="currentPassword" autocomplete="current-password" />
|
||||
</label>
|
||||
<label>
|
||||
New password
|
||||
<input type="password" name="newPassword" autocomplete="new-password" minlength="12" required />
|
||||
</label>
|
||||
<button type="submit">Change password</button>
|
||||
<p class="form-status" data-password-status></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="account-section" data-admin-users-section hidden>
|
||||
<h2>Users</h2>
|
||||
<div class="user-list" data-admin-users></div>
|
||||
<p class="form-status" data-admin-users-status></p>
|
||||
</section>
|
||||
|
||||
<section class="account-section account-section--danger">
|
||||
<h2>Delete Account</h2>
|
||||
<form class="auth-form" data-account-delete>
|
||||
<label>
|
||||
Current password
|
||||
<input type="password" name="currentPassword" autocomplete="current-password" />
|
||||
</label>
|
||||
<button type="submit">Delete account</button>
|
||||
<p class="form-status" data-delete-status></p>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
{{ end }}
|
||||
@@ -19,7 +19,7 @@
|
||||
<header class="site-header">
|
||||
<div class="site-header__inner">
|
||||
<a class="site-brand" href="/" aria-label="Cairnquire home">
|
||||
<img src="/static/cairnquire%20logo%402x.webp" alt="" width="160" height="40" decoding="async" />
|
||||
<img src="/static/favicon.png" alt="" width="64" height="64" decoding="async" />
|
||||
<span class="sr-only">Cairnquire</span>
|
||||
</a>
|
||||
<form class="site-search" action="/" method="get">
|
||||
@@ -28,6 +28,7 @@
|
||||
</form>
|
||||
<nav class="site-nav">
|
||||
<a href="/">Docs</a>
|
||||
<a href="/account">Account</a>
|
||||
{{ if .WebEnabled }}<a href="/app/">Admin</a>{{ end }}
|
||||
</nav>
|
||||
</div>
|
||||
@@ -41,6 +42,12 @@
|
||||
{{ template "document_edit_content" .Data }}
|
||||
{{ else if eq .BodyTemplate "search_content" }}
|
||||
{{ template "search_content" .Data }}
|
||||
{{ else if eq .BodyTemplate "login_content" }}
|
||||
{{ template "login_content" .Data }}
|
||||
{{ else if eq .BodyTemplate "account_content" }}
|
||||
{{ template "account_content" .Data }}
|
||||
{{ else if eq .BodyTemplate "device_verify_content" }}
|
||||
{{ template "device_verify_content" .Data }}
|
||||
{{ else if eq .BodyTemplate "error_content" }}
|
||||
{{ template "error_content" .Data }}
|
||||
{{ end }}
|
||||
@@ -53,6 +60,16 @@
|
||||
<span aria-hidden="true" class="cached-icon"></span>
|
||||
<p>Viewing cached offline content.</p>
|
||||
</div>
|
||||
<section class="sync-queue" data-sync-queue hidden aria-live="polite">
|
||||
<div class="sync-queue__header">
|
||||
<div>
|
||||
<strong data-sync-queue-title>Pending changes</strong>
|
||||
<p data-sync-queue-summary>Waiting to sync.</p>
|
||||
</div>
|
||||
<button type="button" data-sync-now>Sync now</button>
|
||||
</div>
|
||||
<ul data-sync-queue-list></ul>
|
||||
</section>
|
||||
<div class="version-notice" data-version-notice hidden>
|
||||
<p>A newer version is available.</p>
|
||||
<button type="button" data-version-reload>Reload</button>
|
||||
@@ -61,6 +78,7 @@
|
||||
<script src="/static/sync.js" defer></script>
|
||||
<script src="/static/realtime.js" defer></script>
|
||||
<script src="/static/editor.js" defer></script>
|
||||
<script src="/static/auth.js" defer></script>
|
||||
<script src="/static/render.js" defer></script>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{{ define "device_verify.gohtml" }}{{ template "base" . }}{{ end }}
|
||||
|
||||
{{ define "device_verify_content" }}
|
||||
<section class="auth-shell">
|
||||
<div class="auth-panel">
|
||||
<div class="auth-panel__header">
|
||||
<p class="eyebrow">Device authorization</p>
|
||||
<h1>Approve device</h1>
|
||||
</div>
|
||||
<form class="auth-form" data-device-approve>
|
||||
<label>
|
||||
User code
|
||||
<input type="text" name="userCode" value="{{ .UserCode }}" autocomplete="one-time-code" required />
|
||||
</label>
|
||||
<button type="submit">Approve</button>
|
||||
<p class="form-status" data-device-status></p>
|
||||
</form>
|
||||
<p class="auth-note">You must be signed in with a browser session before approving a device.</p>
|
||||
</div>
|
||||
</section>
|
||||
{{ end }}
|
||||
76
apps/server/internal/httpserver/templates/login.gohtml
Normal file
76
apps/server/internal/httpserver/templates/login.gohtml
Normal file
@@ -0,0 +1,76 @@
|
||||
{{ define "login.gohtml" }}{{ template "base" . }}{{ end }}
|
||||
|
||||
{{ define "login_content" }}
|
||||
<section class="auth-shell">
|
||||
<input type="hidden" data-auth-next value="{{ .Next }}" />
|
||||
<div class="auth-panel">
|
||||
<div class="auth-panel__header">
|
||||
<p class="eyebrow">Cairnquire</p>
|
||||
<h1>Sign in</h1>
|
||||
</div>
|
||||
|
||||
<div class="auth-grid">
|
||||
<form class="auth-form" data-auth-login>
|
||||
<h2>Password</h2>
|
||||
<label>
|
||||
Email
|
||||
<input type="email" name="email" autocomplete="email" required />
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input type="password" name="password" autocomplete="current-password" required />
|
||||
</label>
|
||||
<button type="submit">Sign in</button>
|
||||
<p class="form-status" data-login-status></p>
|
||||
</form>
|
||||
|
||||
<form class="auth-form" data-passkey-login>
|
||||
<h2>Passkey</h2>
|
||||
<label>
|
||||
Email
|
||||
<input type="email" name="email" autocomplete="username webauthn" required />
|
||||
</label>
|
||||
<button type="submit">Use passkey</button>
|
||||
<p class="form-status" data-passkey-login-status></p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<details class="auth-details">
|
||||
<summary>Create an account</summary>
|
||||
<div class="auth-grid">
|
||||
<form class="auth-form" data-auth-register>
|
||||
<h2>Password account</h2>
|
||||
<label>
|
||||
Email
|
||||
<input type="email" name="email" autocomplete="email" required />
|
||||
</label>
|
||||
<label>
|
||||
Display name
|
||||
<input type="text" name="displayName" autocomplete="name" />
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input type="password" name="password" autocomplete="new-password" minlength="12" required />
|
||||
</label>
|
||||
<button type="submit">Register</button>
|
||||
<p class="form-status" data-register-status></p>
|
||||
</form>
|
||||
|
||||
<form class="auth-form" data-passkey-register>
|
||||
<h2>Passkey account</h2>
|
||||
<label>
|
||||
Email
|
||||
<input type="email" name="email" autocomplete="email" required />
|
||||
</label>
|
||||
<label>
|
||||
Display name
|
||||
<input type="text" name="displayName" autocomplete="name" />
|
||||
</label>
|
||||
<button type="submit">Create passkey</button>
|
||||
<p class="form-status" data-passkey-register-status></p>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
{{ end }}
|
||||
Reference in New Issue
Block a user