diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ce9a4a2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Binaries(/ 錨定僅忽略根目錄執行檔;未錨定的 alterminal 會連 +# cmd/alterminal 原始碼目錄一併忽略) +/alterminal +*.exe + +# Build tools (Tailwind standalone CLI,下載方式見 README) +tools/ + +# Test and coverage +*.test +*.out + +# Go workspace +go.work +go.work.sum + +# Dependencies +vendor/ + +# Environment variables (contains DB credentials) +.env +.env.* + +# Logs +*.log + +# Editor / IDE +.idea/ +.vscode/ + +# OS +.DS_Store +Thumbs.db diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..eed7b2a --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +TAILWIND := tools/tailwindcss +CSS_SRC := web/static/src/input.css +CSS_OUT := web/static/css/app.css + +.PHONY: css watch run test build clean + +# 編譯 Tailwind CSS(產物 app.css 會內嵌進執行檔,記得重新 go build) +css: + $(TAILWIND) -i $(CSS_SRC) -o $(CSS_OUT) --minify + +# 開發時監看模板變更並即時重建 CSS +watch: + $(TAILWIND) -i $(CSS_SRC) -o $(CSS_OUT) --watch + +run: + go run . + +test: + go test ./... + +build: css + go build -o nestly . + +clean: + rm -f nestly diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c2085e3 --- /dev/null +++ b/go.mod @@ -0,0 +1,26 @@ +module nestly + +go 1.26.5 + +require ( + github.com/glebarez/sqlite v1.11.0 + github.com/go-chi/chi/v5 v5.3.2 + golang.org/x/crypto v0.57.0 + gorm.io/gorm v1.31.2 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9adab4d --- /dev/null +++ b/go.sum @@ -0,0 +1,42 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= +github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= +gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= diff --git a/internal/auth/session.go b/internal/auth/session.go new file mode 100644 index 0000000..510b90e --- /dev/null +++ b/internal/auth/session.go @@ -0,0 +1,109 @@ +// Package auth 提供以 HMAC 簽名 cookie 實作的輕量 session, +// 不需額外的 session 儲存,適合目前無狀態的 SSR 架構。 +package auth + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" +) + +const ( + // CookieName session cookie 的名稱。 + CookieName = "nestly_session" + + defaultTTL = 7 * 24 * time.Hour +) + +// ErrInvalidSession 代表 cookie 不存在、簽名不符或已過期; +// 呼叫端可將其視為「未登入」而非錯誤。 +var ErrInvalidSession = errors.New("session 無效或已過期") + +// SessionManager 以伺服器端密鑰簽發並驗證 session cookie。 +// cookie 值格式為 "uid.expUnix.signature"(HMAC-SHA256), +// 驗證僅保證簽名正確且未過期,帳號是否仍存在須由呼叫端查詢。 +type SessionManager struct { + secret []byte + ttl time.Duration + secure bool +} + +// NewSessionManager 以 secret 簽章建立 SessionManager; +// secure 控制 cookie 是否僅經 HTTPS 傳送(本機開發為 false)。 +func NewSessionManager(secret string, secure bool) *SessionManager { + return &SessionManager{ + secret: []byte(secret), + ttl: defaultTTL, + secure: secure, + } +} + +func (m *SessionManager) sign(payload string) []byte { + mac := hmac.New(sha256.New, m.secret) + mac.Write([]byte(payload)) + return mac.Sum(nil) +} + +// Login 為 uid 簽發 session cookie。 +func (m *SessionManager) Login(w http.ResponseWriter, uid uint) { + payload := fmt.Sprintf("%d.%d", uid, time.Now().Add(m.ttl).Unix()) + http.SetCookie(w, &http.Cookie{ + Name: CookieName, + Value: payload + "." + hex.EncodeToString(m.sign(payload)), + Path: "/", + MaxAge: int(m.ttl.Seconds()), + HttpOnly: true, + Secure: m.secure, + SameSite: http.SameSiteLaxMode, + }) +} + +// Logout 使 session cookie 立即失效。 +func (m *SessionManager) Logout(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: CookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: m.secure, + SameSite: http.SameSiteLaxMode, + }) +} + +// UserID 驗證請求中的 session cookie 並取出使用者 ID。 +func (m *SessionManager) UserID(r *http.Request) (uint, error) { + c, err := r.Cookie(CookieName) + if err != nil || c.Value == "" { + return 0, ErrInvalidSession + } + // 值格式為 uid.exp.sig,簽名為最後一段,從右側切分以免誤切數值中的點。 + dot := strings.LastIndexByte(c.Value, '.') + if dot <= 0 { + return 0, ErrInvalidSession + } + payload, sigHex := c.Value[:dot], c.Value[dot+1:] + uidStr, expStr, ok := strings.Cut(payload, ".") + if !ok { + return 0, ErrInvalidSession + } + uid, err := strconv.ParseUint(uidStr, 10, 64) + if err != nil { + return 0, ErrInvalidSession + } + exp, err := strconv.ParseInt(expStr, 10, 64) + if err != nil || time.Now().Unix() >= exp { + return 0, ErrInvalidSession + } + sig, err := hex.DecodeString(sigHex) + if err != nil || !hmac.Equal(sig, m.sign(payload)) { + return 0, ErrInvalidSession + } + return uint(uid), nil +} diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go new file mode 100644 index 0000000..1c7b03a --- /dev/null +++ b/internal/handlers/auth.go @@ -0,0 +1,61 @@ +package handlers + +import ( + "errors" + "net/http" + "strings" + + "nestly/internal/models" +) + +// loginView 登入頁的模板資料。 +type loginView struct { + Email string + Error string +} + +// AuthHandler 處理登入與登出。 +type AuthHandler struct { + deps Dependencies +} + +// Login 渲染登入頁;已登入者直接導向首頁。 +func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { + if _, err := h.deps.Sessions.UserID(r); err == nil { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + if err := h.deps.Templates.Render(w, http.StatusOK, "login.html", loginView{}); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +// DoLogin 驗證 Email 與密碼,成功後建立 session 並導向首頁; +// 失敗時重新渲染表單並保留使用者輸入的 Email。 +func (h *AuthHandler) DoLogin(w http.ResponseWriter, r *http.Request) { + email := strings.TrimSpace(r.PostFormValue("email")) + password := r.PostFormValue("password") + + // 帳號不存在與密碼錯誤回應同一訊息,避免列舉有效 Email。 + view := loginView{Email: email} + acct, err := h.deps.Accounts.FindByEmail(r.Context(), email) + if err != nil && !errors.Is(err, models.ErrNotFound) { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err == nil && acct.VerifyPassword(password) { + h.deps.Sessions.Login(w, acct.ID) + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + view.Error = "Email 或密碼不正確。" + if err := h.deps.Templates.Render(w, http.StatusUnauthorized, "login.html", view); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +// Logout 清除 session 並回到登入頁。 +func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { + h.deps.Sessions.Logout(w) + http.Redirect(w, r, "/login", http.StatusSeeOther) +} diff --git a/internal/handlers/auth_test.go b/internal/handlers/auth_test.go new file mode 100644 index 0000000..3389f79 --- /dev/null +++ b/internal/handlers/auth_test.go @@ -0,0 +1,185 @@ +package handlers + +import ( + "context" + "io/fs" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + + "nestly/internal/auth" + "nestly/internal/models" + "nestly/internal/templates" + "nestly/web" +) + +// newTestServer 建立以記憶體 SQLite 為後端的測試路由與示範帳號。 +func newTestServer(t *testing.T) http.Handler { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("開啟記憶體資料庫失敗: %v", err) + } + accounts := models.NewAccountStore(db) + if err := accounts.AutoMigrate(context.Background()); err != nil { + t.Fatalf("自動遷移失敗: %v", err) + } + acct := &models.Account{Email: "demo@nestly.test", Name: "示範帳號", Role: models.RoleMember} + if err := acct.SetPassword("nestly1234"); err != nil { + t.Fatalf("設定密碼失敗: %v", err) + } + if err := accounts.Create(context.Background(), acct); err != nil { + t.Fatalf("建立帳號失敗: %v", err) + } + + templateFS, err := fs.Sub(web.TemplatesFS, "templates") + if err != nil { + t.Fatalf("建立模板子目錄失敗: %v", err) + } + engine, err := templates.New(templateFS) + if err != nil { + t.Fatalf("載入模板失敗: %v", err) + } + return NewRouter(Dependencies{ + Templates: engine, + Accounts: accounts, + Sessions: auth.NewSessionManager("test-secret", false), + }) +} + +func TestLoginPageRenders(t *testing.T) { + srv := newTestServer(t) + + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("GET /login 狀態碼 = %d, 想要 200", rec.Code) + } + body := rec.Body.String() + for _, want := range []string{"登入", `name="email"`, `name="password"`} { + if !strings.Contains(body, want) { + t.Errorf("GET /login 回應缺少 %q", want) + } + } +} + +func TestLoginWrongPassword(t *testing.T) { + srv := newTestServer(t) + + form := url.Values{"email": {"demo@nestly.test"}, "password": {"wrong-password"}} + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("POST /login(錯誤密碼)狀態碼 = %d, 想要 401", rec.Code) + } + if body := rec.Body.String(); !strings.Contains(body, "Email 或密碼不正確") { + t.Error("錯誤密碼應顯示錯誤訊息") + } + if body := rec.Body.String(); !strings.Contains(body, `value="demo@nestly.test"`) { + t.Error("錯誤後應保留使用者輸入的 Email") + } +} + +func TestLoginSuccessFlow(t *testing.T) { + srv := newTestServer(t) + + form := url.Values{"email": {"Demo@Nestly.test "}, "password": {"nestly1234"}} // email 大小寫與空白應被容忍 + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("POST /login(正確)狀態碼 = %d, 想要 303", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "/" { + t.Errorf("登入成功應導向 /, 實際 %q", loc) + } + var cookie *http.Cookie + for _, c := range rec.Result().Cookies() { + if c.Name == auth.CookieName { + cookie = c + } + } + if cookie == nil { + t.Fatal("登入成功應設定 session cookie") + } + + // 帶著 session cookie 造訪首頁,應顯示帳號名稱而非登入連結。 + req = httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(cookie) + rec = httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("GET /(已登入)狀態碼 = %d, 想要 200", rec.Code) + } + if body := rec.Body.String(); !strings.Contains(body, "示範帳號") { + t.Error("已登入的首頁應顯示帳號名稱") + } + + // 已登入者造訪 /login 應被導回首頁。 + req = httptest.NewRequest(http.MethodGet, "/login", nil) + req.AddCookie(cookie) + rec = httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther { + t.Errorf("GET /login(已登入)狀態碼 = %d, 想要 303", rec.Code) + } + + // 登出後 cookie 失效,首頁不再顯示帳號。 + req = httptest.NewRequest(http.MethodPost, "/logout", nil) + req.AddCookie(cookie) + rec = httptest.NewRecorder() + srv.ServeHTTP(rec, req) + var cleared *http.Cookie + for _, c := range rec.Result().Cookies() { + if c.Name == auth.CookieName { + cleared = c + } + } + if cleared == nil || cleared.MaxAge >= 0 { + t.Fatal("登出應清除 session cookie") + } + req = httptest.NewRequest(http.MethodGet, "/", nil) + if cleared != nil { + req.AddCookie(cleared) + } + rec = httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if body := rec.Body.String(); strings.Contains(body, "示範帳號") { + t.Error("登出後首頁不應顯示帳號名稱") + } +} + +func TestTamperedSessionRejected(t *testing.T) { + srv := newTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: "1.9999999999.deadbeef"}) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if body := rec.Body.String(); strings.Contains(body, "示範帳號") { + t.Error("偽造的 session 不應被接受") + } +} + +func TestStaticCSSServed(t *testing.T) { + srv := newTestServer(t) + + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/static/css/app.css", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("GET /static/css/app.css 狀態碼 = %d, 想要 200(記得先執行 make css)", rec.Code) + } +} diff --git a/internal/handlers/home.go b/internal/handlers/home.go new file mode 100644 index 0000000..e692c56 --- /dev/null +++ b/internal/handlers/home.go @@ -0,0 +1,30 @@ +package handlers + +import ( + "net/http" + + "nestly/internal/models" +) + +// homeView 首頁的模板資料;未登入時 Account 為 nil。 +type homeView struct { + Account *models.Account +} + +// HomeHandler 渲染首頁。 +type HomeHandler struct { + deps Dependencies +} + +// Show 顯示首頁;持有有效 session 時一併帶出帳號資料。 +func (h *HomeHandler) Show(w http.ResponseWriter, r *http.Request) { + view := homeView{} + if uid, err := h.deps.Sessions.UserID(r); err == nil { + if acct, err := h.deps.Accounts.FindByID(r.Context(), uid); err == nil { + view.Account = acct + } + } + if err := h.deps.Templates.Render(w, http.StatusOK, "home.html", view); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} diff --git a/internal/handlers/routes.go b/internal/handlers/routes.go new file mode 100644 index 0000000..b6501b9 --- /dev/null +++ b/internal/handlers/routes.go @@ -0,0 +1,47 @@ +// Package handlers 實作 Nestly 的 HTTP handlers 與路由。 +package handlers + +import ( + "io/fs" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + + "nestly/internal/auth" + "nestly/internal/models" + "nestly/internal/templates" + "nestly/web" +) + +// Dependencies 集中所有 handler 依賴的週邊設施。 +type Dependencies struct { + Templates *templates.Engine + Accounts *models.AccountStore + Sessions *auth.SessionManager +} + +// NewRouter 建立應用程式的完整路由與中介軟體。 +func NewRouter(deps Dependencies) http.Handler { + r := chi.NewRouter() + + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + + // 靜態資源由內嵌 FS 提供,部署不依賴磁碟上的 web/ 目錄。 + staticFS, err := fs.Sub(web.StaticFS, "static") + if err != nil { + panic(err) + } + r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) + + home := &HomeHandler{deps: deps} + authh := &AuthHandler{deps: deps} + + r.Get("/", home.Show) + r.Get("/login", authh.Login) + r.Post("/login", authh.DoLogin) + r.Post("/logout", authh.Logout) + + return r +} diff --git a/internal/models/account.go b/internal/models/account.go new file mode 100644 index 0000000..9f5ab6c --- /dev/null +++ b/internal/models/account.go @@ -0,0 +1,281 @@ +// Package models 定義 Nestly 的資料結構與資料存取邏輯。 +package models + +import ( + "context" + "errors" + "regexp" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +// AccountRole 使用者在系統中的角色。 +type AccountRole string + +const ( + // RoleMember 一般會員(買方/租客)。 + RoleMember AccountRole = "member" + // RoleOwner 屋主,可刊登自有物件。 + RoleOwner AccountRole = "owner" + // RoleAgent 房仲經紀人,可代管多筆物件。 + RoleAgent AccountRole = "agent" + // RoleAdmin 管理員,擁有系統全部權限。 + RoleAdmin AccountRole = "admin" +) + +// Valid 回傳角色是否為系統定義的合法值。 +func (r AccountRole) Valid() bool { + switch r { + case RoleMember, RoleOwner, RoleAgent, RoleAdmin: + return true + } + return false +} + +// String 實作 fmt.Stringer。 +func (r AccountRole) String() string { return string(r) } + +const ( + // MinPasswordLength 密碼最小長度。 + MinPasswordLength = 8 + // MaxPasswordLength 密碼最大長度,bcrypt 僅使用前 72 個位元組。 + MaxPasswordLength = 72 + + // maxEmailLength 為 RFC 5321 允許的 email 最大長度。 + maxEmailLength = 254 + maxNameLength = 100 + maxPhoneLength = 30 + maxAvatarURLLength = 512 + + // DefaultPageSize 與 MaxPageSize 限制 List 的分頁大小。 + DefaultPageSize = 20 + MaxPageSize = 100 +) + +// 帳號相關的 sentinel errors,handler 可用 errors.Is 判斷後轉為對應的回應。 +var ( + ErrNotFound = errors.New("帳號不存在") + ErrEmailExists = errors.New("email 已被註冊") + ErrEmailRequired = errors.New("email 為必填") + ErrEmailInvalid = errors.New("email 格式不正確") + ErrNameRequired = errors.New("name 為必填") + ErrNameTooLong = errors.New("name 長度過長") + ErrRoleInvalid = errors.New("role 不合法") + ErrPhoneTooLong = errors.New("phone 長度過長") + ErrAvatarURLTooLong = errors.New("avatar_url 長度過長") + ErrPasswordRequired = errors.New("尚未設定密碼") + ErrPasswordTooShort = errors.New("密碼長度至少需 8 個字元") + ErrPasswordTooLong = errors.New("密碼長度不可超過 72 個字元") +) + +var emailRegex = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`) + +// Account 使用者帳號,對應資料庫中的 accounts 資料表。 +type Account struct { + ID uint `gorm:"primaryKey" json:"id"` + Email string `gorm:"uniqueIndex;size:254;not null" json:"email"` + PasswordHash string `gorm:"size:255;not null" json:"-"` + Name string `gorm:"size:100;not null" json:"name"` + Phone string `gorm:"size:30" json:"phone"` + Role AccountRole `gorm:"size:20;not null;default:member" json:"role"` + AvatarURL string `gorm:"size:512" json:"avatar_url"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// Normalize 去除欄位多餘空白、將 email 統一為小寫,並補上預設角色。 +func (a *Account) Normalize() { + a.Email = strings.ToLower(strings.TrimSpace(a.Email)) + a.Name = strings.TrimSpace(a.Name) + a.Phone = strings.TrimSpace(a.Phone) + a.AvatarURL = strings.TrimSpace(a.AvatarURL) + if a.Role == "" { + a.Role = RoleMember + } +} + +// SetPassword 驗證明文密碼長度後以 bcrypt 產生雜湊存入 PasswordHash。 +func (a *Account) SetPassword(plain string) error { + n := len(plain) + if n < MinPasswordLength { + return ErrPasswordTooShort + } + if n > MaxPasswordLength { + return ErrPasswordTooLong + } + hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost) + if err != nil { + return err + } + a.PasswordHash = string(hash) + return nil +} + +// VerifyPassword 比對明文密碼與儲存的雜湊是否相符。 +func (a *Account) VerifyPassword(plain string) bool { + return bcrypt.CompareHashAndPassword([]byte(a.PasswordHash), []byte(plain)) == nil +} + +// IsAdmin 回傳帳號是否為管理員。 +func (a *Account) IsAdmin() bool { + return a.Role == RoleAdmin +} + +// Validate 檢查欄位是否合法(會先呼叫 Normalize),不可通過時回傳對應的 sentinel error。 +// 寫入資料庫前帳號必須已透過 SetPassword 設定密碼。 +func (a *Account) Validate() error { + a.Normalize() + switch { + case a.Email == "": + return ErrEmailRequired + case !emailRegex.MatchString(a.Email) || len(a.Email) > maxEmailLength: + return ErrEmailInvalid + } + switch { + case a.Name == "": + return ErrNameRequired + case len(a.Name) > maxNameLength: + return ErrNameTooLong + } + if !a.Role.Valid() { + return ErrRoleInvalid + } + if a.Phone != "" && len(a.Phone) > maxPhoneLength { + return ErrPhoneTooLong + } + if a.AvatarURL != "" && len(a.AvatarURL) > maxAvatarURLLength { + return ErrAvatarURLTooLong + } + if a.PasswordHash == "" { + return ErrPasswordRequired + } + return nil +} + +// AccountStore 封裝 Account 的資料庫存取,所有方法皆帶 context。 +type AccountStore struct { + db *gorm.DB +} + +// NewAccountStore 建立以 db 為後端的 AccountStore。 +func NewAccountStore(db *gorm.DB) *AccountStore { + return &AccountStore{db: db} +} + +// AutoMigrate 建立或更新 accounts 資料表。 +func (s *AccountStore) AutoMigrate(ctx context.Context) error { + return s.db.WithContext(ctx).AutoMigrate(&Account{}) +} + +// Create 驗證並新增帳號;email 已被註冊時回傳 ErrEmailExists。 +func (s *AccountStore) Create(ctx context.Context, acct *Account) error { + if err := acct.Validate(); err != nil { + return err + } + err := s.db.WithContext(ctx). + Where("email = ?", acct.Email). + First(&Account{}).Error + if err == nil { + return ErrEmailExists + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + return s.db.WithContext(ctx).Create(acct).Error +} + +// FindByID 依主鍵查詢帳號,查無資料時回傳 ErrNotFound。 +func (s *AccountStore) FindByID(ctx context.Context, id uint) (*Account, error) { + var acct Account + err := s.db.WithContext(ctx).First(&acct, id).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return &acct, nil +} + +// FindByEmail 依 email 查詢帳號(不分大小寫),查無資料時回傳 ErrNotFound。 +func (s *AccountStore) FindByEmail(ctx context.Context, email string) (*Account, error) { + var acct Account + err := s.db.WithContext(ctx). + Where("email = ?", strings.ToLower(strings.TrimSpace(email))). + First(&acct).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return &acct, nil +} + +// List 分頁列出帳號(新註冊在前),回傳帳號清單與符合條件的總數;page 從 1 開始。 +func (s *AccountStore) List(ctx context.Context, page, pageSize int) ([]Account, int64, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = DefaultPageSize + } + if pageSize > MaxPageSize { + pageSize = MaxPageSize + } + var total int64 + if err := s.db.WithContext(ctx).Model(&Account{}).Count(&total).Error; err != nil { + return nil, 0, err + } + var accounts []Account + err := s.db.WithContext(ctx). + Order("id DESC"). + Limit(pageSize). + Offset((page - 1) * pageSize). + Find(&accounts).Error + if err != nil { + return nil, 0, err + } + return accounts, total, nil +} + +// Update 驗證並儲存整個帳號;目標不存在時回傳 ErrNotFound, +// email 改成其他帳號已使用的值時回傳 ErrEmailExists。 +func (s *AccountStore) Update(ctx context.Context, acct *Account) error { + if err := acct.Validate(); err != nil { + return err + } + err := s.db.WithContext(ctx). + Where("email = ? AND id <> ?", acct.Email, acct.ID). + First(&Account{}).Error + if err == nil { + return ErrEmailExists + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + result := s.db.WithContext(ctx).Save(acct) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return ErrNotFound + } + return nil +} + +// Delete 軟刪除帳號,目標不存在時回傳 ErrNotFound。 +func (s *AccountStore) Delete(ctx context.Context, id uint) error { + result := s.db.WithContext(ctx).Delete(&Account{}, id) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return ErrNotFound + } + return nil +} diff --git a/internal/models/account_test.go b/internal/models/account_test.go new file mode 100644 index 0000000..c4a4956 --- /dev/null +++ b/internal/models/account_test.go @@ -0,0 +1,218 @@ +package models + +import ( + "errors" + "strings" + "testing" +) + +func TestAccountRole_Valid(t *testing.T) { + valid := []AccountRole{RoleMember, RoleOwner, RoleAgent, RoleAdmin, ""} + invalid := []AccountRole{"superuser", "MEMBER", "member ", "0"} + + for _, role := range valid { + if got := role.Valid(); role != "" && !got { + t.Errorf("AccountRole(%q).Valid() = false, want true", role) + } + } + for _, role := range invalid { + if role.Valid() { + t.Errorf("AccountRole(%q).Valid() = true, want false", role) + } + } + if !RoleMember.Valid() { + t.Error("RoleMember.Valid() = false, want true") + } +} + +func TestAccount_SetPassword(t *testing.T) { + tests := []struct { + name string + password string + wantErr error + }{ + {name: "合法密碼", password: "s3cret!pass"}, + {name: "剛好 8 字元", password: "12345678"}, + {name: "太短", password: "1234567", wantErr: ErrPasswordTooShort}, + {name: "空白", password: "", wantErr: ErrPasswordTooShort}, + {name: "太長", password: strings.Repeat("a", MaxPasswordLength+1), wantErr: ErrPasswordTooLong}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + acct := &Account{} + err := acct.SetPassword(tt.password) + if !errors.Is(err, tt.wantErr) { + t.Fatalf("SetPassword() error = %v, want %v", err, tt.wantErr) + } + if tt.wantErr == nil { + if acct.PasswordHash == "" { + t.Fatal("SetPassword() 後 PasswordHash 為空") + } + if acct.PasswordHash == tt.password { + t.Fatal("PasswordHash 不應儲存明文密碼") + } + } + }) + } +} + +func TestAccount_VerifyPassword(t *testing.T) { + acct := &Account{} + if err := acct.SetPassword("s3cret!pass"); err != nil { + t.Fatalf("SetPassword() error = %v", err) + } + + if !acct.VerifyPassword("s3cret!pass") { + t.Error("VerifyPassword(正確密碼) = false, want true") + } + if acct.VerifyPassword("wrong-pass") { + t.Error("VerifyPassword(錯誤密碼) = true, want false") + } + if acct.VerifyPassword("") { + t.Error("VerifyPassword(空字串) = true, want false") + } +} + +func TestAccount_VerifyPassword_每次雜湊不同(t *testing.T) { + a1, a2 := &Account{}, &Account{} + if err := a1.SetPassword("same-password"); err != nil { + t.Fatalf("a1.SetPassword() error = %v", err) + } + if err := a2.SetPassword("same-password"); err != nil { + t.Fatalf("a2.SetPassword() error = %v", err) + } + if a1.PasswordHash == a2.PasswordHash { + t.Error("相同密碼的兩次雜湊應不同(bcrypt 應加鹽)") + } + if !a1.VerifyPassword("same-password") || !a2.VerifyPassword("same-password") { + t.Error("兩個帳號都應能以原始密碼通過驗證") + } +} + +func TestAccount_Validate(t *testing.T) { + valid := func() *Account { + acct := &Account{Email: "dan@example.com", Name: "Dan"} + if err := acct.SetPassword("s3cret!pass"); err != nil { + t.Fatalf("SetPassword() error = %v", err) + } + return acct + } + + tests := []struct { + name string + mutate func(*Account) + wantErr error + }{ + {name: "合法帳號", mutate: func(*Account) {}}, + { + name: "缺 email", + mutate: func(a *Account) { a.Email = "" }, + wantErr: ErrEmailRequired, + }, + { + name: "email 格式錯誤", + mutate: func(a *Account) { a.Email = "not-an-email" }, + wantErr: ErrEmailInvalid, + }, + { + name: "email 過長", + mutate: func(a *Account) { a.Email = strings.Repeat("a", 250) + "@example.com" }, + wantErr: ErrEmailInvalid, + }, + { + name: "缺 name", + mutate: func(a *Account) { a.Name = "" }, + wantErr: ErrNameRequired, + }, + { + name: "name 過長", + mutate: func(a *Account) { a.Name = strings.Repeat("名", 101) }, + wantErr: ErrNameTooLong, + }, + { + name: "role 不合法", + mutate: func(a *Account) { a.Role = "hacker" }, + wantErr: ErrRoleInvalid, + }, + { + name: "phone 過長", + mutate: func(a *Account) { a.Phone = strings.Repeat("0", 31) }, + wantErr: ErrPhoneTooLong, + }, + { + name: "avatar_url 過長", + mutate: func(a *Account) { a.AvatarURL = strings.Repeat("x", 513) }, + wantErr: ErrAvatarURLTooLong, + }, + { + name: "未設定密碼", + mutate: func(a *Account) { a.PasswordHash = "" }, + wantErr: ErrPasswordRequired, + }, + { + name: "所有合法角色可通過", + mutate: func(a *Account) { + a.Role = RoleAdmin + a.Phone = "0912345678" + a.AvatarURL = "https://example.com/a.png" + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + acct := valid() + tt.mutate(acct) + err := acct.Validate() + if !errors.Is(err, tt.wantErr) { + t.Fatalf("Validate() error = %v, want %v", err, tt.wantErr) + } + }) + } +} + +func TestAccount_Normalize(t *testing.T) { + acct := &Account{ + Email: " Dan@Example.COM ", + Name: " 陳大同 ", + Phone: " 0912345678 ", + AvatarURL: " https://example.com/a.png ", + } + acct.Normalize() + + if acct.Email != "dan@example.com" { + t.Errorf("Email = %q, want %q", acct.Email, "dan@example.com") + } + if acct.Name != "陳大同" { + t.Errorf("Name = %q, want %q", acct.Name, "陳大同") + } + if acct.Phone != "0912345678" { + t.Errorf("Phone = %q, want %q", acct.Phone, "0912345678") + } + if acct.AvatarURL != "https://example.com/a.png" { + t.Errorf("AvatarURL = %q, want %q", acct.AvatarURL, "https://example.com/a.png") + } + if acct.Role != RoleMember { + t.Errorf("空角色應預設為 RoleMember, got %q", acct.Role) + } +} + +func TestAccount_Normalize_不覆寫已設角色(t *testing.T) { + acct := &Account{Role: RoleAgent} + acct.Normalize() + if acct.Role != RoleAgent { + t.Errorf("Role = %q, want %q", acct.Role, RoleAgent) + } +} + +func TestAccount_IsAdmin(t *testing.T) { + admin := &Account{Role: RoleAdmin} + member := &Account{Role: RoleMember} + if !admin.IsAdmin() { + t.Error("admin.IsAdmin() = false, want true") + } + if member.IsAdmin() { + t.Error("member.IsAdmin() = true, want false") + } +} diff --git a/internal/storage/db.go b/internal/storage/db.go new file mode 100644 index 0000000..e64b502 --- /dev/null +++ b/internal/storage/db.go @@ -0,0 +1,19 @@ +// Package storage 負責資料庫連線的建立與初始化。 +package storage + +import ( + "fmt" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +// Open 以 dsn 開啟 SQLite 資料庫(glebarez/sqlite 為純 Go driver,無需 cgo)。 +// Postgres 支援待正式環境導入時再透過 driver 抽象銜接。 +func Open(dsn string) (*gorm.DB, error) { + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + return nil, fmt.Errorf("storage: 開啟資料庫 %q: %w", dsn, err) + } + return db, nil +} diff --git a/internal/templates/templates.go b/internal/templates/templates.go new file mode 100644 index 0000000..8e4c05d --- /dev/null +++ b/internal/templates/templates.go @@ -0,0 +1,57 @@ +// Package templates 負責載入與渲染 html/template 模板。 +package templates + +import ( + "bytes" + "fmt" + "html/template" + "io/fs" + "net/http" + "path" +) + +// layoutFile 基礎版型,定義 "layout" 模板並引用各頁面的 "content" 區塊。 +const layoutFile = "layout.html" + +// Engine 依頁面名稱持有各自獨立的模板集合(layout + 單一頁面), +// 讓每個頁面都能定義自己的 "content"、"title" 區塊而不互相覆蓋。 +type Engine struct { + pages map[string]*template.Template +} + +// New 載入 fsys 根目錄的 layout.html 與 pages/ 下所有頁面模板。 +func New(fsys fs.FS) (*Engine, error) { + pageFiles, err := fs.Glob(fsys, path.Join("pages", "*.html")) + if err != nil { + return nil, err + } + if len(pageFiles) == 0 { + return nil, fmt.Errorf("templates: pages/ 下找不到任何模板") + } + e := &Engine{pages: make(map[string]*template.Template, len(pageFiles))} + for _, page := range pageFiles { + t, err := template.New(layoutFile).ParseFS(fsys, layoutFile, page) + if err != nil { + return nil, fmt.Errorf("templates: 解析 %s: %w", page, err) + } + e.pages[path.Base(page)] = t + } + return e, nil +} + +// Render 先將頁面完整渲染至緩衝區再以 status 寫出, +// 避免中途失敗時輸出殘缺的 HTML。頁面名稱為檔名,如 "login.html"。 +func (e *Engine) Render(w http.ResponseWriter, status int, page string, data any) error { + t, ok := e.pages[page] + if !ok { + return fmt.Errorf("templates: 找不到頁面 %q", page) + } + var buf bytes.Buffer + if err := t.ExecuteTemplate(&buf, "layout", data); err != nil { + return fmt.Errorf("templates: 渲染 %q: %w", page, err) + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + _, err := w.Write(buf.Bytes()) + return err +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..ecebb6a --- /dev/null +++ b/main.go @@ -0,0 +1,83 @@ +package main + +import ( + "context" + "io/fs" + "log" + "net/http" + "os" + + "nestly/internal/auth" + "nestly/internal/handlers" + "nestly/internal/models" + "nestly/internal/storage" + "nestly/internal/templates" + "nestly/web" +) + +const demoEmail = "demo@nestly.test" + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func main() { + port := envOr("PORT", "3000") + dsn := envOr("DB_DSN", "nestly.db") + secret := os.Getenv("SESSION_SECRET") + if secret == "" { + secret = "nestly-dev-secret" + log.Println("警告:未設定 SESSION_SECRET,使用開發用預設值,正式環境務必指定") + } + + ctx := context.Background() + db, err := storage.Open(dsn) + if err != nil { + log.Fatal(err) + } + accounts := models.NewAccountStore(db) + if err := accounts.AutoMigrate(ctx); err != nil { + log.Fatal(err) + } + seedDemoAccount(ctx, accounts) + + templateFS, err := fs.Sub(web.TemplatesFS, "templates") + if err != nil { + log.Fatal(err) + } + engine, err := templates.New(templateFS) + if err != nil { + log.Fatal(err) + } + + router := handlers.NewRouter(handlers.Dependencies{ + Templates: engine, + Accounts: accounts, + Sessions: auth.NewSessionManager(secret, os.Getenv("COOKIE_SECURE") == "true"), + }) + + log.Printf("Nestly 啟動於 http://localhost:%s", port) + if err := http.ListenAndServe(":"+port, router); err != nil { + log.Fatal(err) + } +} + +// seedDemoAccount 於資料庫無此帳號時建立示範帳號,方便本地試用登入流程。 +func seedDemoAccount(ctx context.Context, accounts *models.AccountStore) { + if _, err := accounts.FindByEmail(ctx, demoEmail); err == nil { + return + } else if err != models.ErrNotFound { + log.Fatal(err) + } + acct := &models.Account{Email: demoEmail, Name: "示範帳號", Role: models.RoleMember} + if err := acct.SetPassword("nestly1234"); err != nil { + log.Fatal(err) + } + if err := accounts.Create(ctx, acct); err != nil { + log.Fatal(err) + } + log.Printf("已建立示範帳號 %s(密碼:nestly1234)", demoEmail) +} diff --git a/nestly b/nestly new file mode 100755 index 0000000..8b80c9d Binary files /dev/null and b/nestly differ diff --git a/nestly.db b/nestly.db new file mode 100644 index 0000000..99a3dd8 Binary files /dev/null and b/nestly.db differ diff --git a/web/static/css/app.css b/web/static/css/app.css new file mode 100644 index 0000000..dbe1a87 --- /dev/null +++ b/web/static/css/app.css @@ -0,0 +1,2 @@ +/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-200:oklch(88.5% .062 18.334);--color-red-700:oklch(50.5% .213 27.518);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-900:oklch(37.8% .077 168.94);--color-teal-400:oklch(77.7% .152 181.912);--color-teal-700:oklch(51.1% .096 186.391);--color-stone-50:oklch(98.5% .001 106.423);--color-stone-200:oklch(92.3% .003 48.717);--color-stone-300:oklch(86.9% .005 56.366);--color-stone-400:oklch(70.9% .01 56.259);--color-stone-500:oklch(55.3% .013 58.071);--color-stone-600:oklch(44.4% .011 73.639);--color-stone-700:oklch(37.4% .01 67.558);--color-stone-800:oklch(26.8% .007 34.298);--color-stone-900:oklch(21.6% .006 56.043);--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-2xl:42rem;--container-5xl:64rem;--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--leading-tight:1.25;--radius-lg:.5rem;--radius-xl:.75rem;--blur-3xl:64px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.-top-24{top:calc(var(--spacing) * -24)}.-right-24{right:calc(var(--spacing) * -24)}.-bottom-32{bottom:calc(var(--spacing) * -32)}.-left-16{left:calc(var(--spacing) * -16)}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.block{display:block}.flex{display:flex}.hidden{display:none}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-8{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.size-10{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.size-96{width:calc(var(--spacing) * 96);height:calc(var(--spacing) * 96)}.min-h-screen{min-height:100vh}.w-1\/2{width:50%}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-md{max-width:var(--container-md)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.flex-col{flex-direction:column}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}.overflow-hidden{overflow:hidden}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-red-200{border-color:var(--color-red-200)}.border-stone-200{border-color:var(--color-stone-200)}.border-stone-300{border-color:var(--color-stone-300)}.bg-emerald-600{background-color:var(--color-emerald-600)}.bg-red-50{background-color:var(--color-red-50)}.bg-stone-50{background-color:var(--color-stone-50)}.bg-teal-400\/20{background-color:#00d3bd33}@supports (color:color-mix(in lab, red, red)){.bg-teal-400\/20{background-color:color-mix(in oklab, var(--color-teal-400) 20%, transparent)}}.bg-white{background-color:var(--color-white)}.bg-white\/10{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.bg-white\/10{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.bg-white\/15{background-color:#ffffff26}@supports (color:color-mix(in lab, red, red)){.bg-white\/15{background-color:color-mix(in oklab, var(--color-white) 15%, transparent)}}.bg-linear-to-br{--tw-gradient-position:to bottom right}@supports (background-image:linear-gradient(in lab, red, red)){.bg-linear-to-br{--tw-gradient-position:to bottom right in oklab}}.bg-linear-to-br{background-image:linear-gradient(var(--tw-gradient-stops))}.from-emerald-600{--tw-gradient-from:var(--color-emerald-600);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.via-teal-700{--tw-gradient-via:var(--color-teal-700);--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.to-emerald-900{--tw-gradient-to:var(--color-emerald-900);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.p-12{padding:calc(var(--spacing) * 12)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-16{padding-block:calc(var(--spacing) * 16)}.text-center{text-align:center}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.text-emerald-100{color:var(--color-emerald-100)}.text-emerald-200{color:var(--color-emerald-200)}.text-emerald-600{color:var(--color-emerald-600)}.text-red-700{color:var(--color-red-700)}.text-stone-400{color:var(--color-stone-400)}.text-stone-500{color:var(--color-stone-500)}.text-stone-600{color:var(--color-stone-600)}.text-stone-700{color:var(--color-stone-700)}.text-stone-800{color:var(--color-stone-800)}.text-stone-900{color:var(--color-stone-900)}.text-white{color:var(--color-white)}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.placeholder-stone-400::placeholder{color:var(--color-stone-400)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.blur-3xl{--tw-blur:blur(var(--blur-3xl));filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.select-none{-webkit-user-select:none;user-select:none}@media (hover:hover){.hover\:bg-emerald-500:hover{background-color:var(--color-emerald-500)}.hover\:bg-stone-50:hover{background-color:var(--color-stone-50)}.hover\:text-emerald-500:hover{color:var(--color-emerald-500)}}.focus\:border-emerald-500:focus{border-color:var(--color-emerald-500)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-emerald-500\/40:focus{--tw-ring-color:#00bb7f66}@supports (color:color-mix(in lab, red, red)){.focus\:ring-emerald-500\/40:focus{--tw-ring-color:color-mix(in oklab, var(--color-emerald-500) 40%, transparent)}}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.active\:bg-emerald-700:active{background-color:var(--color-emerald-700)}@media (min-width:64rem){.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:w-1\/2{width:50%}}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false} \ No newline at end of file diff --git a/web/static/src/input.css b/web/static/src/input.css new file mode 100644 index 0000000..184c538 --- /dev/null +++ b/web/static/src/input.css @@ -0,0 +1,2 @@ +/* Tailwind 進入點:由 tools/tailwindcss 編譯為 web/static/css/app.css(見 Makefile)。 */ +@import "tailwindcss"; diff --git a/web/templates/layout.html b/web/templates/layout.html new file mode 100644 index 0000000..01b290d --- /dev/null +++ b/web/templates/layout.html @@ -0,0 +1,13 @@ +{{define "layout"}} + + + + + {{block "title" .}}Nestly{{end}} + + + +{{template "content" .}} + + +{{end}} diff --git a/web/templates/pages/home.html b/web/templates/pages/home.html new file mode 100644 index 0000000..4ff9052 --- /dev/null +++ b/web/templates/pages/home.html @@ -0,0 +1,38 @@ +{{define "title"}}首頁 · Nestly{{end}} +{{define "content"}} +
+
+
+ + 🏡 + Nestly + + {{if .Account}} +
+ 你好,{{.Account.Name}} +
+ +
+
+ {{else}} + + 登入 + + {{end}} +
+
+ +
+

Nestly 房地產平台

+

物件列表、搜尋與刊登功能尚在開發中,目前完成帳號與登入流程。

+
+ +
+

© 2026 Nestly

+
+
+{{end}} diff --git a/web/templates/pages/login.html b/web/templates/pages/login.html new file mode 100644 index 0000000..6bf9766 --- /dev/null +++ b/web/templates/pages/login.html @@ -0,0 +1,84 @@ +{{define "title"}}登入 · Nestly{{end}} +{{define "content"}} +
+ + + + +
+
+
+ 🏡 + Nestly +
+ +

登入

+

歡迎回來!請輸入你的 Email 與密碼。

+ + {{if .Error}} + + {{end}} + +
+
+ + +
+ +
+
+ + 忘記密碼? +
+ +
+ + + + +
+ +
+

示範帳號:demo@nestly.test / nestly1234

+
+ +

+ 還沒有帳號? + 立即註冊 +

+
+
+
+{{end}} diff --git a/web/web.go b/web/web.go new file mode 100644 index 0000000..9808c68 --- /dev/null +++ b/web/web.go @@ -0,0 +1,14 @@ +// Package web 以 go:embed 內嵌 HTML 模板與靜態資源,讓部署只需單一執行檔。 +package web + +import "embed" + +// TemplatesFS 內嵌 web/templates 下的所有模板。 +// +//go:embed templates +var TemplatesFS embed.FS + +// StaticFS 內嵌 web/static 下已建置的靜態資源(不含尚未編譯的原始 CSS)。 +// +//go:embed static/css +var StaticFS embed.FS