Add configuration files and update project documentation

- Introduced .editorconfig for consistent coding styles across the project.
- Added .golangci.yml for Go linting configuration.
- Updated AGENTS.md to clarify project structure and components.
- Enhanced CONTRIBUTING.md with Makefile usage for common tasks.
- Updated Dockerfiles to use Go 1.24 and improved build instructions.
- Refined README.md and deployment documentation for clarity.
- Added testing documentation in testing.md for backend and frontend tests.
- Introduced Makefile for streamlined development commands and tasks.
This commit is contained in:
2026-03-01 01:51:47 +03:00
parent 0466ff3087
commit 6529d7370e
92 changed files with 13411 additions and 8438 deletions

View File

@@ -1,36 +1,58 @@
package services
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"log/slog"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/apperr"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
"golang.org/x/crypto/bcrypt"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
// AuthService handles authentication and session business logic.
const oauthStateTTL = 10 * time.Minute
type oauthState struct {
Provider string `json:"provider"`
RedirectURI string `json:"redirect_uri,omitempty"`
CreatedAt int64 `json:"created_at"`
}
type googleUserInfo struct {
Sub string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
}
// AuthService handles authentication, sessions, and OAuth business logic.
type AuthService struct {
st *store.Store
}
// NewAuthService creates an AuthService.
// NewAuthService creates an AuthService with the given store.
func NewAuthService(st *store.Store) *AuthService {
return &AuthService{st: st}
}
// GetSession returns the session from the request cookie, or nil.
func (s *AuthService) GetSession(req *http.Request) *app.Session {
func (s *AuthService) GetSession(ctx context.Context, req *http.Request) *app.Session {
c, err := req.Cookie("session")
if err != nil {
return nil
}
var sess *app.Session
s.st.View(func(tx *bbolt.Tx) error {
s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetSession(tx, c.Value)
if raw == nil {
return nil
@@ -59,15 +81,17 @@ func (s *AuthService) GetSession(req *http.Request) *app.Session {
}
// DeleteSession removes a session.
func (s *AuthService) DeleteSession(sess *app.Session) {
s.st.Update(func(tx *bbolt.Tx) error {
func (s *AuthService) DeleteSession(ctx context.Context, sess *app.Session) {
if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
return s.st.DeleteSession(tx, sess.ID)
})
}); err != nil {
slog.Error("failed to delete session", "session_id", sess.ID, "error", err)
}
}
// SaveSession stores a session.
func (s *AuthService) SaveSession(sess *app.Session) error {
return s.st.Update(func(tx *bbolt.Tx) error {
func (s *AuthService) SaveSession(ctx context.Context, sess *app.Session) error {
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
buf, err := json.Marshal(sess)
if err != nil {
return err
@@ -77,7 +101,7 @@ func (s *AuthService) SaveSession(sess *app.Session) error {
}
// CreateSession creates a session for username, returns session ID or empty string.
func (s *AuthService) CreateSession(username string, tempAdmin bool) string {
func (s *AuthService) CreateSession(ctx context.Context, username string, tempAdmin bool) string {
session := make([]byte, 32)
if _, err := rand.Read(session); err != nil {
return ""
@@ -88,16 +112,16 @@ func (s *AuthService) CreateSession(username string, tempAdmin bool) string {
Username: username,
TempAdmin: tempAdmin,
}
if s.SaveSession(sess) != nil {
if s.SaveSession(ctx, sess) != nil {
return ""
}
return sid
}
// GetUser returns user if username/password match.
func (s *AuthService) GetUser(username, pass string) *app.User {
func (s *AuthService) GetUser(ctx context.Context, username, pass string) *app.User {
var u *app.User
s.st.View(func(tx *bbolt.Tx) error {
s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetUser(tx, username)
if raw == nil {
return nil
@@ -117,9 +141,9 @@ func (s *AuthService) GetUser(username, pass string) *app.User {
}
// GetUserByUsername returns user without password check (for OAuth-only check).
func (s *AuthService) GetUserByUsername(username string) *app.User {
func (s *AuthService) GetUserByUsername(ctx context.Context, username string) *app.User {
var u *app.User
s.st.View(func(tx *bbolt.Tx) error {
s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetUser(tx, username)
if raw != nil {
json.Unmarshal(raw, &u)
@@ -130,9 +154,9 @@ func (s *AuthService) GetUserByUsername(username string) *app.User {
}
// SetupRequired returns true if no users exist (first run).
func (s *AuthService) SetupRequired() bool {
func (s *AuthService) SetupRequired(ctx context.Context) bool {
var required bool
s.st.View(func(tx *bbolt.Tx) error {
s.st.View(ctx, func(tx *bbolt.Tx) error {
if s.st.UserCount(tx) == 0 {
required = true
}
@@ -142,14 +166,13 @@ func (s *AuthService) SetupRequired() bool {
}
// BootstrapAdmin creates the first admin user if bootstrap env is set and no users exist.
// Returns the user if created, nil otherwise.
func (s *AuthService) BootstrapAdmin(username, pass, bootstrapEnv string) *app.User {
func (s *AuthService) BootstrapAdmin(ctx context.Context, username, pass, bootstrapEnv string) *app.User {
if username != "admin" || pass == "" || bootstrapEnv == "" || pass != bootstrapEnv {
return nil
}
var created bool
var u *app.User
s.st.Update(func(tx *bbolt.Tx) error {
s.st.Update(ctx, func(tx *bbolt.Tx) error {
if s.st.GetUser(tx, "admin") != nil {
return nil
}
@@ -181,8 +204,8 @@ func GetBootstrapPassword() string {
}
// GetUserTokensAndPrefix returns tokens and config prefix for a user.
func (s *AuthService) GetUserTokensAndPrefix(username string) (tokens []string, prefix string) {
s.st.View(func(tx *bbolt.Tx) error {
func (s *AuthService) GetUserTokensAndPrefix(ctx context.Context, username string) (tokens []string, prefix string) {
s.st.View(ctx, func(tx *bbolt.Tx) error {
uRaw := s.st.GetUser(tx, username)
if uRaw != nil {
var u app.User
@@ -198,14 +221,14 @@ func (s *AuthService) GetUserTokensAndPrefix(username string) (tokens []string,
}
// GenerateTokenForUser adds a new token for user and returns the full list.
func (s *AuthService) GenerateTokenForUser(username string) []string {
func (s *AuthService) GenerateTokenForUser(ctx context.Context, username string) []string {
tokenRaw := make([]byte, 16)
if _, err := rand.Read(tokenRaw); err != nil {
return nil
}
token := hex.EncodeToString(tokenRaw)
var tokens []string
s.st.Update(func(tx *bbolt.Tx) error {
s.st.Update(ctx, func(tx *bbolt.Tx) error {
uRaw := s.st.GetUser(tx, username)
u := app.User{}
if uRaw != nil {
@@ -221,11 +244,11 @@ func (s *AuthService) GenerateTokenForUser(username string) []string {
}
// SetUserPassword sets password for user (empty pass = no change).
func (s *AuthService) SetUserPassword(username, pass string) error {
func (s *AuthService) SetUserPassword(ctx context.Context, username, pass string) error {
if pass == "" {
return nil
}
return s.st.Update(func(tx *bbolt.Tx) error {
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
uRaw := s.st.GetUser(tx, username)
u := app.User{}
if uRaw != nil {
@@ -240,3 +263,243 @@ func (s *AuthService) SetUserPassword(username, pass string) error {
return s.st.PutUser(tx, username, raw)
})
}
// ValidateClientToken validates a client token and returns the username if valid with upload permission.
func (s *AuthService) ValidateClientToken(ctx context.Context, token string) (string, error) {
var username string
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
userName := s.st.GetTokenUser(tx, token)
if userName == nil {
return apperr.ErrUnauthorized
}
uRaw := s.st.GetUser(tx, string(userName))
if uRaw == nil {
return apperr.ErrUnauthorized
}
var u app.User
json.Unmarshal(uRaw, &u)
if !u.Auths.Has(app.AUTH_UPLOAD) {
return apperr.ErrForbidden
}
username = string(userName)
return nil
})
return username, err
}
// --- OAuth ---
// OAuthConfig returns OAuth2 config for the given provider, or nil if not configured.
func OAuthConfig(provider string, baseURL string) *oauth2.Config {
switch provider {
case "google":
clientID := os.Getenv("HNHMAP_OAUTH_GOOGLE_CLIENT_ID")
clientSecret := os.Getenv("HNHMAP_OAUTH_GOOGLE_CLIENT_SECRET")
if clientID == "" || clientSecret == "" {
return nil
}
redirectURL := strings.TrimSuffix(baseURL, "/") + "/map/api/oauth/google/callback"
return &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: redirectURL,
Scopes: []string{"openid", "email", "profile"},
Endpoint: google.Endpoint,
}
default:
return nil
}
}
// BaseURL returns the configured base URL for the app.
func BaseURL(req *http.Request) string {
if base := os.Getenv("HNHMAP_BASE_URL"); base != "" {
return strings.TrimSuffix(base, "/")
}
scheme := "https"
if req.TLS == nil {
scheme = "http"
}
host := req.Host
if h := req.Header.Get("X-Forwarded-Host"); h != "" {
host = h
}
if proto := req.Header.Get("X-Forwarded-Proto"); proto != "" {
scheme = proto
}
return scheme + "://" + host
}
// OAuthProviders returns list of configured OAuth providers.
func OAuthProviders() []string {
var providers []string
if os.Getenv("HNHMAP_OAUTH_GOOGLE_CLIENT_ID") != "" && os.Getenv("HNHMAP_OAUTH_GOOGLE_CLIENT_SECRET") != "" {
providers = append(providers, "google")
}
return providers
}
// OAuthInitLogin creates an OAuth state and returns the redirect URL.
func (s *AuthService) OAuthInitLogin(ctx context.Context, provider, redirectURI string, req *http.Request) (string, error) {
baseURL := BaseURL(req)
cfg := OAuthConfig(provider, baseURL)
if cfg == nil {
return "", apperr.ErrProviderUnconfigured
}
state := make([]byte, 32)
if _, err := rand.Read(state); err != nil {
return "", err
}
stateStr := hex.EncodeToString(state)
st := oauthState{
Provider: provider,
RedirectURI: redirectURI,
CreatedAt: time.Now().Unix(),
}
stRaw, _ := json.Marshal(st)
if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
return s.st.PutOAuthState(tx, stateStr, stRaw)
}); err != nil {
return "", err
}
authURL := cfg.AuthCodeURL(stateStr, oauth2.AccessTypeOffline)
return authURL, nil
}
// OAuthHandleCallback processes the OAuth callback, validates state, exchanges code, and creates a session.
// Returns (sessionID, redirectPath, error).
func (s *AuthService) OAuthHandleCallback(ctx context.Context, provider, code, state string, req *http.Request) (string, string, error) {
baseURL := BaseURL(req)
cfg := OAuthConfig(provider, baseURL)
if cfg == nil {
return "", "", apperr.ErrProviderUnconfigured
}
var st oauthState
err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetOAuthState(tx, state)
if raw == nil {
return apperr.ErrBadRequest
}
json.Unmarshal(raw, &st)
return s.st.DeleteOAuthState(tx, state)
})
if err != nil || st.Provider == "" {
return "", "", apperr.ErrBadRequest
}
if time.Since(time.Unix(st.CreatedAt, 0)) > oauthStateTTL {
return "", "", apperr.ErrStateExpired
}
if st.Provider != provider {
return "", "", apperr.ErrStateMismatch
}
tok, err := cfg.Exchange(ctx, code)
if err != nil {
slog.Error("OAuth exchange failed", "provider", provider, "error", err)
return "", "", apperr.ErrExchangeFailed
}
var sub, email string
switch provider {
case "google":
sub, email, err = fetchGoogleUserInfo(ctx, tok.AccessToken)
if err != nil {
slog.Error("failed to get Google user info", "error", err)
return "", "", apperr.ErrUserInfoFailed
}
default:
return "", "", apperr.ErrBadRequest
}
username := s.findOrCreateOAuthUser(ctx, provider, sub, email)
if username == "" {
return "", "", apperr.ErrInternal
}
sessionID := s.CreateSession(ctx, username, false)
if sessionID == "" {
return "", "", apperr.ErrInternal
}
redirectTo := "/profile"
if st.RedirectURI != "" {
if u, err := url.Parse(st.RedirectURI); err == nil && u.Path != "" && !strings.HasPrefix(u.Path, "//") {
redirectTo = u.Path
if u.RawQuery != "" {
redirectTo += "?" + u.RawQuery
}
}
}
return sessionID, redirectTo, nil
}
func fetchGoogleUserInfo(ctx context.Context, accessToken string) (sub, email string, err error) {
req, err := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/oauth2/v3/userinfo", nil)
if err != nil {
return "", "", err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", "", apperr.ErrUserInfoFailed
}
var info googleUserInfo
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return "", "", err
}
return info.Sub, info.Email, nil
}
func (s *AuthService) findOrCreateOAuthUser(ctx context.Context, provider, sub, email string) string {
var username string
err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
_ = s.st.ForEachUser(tx, func(k, v []byte) error {
user := app.User{}
if json.Unmarshal(v, &user) != nil {
return nil
}
if user.OAuthLinks != nil && user.OAuthLinks[provider] == sub {
username = string(k)
}
return nil
})
if username != "" {
raw := s.st.GetUser(tx, username)
if raw != nil {
user := app.User{}
json.Unmarshal(raw, &user)
if user.OAuthLinks == nil {
user.OAuthLinks = map[string]string{provider: sub}
} else {
user.OAuthLinks[provider] = sub
}
raw, _ = json.Marshal(user)
s.st.PutUser(tx, username, raw)
}
return nil
}
username = email
if username == "" {
username = provider + "_" + sub
}
if s.st.GetUser(tx, username) != nil {
username = provider + "_" + sub
}
newUser := &app.User{
Pass: nil,
Auths: app.Auths{app.AUTH_MAP, app.AUTH_MARKERS, app.AUTH_UPLOAD},
OAuthLinks: map[string]string{provider: sub},
}
raw, _ := json.Marshal(newUser)
return s.st.PutUser(tx, username, raw)
})
if err != nil {
slog.Error("failed to find or create OAuth user", "provider", provider, "error", err)
return ""
}
return username
}