This commit is contained in:
2026-10-05 07:40:34 +08:00
parent 5a7ac2e5d9
commit d13088bfd5
22 changed files with 1369 additions and 0 deletions
+33
View File
@@ -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
+25
View File
@@ -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
+26
View File
@@ -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
)
+42
View File
@@ -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=
+109
View File
@@ -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
}
+61
View File
@@ -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)
}
+185
View File
@@ -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)
}
}
+30
View File
@@ -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)
}
}
+47
View File
@@ -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
}
+281
View File
@@ -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
}
+218
View File
@@ -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")
}
}
+19
View File
@@ -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
}
+57
View File
@@ -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
}
+83
View File
@@ -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)
}
Executable
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
/* Tailwind 進入點:由 tools/tailwindcss 編譯為 web/static/css/app.css(見 Makefile)。 */
@import "tailwindcss";
+13
View File
@@ -0,0 +1,13 @@
{{define "layout"}}<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{block "title" .}}Nestly{{end}}</title>
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body class="min-h-screen bg-stone-50 text-stone-900 antialiased">
{{template "content" .}}
</body>
</html>
{{end}}
+38
View File
@@ -0,0 +1,38 @@
{{define "title"}}首頁 · Nestly{{end}}
{{define "content"}}
<div class="flex min-h-screen flex-col">
<header class="border-b border-stone-200 bg-white">
<div class="mx-auto flex w-full max-w-5xl items-center justify-between px-6 py-4">
<a href="/" class="flex items-center gap-2 text-lg font-bold text-stone-900">
<span class="flex size-8 items-center justify-center rounded-lg bg-emerald-600 text-white">🏡</span>
Nestly
</a>
{{if .Account}}
<div class="flex items-center gap-4">
<span class="text-sm text-stone-600">你好,<span class="font-medium text-stone-900">{{.Account.Name}}</span></span>
<form method="post" action="/logout">
<button type="submit"
class="rounded-lg border border-stone-300 px-3 py-1.5 text-sm font-medium text-stone-600 transition hover:bg-stone-50">
登出
</button>
</form>
</div>
{{else}}
<a href="/login"
class="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-emerald-500">
登入
</a>
{{end}}
</div>
</header>
<main class="mx-auto w-full max-w-5xl flex-1 px-6 py-16">
<h1 class="text-3xl font-bold text-stone-900">Nestly 房地產平台</h1>
<p class="mt-3 max-w-2xl text-stone-600">物件列表、搜尋與刊登功能尚在開發中,目前完成帳號與登入流程。</p>
</main>
<footer class="border-t border-stone-200 bg-white py-4">
<p class="mx-auto max-w-5xl px-6 text-sm text-stone-400">© 2026 Nestly</p>
</footer>
</div>
{{end}}
+84
View File
@@ -0,0 +1,84 @@
{{define "title"}}登入 · Nestly{{end}}
{{define "content"}}
<div class="flex min-h-screen">
<!-- 品牌側欄:僅在大螢幕顯示 -->
<aside class="relative hidden w-1/2 flex-col justify-between overflow-hidden p-12 text-white lg:flex">
<div class="absolute inset-0 bg-linear-to-br from-emerald-600 via-teal-700 to-emerald-900"></div>
<div class="absolute -top-24 -right-24 size-96 rounded-full bg-white/10 blur-3xl"></div>
<div class="absolute -bottom-32 -left-16 size-96 rounded-full bg-teal-400/20 blur-3xl"></div>
<div class="relative flex items-center gap-3">
<span class="flex size-10 items-center justify-center rounded-xl bg-white/15 text-2xl">🏡</span>
<span class="text-2xl font-bold tracking-tight">Nestly</span>
</div>
<div class="relative max-w-md space-y-4">
<h1 class="text-4xl leading-tight font-bold">找到屬於你的<br>理想居所</h1>
<p class="text-lg text-emerald-100">買房、租屋、刊登物件,Nestly 讓你一站完成。</p>
</div>
<p class="relative text-sm text-emerald-200">© 2026 Nestly</p>
</aside>
<!-- 登入表單 -->
<main class="flex w-full items-center justify-center px-6 py-12 lg:w-1/2">
<section class="w-full max-w-md">
<div class="mb-8 flex items-center gap-3 lg:hidden">
<span class="flex size-10 items-center justify-center rounded-xl bg-emerald-600 text-2xl text-white">🏡</span>
<span class="text-2xl font-bold tracking-tight">Nestly</span>
</div>
<h2 class="text-2xl font-bold text-stone-900">登入</h2>
<p class="mt-2 text-sm text-stone-500">歡迎回來!請輸入你的 Email 與密碼。</p>
{{if .Error}}
<div class="mt-6 flex items-start gap-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
<svg class="mt-0.5 size-4 shrink-0" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd"/>
</svg>
<p>{{.Error}}</p>
</div>
{{end}}
<form method="post" action="/login" class="mt-6 space-y-5">
<div>
<label for="email" class="mb-1.5 block text-sm font-medium text-stone-700">Email</label>
<input type="email" id="email" name="email" value="{{.Email}}" required autofocus
autocomplete="email" placeholder="you@example.com"
class="block w-full rounded-lg border border-stone-300 bg-white px-3.5 py-2.5 text-sm text-stone-900 shadow-xs placeholder-stone-400 focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/40 focus:outline-none">
</div>
<div>
<div class="mb-1.5 flex items-center justify-between">
<label for="password" class="block text-sm font-medium text-stone-700">密碼</label>
<a href="#" class="text-sm font-medium text-emerald-600 hover:text-emerald-500">忘記密碼?</a>
</div>
<input type="password" id="password" name="password" required
autocomplete="current-password" placeholder="至少 8 個字元"
class="block w-full rounded-lg border border-stone-300 bg-white px-3.5 py-2.5 text-sm text-stone-900 shadow-xs placeholder-stone-400 focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/40 focus:outline-none">
</div>
<label class="flex items-center gap-2 text-sm text-stone-600 select-none">
<input type="checkbox" name="remember"
class="size-4 rounded border-stone-300 text-emerald-600 focus:ring-2 focus:ring-emerald-500/40">
記住我
</label>
<button type="submit"
class="flex w-full items-center justify-center rounded-lg bg-emerald-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-emerald-500 focus:ring-2 focus:ring-emerald-500/40 focus:ring-offset-2 focus:outline-none active:bg-emerald-700">
登入
</button>
</form>
<div class="mt-6 rounded-lg border border-stone-200 bg-white px-4 py-3 text-sm text-stone-600">
<p><span class="font-medium text-stone-800">示範帳號:</span>demo@nestly.test / nestly1234</p>
</div>
<p class="mt-8 text-center text-sm text-stone-500">
還沒有帳號?
<a href="#" class="font-medium text-emerald-600 hover:text-emerald-500">立即註冊</a>
</p>
</section>
</main>
</div>
{{end}}
+14
View File
@@ -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