From c82d4482d4b049ed9946561a4e4da1a4e383db72 Mon Sep 17 00:00:00 2001 From: kevin Date: Sun, 21 Jun 2026 01:49:04 +0800 Subject: [PATCH] feat: initial blog engine with multi-language, profile, and role system - Gin + GORM + Tailwind CSS blog engine - OS-aware config (Linux /etc/blog_go/, Windows ./win/etc/blog_go/) - SQLite by default, MySQL support via config - Auto-migrate database + seed admin user on first run - Session-based auth (login/logout) - i18n: Chinese/English with auto-detect + manual switch - Avatar dropdown nav with click-to-toggle menu - Profile page: avatar upload, edit info, change password - Role system: admin (full access) and author (profile only) Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 22 ++++ README.md | 128 +++++++++++++++++++++ config/config.go | 125 ++++++++++++++++++++ go.mod | 59 ++++++++++ go.sum | 140 ++++++++++++++++++++++ handlers/admin.go | 19 +++ handlers/auth.go | 64 +++++++++++ handlers/helpers.go | 44 +++++++ handlers/home.go | 17 +++ handlers/profile.go | 144 +++++++++++++++++++++++ i18n/i18n.go | 204 +++++++++++++++++++++++++++++++++ main.go | 75 ++++++++++++ middleware/auth.go | 100 ++++++++++++++++ models/db.go | 86 ++++++++++++++ models/user.go | 52 +++++++++ templates/admin/dashboard.html | 42 +++++++ templates/layouts/base.html | 88 ++++++++++++++ templates/pages/home.html | 38 ++++++ templates/pages/login.html | 46 ++++++++ templates/pages/profile.html | 99 ++++++++++++++++ 20 files changed, 1592 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config/config.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 handlers/admin.go create mode 100644 handlers/auth.go create mode 100644 handlers/helpers.go create mode 100644 handlers/home.go create mode 100644 handlers/profile.go create mode 100644 i18n/i18n.go create mode 100644 main.go create mode 100644 middleware/auth.go create mode 100644 models/db.go create mode 100644 models/user.go create mode 100644 templates/admin/dashboard.html create mode 100644 templates/layouts/base.html create mode 100644 templates/pages/home.html create mode 100644 templates/pages/login.html create mode 100644 templates/pages/profile.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f03090 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Binary +go_blog +go_blog.exe +main.exe + +# Auto-generated runtime data (config + database + uploads) +win/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Go +*.test +*.out diff --git a/README.md b/README.md new file mode 100644 index 0000000..e13fad7 --- /dev/null +++ b/README.md @@ -0,0 +1,128 @@ +# Go Blog + +A simple, fast blog engine built with Go, Gin, and Tailwind CSS. Supports SQLite and MySQL, with automatic first-run setup. + +## Features + +- **Multi-language** — 中文 / English, auto-detected or manually switched +- **OS-aware config** — Linux `/etc/blog_go/`, Windows `./win/etc/blog_go/` +- **First-run setup** — auto-creates config, database, and admin user +- **Avatar + Profile** — upload avatar, edit personal info, change password +- **Role system** — admin sees management panel; authors see profile only +- **Responsive UI** — Tailwind CSS, works on desktop and mobile + +## Tech Stack + +| Layer | Library | +|---|---| +| Router | [gin](https://github.com/gin-gonic/gin) | +| ORM | [gorm](https://gorm.io) | +| SQLite | [glebarez/sqlite](https://github.com/glebarez/sqlite) (pure Go, no CGO) | +| Sessions | [gin-contrib/sessions](https://github.com/gin-contrib/sessions) | +| Config | [gopkg.in/yaml.v3](https://gopkg.in/yaml.v3) | +| Passwords | [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto/bcrypt) | +| CSS | [Tailwind CSS](https://tailwindcss.com) (CDN) | + +## Quick Start + +```bash +go run . +``` + +Open http://localhost:8080 and log in with **admin** / **admin**. + +## Configuration + +On first run, a config file is created automatically: + +| Platform | Config Path | Storage Path | +|---|---|---| +| Linux | `/etc/blog_go/config.yaml` | `/srv/blog_go/` | +| Windows | `./win/etc/blog_go/config.yaml` | `./win/srv/blog_go/` | + +```yaml +# config.yaml +database: + type: sqlite # sqlite (default) or mysql + dsn: "" # MySQL DSN, ignored for sqlite +port: "8080" # web server port +path: ./win/srv/blog_go # storage path (db, uploads) +secret: # session encryption key +``` + +### MySQL + +Edit `config.yaml`: + +```yaml +database: + type: mysql + dsn: user:password@tcp(127.0.0.1:3306)/blog_go?charset=utf8mb4&parseTime=True&loc=Local +port: "8080" +``` + +Create the database first: + +```sql +CREATE DATABASE blog_go CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +``` + +## Project Structure + +``` +go_blog/ +├── main.go # Entry point, routes, middleware wiring +├── config/ +│ └── config.go # OS-aware config load / auto-create +├── models/ +│ ├── user.go # User model + bcrypt +│ └── db.go # GORM init, migration, seed +├── middleware/ +│ └── auth.go # Auth guard, language detection +├── handlers/ +│ ├── helpers.go # DefaultData + getTr utilities +│ ├── home.go # GET / +│ ├── auth.go # GET/POST /login, POST /logout +│ ├── admin.go # GET /admin (protected) +│ └── profile.go # GET/POST /profile (protected) +├── i18n/ +│ └── i18n.go # EN/ZH translation maps + Accept-Language +├── templates/ +│ ├── layouts/base.html # Header (nav + avatar dropdown) + footer +│ ├── pages/ +│ │ ├── home.html # Public home page +│ │ ├── login.html # Login form +│ │ └── profile.html # Edit profile page +│ └── admin/ +│ └── dashboard.html # Admin dashboard +└── win/ # Auto-created runtime data (Windows) + ├── etc/blog_go/ + │ └── config.yaml + └── srv/blog_go/ + ├── blog.db + └── avatars/ +``` + +## Routes + +| Method | Path | Auth | Description | +|---|---|---|---| +| GET | `/` | No | Home page | +| GET | `/login` | No | Login form | +| POST | `/login` | No | Submit login | +| POST | `/logout` | No | Clear session | +| GET | `/profile` | Yes | Edit profile | +| POST | `/profile` | Yes | Save profile | +| GET | `/admin` | Yes | Admin dashboard (admin only) | +| GET | `/uploads/*` | No | Static files (avatars, etc.) | + +## User Roles + +| Role | Profile | Admin Dashboard | +|---|---|---| +| `admin` | ✓ | ✓ | +| `author` | ✓ | ✗ | + +## License + +MIT diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..ac49f2b --- /dev/null +++ b/config/config.go @@ -0,0 +1,125 @@ +package config + +import ( + "crypto/sha256" + "fmt" + "log" + "os" + "path/filepath" + "runtime" + + "gopkg.in/yaml.v3" +) + +// Config holds all application configuration. +type Config struct { + Database DatabaseConfig `yaml:"database"` + Port string `yaml:"port"` + Path string `yaml:"path"` + Secret string `yaml:"secret"` +} + +// DatabaseConfig holds database-specific configuration. +type DatabaseConfig struct { + Type string `yaml:"type"` // "sqlite" (default) or "mysql" + DSN string `yaml:"dsn"` // MySQL connection string (ignored for sqlite) +} + +// defaultPort is the fallback web server port. +const defaultPort = "8080" + +// getConfigPath returns the OS-aware config directory and config file path. +func getConfigPath() (dir, file string) { + if runtime.GOOS == "windows" { + dir = filepath.Join(".", "win", "etc", "blog_go") + } else { + dir = filepath.Join("/", "etc", "blog_go") + } + file = filepath.Join(dir, "config.yaml") + return +} + +// getDefaultStoragePath returns the OS-aware default storage path. +func getDefaultStoragePath() string { + if runtime.GOOS == "windows" { + return filepath.Join(".", "win", "srv", "blog_go") + } + return filepath.Join("/", "srv", "blog_go") +} + +// generateSecret returns a random-ish hex string for the session secret. +func generateSecret() string { + // Use a combination of hostname + random to produce a unique secret. + hostname, _ := os.Hostname() + input := fmt.Sprintf("%s-%d", hostname, os.Getpid()) + hash := sha256.Sum256([]byte(input)) + return fmt.Sprintf("%x", hash) +} + +// LoadConfig reads the config file or creates one with defaults. +func LoadConfig() *Config { + configDir, configFile := getConfigPath() + defaultPath := getDefaultStoragePath() + + cfg := &Config{} + + // Check if config file exists; create with defaults if not. + if _, err := os.Stat(configFile); os.IsNotExist(err) { + log.Printf("Config file not found at %s, creating with defaults...", configFile) + if err := os.MkdirAll(configDir, 0755); err != nil { + log.Fatalf("Failed to create config directory %s: %v", configDir, err) + } + + cfg = &Config{ + Database: DatabaseConfig{ + Type: "sqlite", + DSN: "", + }, + Port: defaultPort, + Path: defaultPath, + Secret: generateSecret(), + } + + data, err := yaml.Marshal(cfg) + if err != nil { + log.Fatalf("Failed to marshal default config: %v", err) + } + + if err := os.WriteFile(configFile, data, 0644); err != nil { + log.Fatalf("Failed to write config file %s: %v", configFile, err) + } + log.Printf("Default config created at %s", configFile) + return cfg + } + + // Read existing config file. + data, err := os.ReadFile(configFile) + if err != nil { + log.Printf("Warning: could not read config file %s: %v, using defaults", configFile, err) + return applyDefaults(cfg, defaultPath) + } + + if err := yaml.Unmarshal(data, cfg); err != nil { + log.Printf("Warning: malformed config file %s: %v, using defaults", configFile, err) + cfg = &Config{} + } + + return applyDefaults(cfg, defaultPath) +} + +// applyDefaults fills zero-value fields with sensible defaults. +func applyDefaults(cfg *Config, defaultPath string) *Config { + if cfg.Port == "" { + cfg.Port = defaultPort + } + if cfg.Database.Type == "" { + cfg.Database.Type = "sqlite" + } + if cfg.Path == "" { + cfg.Path = defaultPath + } + if cfg.Secret == "" { + cfg.Secret = generateSecret() + } + return cfg +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..272ce9d --- /dev/null +++ b/go.mod @@ -0,0 +1,59 @@ +module go_blog + +go 1.25.0 + +require ( + github.com/gin-contrib/sessions v1.1.0 + github.com/gin-gonic/gin v1.12.0 + github.com/glebarez/sqlite v1.11.0 + golang.org/x/crypto v0.53.0 + gopkg.in/yaml.v3 v3.0.1 + gorm.io/driver/mysql v1.6.0 + gorm.io/gorm v1.31.1 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/gorilla/context v1.1.2 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/gorilla/sessions v1.4.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect + google.golang.org/protobuf v1.36.10 // 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..589dd17 --- /dev/null +++ b/go.sum @@ -0,0 +1,140 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sessions v1.1.0 h1:00mhHfNEGF5sP2fwxa98aRqj1FOJdL6IkR86n2hOiBo= +github.com/gin-contrib/sessions v1.1.0/go.mod h1:TyYZDIs6qCQg2SOoYPgMT9pAkmZceVNEJMcv5qbIy60= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +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-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +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/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= +github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= +github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= +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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +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= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/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/handlers/admin.go b/handlers/admin.go new file mode 100644 index 0000000..b9910c4 --- /dev/null +++ b/handlers/admin.go @@ -0,0 +1,19 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// AdminDashboard renders the protected admin dashboard. +func AdminDashboard() gin.HandlerFunc { + return func(c *gin.Context) { + tr := getTr(c) + username, _ := c.Get("username") + data := DefaultData(c) + data["Title"] = tr["dash_page_title"] + data["Username"] = username + c.HTML(http.StatusOK, "dashboard", data) + } +} diff --git a/handlers/auth.go b/handlers/auth.go new file mode 100644 index 0000000..cf41080 --- /dev/null +++ b/handlers/auth.go @@ -0,0 +1,64 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "go_blog/models" +) + +// LoginPage renders the login form. +func LoginPage() gin.HandlerFunc { + return func(c *gin.Context) { + tr := getTr(c) + data := DefaultData(c) + data["Title"] = tr["page_login"] + if c.Query("error") == "1" { + data["Error"] = tr["login_error"] + } + c.HTML(http.StatusOK, "login", data) + } +} + +// Login processes the login form submission. +func Login(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + username := c.PostForm("username") + password := c.PostForm("password") + + var user models.User + if err := db.Where("username = ?", username).First(&user).Error; err != nil { + c.Redirect(http.StatusFound, "/login?error=1") + return + } + + if !user.CheckPassword(password) { + c.Redirect(http.StatusFound, "/login?error=1") + return + } + + // Create session. + session := sessions.Default(c) + session.Set("user_id", user.ID) + session.Set("username", user.Username) + if err := session.Save(); err != nil { + c.String(http.StatusInternalServerError, "Failed to save session") + return + } + + c.Redirect(http.StatusFound, "/admin") + } +} + +// Logout clears the session and redirects home. +func Logout() gin.HandlerFunc { + return func(c *gin.Context) { + session := sessions.Default(c) + session.Clear() + session.Save() + c.Redirect(http.StatusFound, "/") + } +} diff --git a/handlers/helpers.go b/handlers/helpers.go new file mode 100644 index 0000000..fc01b1c --- /dev/null +++ b/handlers/helpers.go @@ -0,0 +1,44 @@ +package handlers + +import ( + "github.com/gin-gonic/gin" +) + +// DefaultData builds a base gin.H map populated with values set by the +// SetUserContext middleware (translations, language, auth state). Handlers +// add page-specific fields on top and pass it to c.HTML(). +func DefaultData(c *gin.Context) gin.H { + tr, _ := c.Get("tr") + isLoggedIn, _ := c.Get("is_logged_in") + username, _ := c.Get("username") + lang, _ := c.Get("lang") + switchLang, _ := c.Get("switch_lang") + avatar, _ := c.Get("avatar") + displayName, _ := c.Get("display_name") + role, _ := c.Get("role") + + return gin.H{ + "Tr": tr, + "IsLoggedIn": isLoggedIn, + "Username": username, + "Lang": lang, + "SwitchLang": switchLang, + "Avatar": avatar, + "DisplayName": displayName, + "Role": role, + } +} + +// getTr is a convenience helper that returns the translation map from the +// Gin context, falling back to an empty map if not set. +func getTr(c *gin.Context) map[string]string { + tr, ok := c.Get("tr") + if !ok { + return map[string]string{} + } + m, ok := tr.(map[string]string) + if !ok { + return map[string]string{} + } + return m +} diff --git a/handlers/home.go b/handlers/home.go new file mode 100644 index 0000000..f173b9f --- /dev/null +++ b/handlers/home.go @@ -0,0 +1,17 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// HomePage renders the public home page. +func HomePage() gin.HandlerFunc { + return func(c *gin.Context) { + tr := getTr(c) + data := DefaultData(c) + data["Title"] = tr["page_home"] + c.HTML(http.StatusOK, "home", data) + } +} diff --git a/handlers/profile.go b/handlers/profile.go new file mode 100644 index 0000000..618fe3c --- /dev/null +++ b/handlers/profile.go @@ -0,0 +1,144 @@ +package handlers + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "go_blog/models" +) + +// ProfilePage renders the profile edit page. +func ProfilePage(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + tr := getTr(c) + session := sessions.Default(c) + userID := session.Get("user_id") + + var user models.User + if err := db.First(&user, userID).Error; err != nil { + c.String(http.StatusInternalServerError, "User not found") + return + } + + data := DefaultData(c) + data["Title"] = tr["profile_title"] + data["Profile"] = user + + // Flash messages (success / error). + if msg := c.Query("saved"); msg == "1" { + data["Success"] = tr["profile_saved"] + } + if msg := c.Query("error"); msg == "pw" { + data["Error"] = tr["profile_wrong_password"] + } + + c.HTML(http.StatusOK, "profile", data) + } +} + +// UpdateProfile processes the profile edit form (multipart). +func UpdateProfile(db *gorm.DB, storagePath string) gin.HandlerFunc { + return func(c *gin.Context) { + session := sessions.Default(c) + userID := session.Get("user_id") + + var user models.User + if err := db.First(&user, userID).Error; err != nil { + c.Redirect(http.StatusFound, "/profile") + return + } + + // --- Text fields --- + if v := c.PostForm("display_name"); v != "" { + user.DisplayName = v + } + if v := c.PostForm("gender"); v != "" { + user.Gender = v + } + if v := c.PostForm("email"); v != "" { + user.Email = v + } + if v := c.PostForm("birthday"); v != "" { + if t, err := time.Parse("2006-01-02", v); err == nil { + user.Birthday = &t + } + } + + // --- Avatar upload --- + file, header, err := c.Request.FormFile("avatar") + if err == nil { + defer file.Close() + + // Determine file extension. + ext := strings.ToLower(filepath.Ext(header.Filename)) + if ext == "" || (ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".gif" && ext != ".webp") { + ext = ".jpg" + } + + // Save under storagePath/avatars/. + avatarDir := filepath.Join(storagePath, "avatars") + if err := os.MkdirAll(avatarDir, 0755); err != nil { + c.Redirect(http.StatusFound, "/profile") + return + } + + // Use user ID as filename base to avoid duplicates. + savedName := fmt.Sprintf("%d%s", user.ID, ext) + savedPath := filepath.Join(avatarDir, savedName) + + dst, err := os.Create(savedPath) + if err != nil { + c.Redirect(http.StatusFound, "/profile") + return + } + defer dst.Close() + + if _, err := io.Copy(dst, file); err != nil { + c.Redirect(http.StatusFound, "/profile") + return + } + + user.Avatar = savedName + // Update session display immediately. + session.Set("avatar", savedName) + } + + // --- Password change --- + currentPass := c.PostForm("current_password") + newPass := c.PostForm("new_password") + if currentPass != "" && newPass != "" { + if !user.CheckPassword(currentPass) { + session.Save() + c.Redirect(http.StatusFound, "/profile?error=pw") + return + } + if err := user.SetPassword(newPass); err != nil { + session.Save() + c.Redirect(http.StatusFound, "/profile") + return + } + } + + // Save user record. + if err := db.Save(&user).Error; err != nil { + session.Save() + c.Redirect(http.StatusFound, "/profile") + return + } + + // Update display name in session. + session.Set("display_name", user.DisplayName) + session.Save() + + c.Redirect(http.StatusFound, "/profile?saved=1") + } +} diff --git a/i18n/i18n.go b/i18n/i18n.go new file mode 100644 index 0000000..9c1dd5b --- /dev/null +++ b/i18n/i18n.go @@ -0,0 +1,204 @@ +package i18n + +import ( + "strings" +) + +// Lang represents a supported language code. +type Lang string + +const ( + EN Lang = "en" + ZH Lang = "zh" +) + +// translations holds all UI strings keyed by language then translation key. +var translations = map[Lang]map[string]string{ + EN: { + // Nav + "site_title": "Go Blog", + "home": "Home", + "login": "Login", + "dashboard": "Dashboard", + "logout": "Logout", + + // Home page + "home_welcome": "Welcome to Go Blog", + "home_subtitle": "A simple, fast blog engine built with Go, Gin, and Tailwind CSS.", + "home_sign_in": "Sign In", + "home_to_dash": "Dashboard", + "home_latest": "Latest Posts", + "home_post1_tit": "Getting Started", + "home_post1_dsc": "Welcome to your new blog! This is a placeholder post. Start writing to see your content here.", + "home_post2_tit": "Hello World", + "home_post2_dsc": "Blog posts will appear here once you start publishing from the admin dashboard.", + "home_post3_tit": "Built with Go", + "home_post3_dsc": "This blog engine uses Go, Gin web framework, GORM, and Tailwind CSS for a modern experience.", + + // Login page + "login_title": "Sign In", + "login_username": "Username", + "login_password": "Password", + "login_ph_user": "Enter your username", + "login_ph_pass": "Enter your password", + "login_submit": "Sign In", + "login_error": "Invalid username or password.", + + // Dashboard + "dash_title": "Dashboard", + "dash_welcome": "Welcome back,", + "dash_posts": "Posts", + "dash_pages": "Pages", + "dash_users": "Users", + "dash_mgmt_title": "Blog Management", + "dash_mgmt_desc": "Post management, categories, and settings will appear here in future updates.", + "dash_page_title": "Dashboard", + + // Footer + "footer_text": "© 2026 Go Blog. Powered by Go & Gin.", + + // Page titles + "page_home": "Home", + "page_login": "Login", + + // Language switcher + "lang_switch": "中文", + + // Profile dropdown & page + "profile": "Profile", + "admin_panel": "Admin Panel", + "profile_title": "Edit Profile", + "profile_avatar": "Avatar", + "profile_change_avatar": "Change Avatar", + "profile_display_name": "Display Name", + "profile_gender": "Gender", + "profile_birthday": "Birthday", + "profile_email": "Email", + "profile_change_password": "Change Password", + "profile_current_password": "Current Password", + "profile_new_password": "New Password", + "profile_save": "Save Changes", + "profile_saved": "Profile updated.", + "profile_wrong_password": "Current password is incorrect.", + "gender_male": "Male", + "gender_female": "Female", + "gender_other": "Other", + }, + ZH: { + // 导航 + "site_title": "Go 博客", + "home": "首页", + "login": "登录", + "dashboard": "后台", + "logout": "退出", + + // 首页 + "home_welcome": "欢迎来到 Go Blog", + "home_subtitle": "一个使用 Go、Gin 和 Tailwind CSS 构建的简洁快速的博客引擎。", + "home_sign_in": "登录", + "home_to_dash": "后台管理", + "home_latest": "最新文章", + "home_post1_tit": "入门指南", + "home_post1_dsc": "欢迎来到你的新博客!这是一篇占位文章。开始写作后,你的内容将显示在这里。", + "home_post2_tit": "你好,世界", + "home_post2_dsc": "当你从后台管理面板发布文章后,博客文章将显示在这里。", + "home_post3_tit": "使用 Go 构建", + "home_post3_dsc": "此博客引擎使用 Go、Gin Web 框架、GORM 和 Tailwind CSS 构建,提供现代化的体验。", + + // 登录页 + "login_title": "登录", + "login_username": "用户名", + "login_password": "密码", + "login_ph_user": "请输入用户名", + "login_ph_pass": "请输入密码", + "login_submit": "登录", + "login_error": "用户名或密码错误。", + + // 后台 + "dash_title": "后台管理", + "dash_welcome": "欢迎回来,", + "dash_posts": "文章", + "dash_pages": "页面", + "dash_users": "用户", + "dash_mgmt_title": "博客管理", + "dash_mgmt_desc": "文章管理、分类和设置将在后续版本中添加。", + "dash_page_title": "后台管理", + + // 页脚 + "footer_text": "© 2026 Go Blog。由 Go & Gin 驱动。", + + // 页面标题 + "page_home": "首页", + "page_login": "登录", + + // 语言切换 + "lang_switch": "English", + + // 个人中心下拉菜单 & 页面 + "profile": "个人信息", + "admin_panel": "后台管理", + "profile_title": "编辑个人信息", + "profile_avatar": "头像", + "profile_change_avatar": "更换头像", + "profile_display_name": "显示名称", + "profile_gender": "性别", + "profile_birthday": "生日", + "profile_email": "邮箱", + "profile_change_password": "修改密码", + "profile_current_password": "当前密码", + "profile_new_password": "新密码", + "profile_save": "保存修改", + "profile_saved": "个人信息已更新。", + "profile_wrong_password": "当前密码错误。", + "gender_male": "男", + "gender_female": "女", + "gender_other": "其他", + }, +} + +// T returns a copy of the translation map for the given language. +// Falls back to English if the language is not supported. +func T(l Lang) map[string]string { + t, ok := translations[l] + if !ok { + t = translations[EN] + } + // Return a shallow copy so callers cannot mutate the original map values. + out := make(map[string]string, len(t)) + for k, v := range t { + out[k] = v + } + return out +} + +// DetectLang parses the Accept-Language header and returns the best matching +// supported language. Returns EN for unsupported languages. +func DetectLang(acceptHeader string) Lang { + if acceptHeader == "" { + return EN + } + + // Accept-Language format: "zh-CN,zh;q=0.9,en;q=0.8,en-US;q=0.7" + // Split by comma, then extract primary language from each entry. + entries := strings.Split(acceptHeader, ",") + for _, entry := range entries { + // Trim spaces and remove quality values. + entry = strings.TrimSpace(entry) + if idx := strings.Index(entry, ";"); idx != -1 { + entry = entry[:idx] + } + // Extract primary language (before any "-" subtag). + primary := strings.ToLower(entry) + if idx := strings.Index(primary, "-"); idx != -1 { + primary = primary[:idx] + } + switch primary { + case "zh": + return ZH + case "en": + return EN + } + } + + return EN +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..f2e8383 --- /dev/null +++ b/main.go @@ -0,0 +1,75 @@ +package main + +import ( + "fmt" + "log" + + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-gonic/gin" + + "go_blog/config" + "go_blog/handlers" + "go_blog/middleware" + "go_blog/models" +) + +func main() { + // 1. Load configuration (auto-creates if missing). + cfg := config.LoadConfig() + + // 2. Initialize the database (auto-migrates, seeds admin). + db := models.InitDB(cfg) + + // 3. Create session store (cookie-based). + store := cookie.NewStore([]byte(cfg.Secret)) + store.Options(sessions.Options{ + Path: "/", + MaxAge: 86400, // 24 hours + HttpOnly: true, // prevent XSS access + Secure: false, // set true in production with HTTPS + }) + + // 4. Create Gin router. + router := gin.Default() + + // 5. Load HTML templates. + router.LoadHTMLGlob("templates/**/*.html") + + // 6. Serve uploaded files (avatars etc.) from the storage path. + router.Static("/uploads", cfg.Path) + + // 6. Global session middleware. + router.Use(sessions.Sessions("blog_session", store)) + + // 7. Global context middleware (sets IsLoggedIn, Username for templates). + router.Use(middleware.SetUserContext(db)) + + // 8. Register routes. + router.GET("/", handlers.HomePage()) + router.GET("/login", handlers.LoginPage()) + router.POST("/login", handlers.Login(db)) + router.POST("/logout", handlers.Logout()) + + // Protected admin routes. + admin := router.Group("/admin") + admin.Use(middleware.AuthRequired()) + { + admin.GET("", handlers.AdminDashboard()) + } + + // Protected profile routes. + profile := router.Group("/profile") + profile.Use(middleware.AuthRequired()) + { + profile.GET("", handlers.ProfilePage(db)) + profile.POST("", handlers.UpdateProfile(db, cfg.Path)) + } + + // 9. Start the server. + addr := fmt.Sprintf(":%s", cfg.Port) + log.Printf("Go Blog starting on http://localhost%s", addr) + if err := router.Run(addr); err != nil { + log.Fatalf("Failed to start server: %v", err) + } +} diff --git a/middleware/auth.go b/middleware/auth.go new file mode 100644 index 0000000..4e88e24 --- /dev/null +++ b/middleware/auth.go @@ -0,0 +1,100 @@ +package middleware + +import ( + "net/http" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "go_blog/i18n" + "go_blog/models" +) + +// AuthRequired is middleware that protects routes. If the user is not logged in, +// they are redirected to /login. +func AuthRequired() gin.HandlerFunc { + return func(c *gin.Context) { + session := sessions.Default(c) + userID := session.Get("user_id") + if userID == nil { + c.Redirect(http.StatusFound, "/login") + c.Abort() + return + } + c.Next() + } +} + +// SetUserContext is global middleware that reads the session and sets +// template-friendly context values for all pages (language, auth state, etc.). +func SetUserContext(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + session := sessions.Default(c) + + // --- Language detection --- + // Priority: query param > session > Accept-Language header > default EN + var lang i18n.Lang + queryLang := c.Query("lang") + + switch queryLang { + case "zh": + lang = i18n.ZH + case "en": + lang = i18n.EN + case "": + // Try session + if saved, ok := session.Get("lang").(string); ok { + lang = i18n.Lang(saved) + } + if lang == "" { + // Try Accept-Language header + lang = i18n.DetectLang(c.GetHeader("Accept-Language")) + } + default: + // Unsupported language in query — fall back to English. + lang = i18n.EN + } + + // Persist language in session. + session.Set("lang", string(lang)) + session.Save() + + // Make translations available in the Gin context. + c.Set("tr", i18n.T(lang)) + c.Set("lang", string(lang)) + + // Set the opposite language code for the language switcher link. + switchLang := "zh" + if lang == i18n.ZH { + switchLang = "en" + } + c.Set("switch_lang", switchLang) + + // --- Auth state --- + userID := session.Get("user_id") + isLoggedIn := false + var username string + var avatar string + var displayName string + var role string + + if userID != nil { + isLoggedIn = true + var user models.User + if err := db.First(&user, userID).Error; err == nil { + username = user.Username + avatar = user.Avatar + displayName = user.DisplayName + role = user.Role + } + } + + c.Set("is_logged_in", isLoggedIn) + c.Set("username", username) + c.Set("avatar", avatar) + c.Set("display_name", displayName) + c.Set("role", role) + c.Next() + } +} diff --git a/models/db.go b/models/db.go new file mode 100644 index 0000000..d04f14a --- /dev/null +++ b/models/db.go @@ -0,0 +1,86 @@ +package models + +import ( + "log" + "os" + "path/filepath" + + "go_blog/config" + + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/glebarez/sqlite" +) + +// DB is the global database connection, initialized by InitDB. +var DB *gorm.DB + +// InitDB opens the database connection, runs migrations, and seeds the admin user. +func InitDB(cfg *config.Config) *gorm.DB { + // Ensure the storage path exists. + if err := os.MkdirAll(cfg.Path, 0755); err != nil { + log.Fatalf("Failed to create storage directory %s: %v", cfg.Path, err) + } + + var dialector gorm.Dialector + + switch cfg.Database.Type { + case "mysql": + dsn := cfg.Database.DSN + if dsn == "" { + dsn = "root:password@tcp(127.0.0.1:3306)/blog_go?charset=utf8mb4&parseTime=True&loc=Local" + } + dialector = mysql.Open(dsn) + default: + dbPath := filepath.Join(cfg.Path, "blog.db") + dialector = sqlite.Open(dbPath) + } + + db, err := gorm.Open(dialector, &gorm.Config{ + Logger: logger.Default.LogMode(logger.Warn), + }) + if err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + + // Auto-migrate the User table (idempotent). + if err := db.AutoMigrate(&User{}); err != nil { + log.Fatalf("Failed to auto-migrate database: %v", err) + } + + // First-run seed: create admin user if no users exist. + var count int64 + db.Model(&User{}).Count(&count) + if count == 0 { + admin := &User{ + Username: "admin", + DisplayName: "Administrator", + Gender: "other", + Status: StatusNormal, + Role: RoleAdmin, + } + if err := admin.SetPassword("admin"); err != nil { + log.Fatalf("Failed to hash admin password: %v", err) + } + if err := db.Create(admin).Error; err != nil { + log.Fatalf("Failed to create admin user: %v", err) + } + log.Println("==============================================") + log.Println(" First run: created default admin user.") + log.Println(" Username: admin") + log.Println(" Password: admin") + log.Println(" Please change this password immediately!") + log.Println("==============================================") + } + + // Migration fix: always set admin role on the admin user. + result := db.Model(&User{}).Where("username = ?", "admin").Update("role", RoleAdmin) + if result.RowsAffected > 0 { + log.Printf("Migration: set admin role for existing admin user (rows affected: %d)", result.RowsAffected) + } + + DB = db + return db +} diff --git a/models/user.go b/models/user.go new file mode 100644 index 0000000..367399f --- /dev/null +++ b/models/user.go @@ -0,0 +1,52 @@ +package models + +import ( + "time" + + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +// Account status constants. +const ( + StatusDisabled = 0 // 禁用 + StatusNormal = 1 // 正常 + StatusLocked = 2 // 锁定 + StatusUnactivated = 3 // 未激活 +) + +// Role constants. +const ( + RoleAdmin = "admin" + RoleAuthor = "author" +) + +// User represents a blog user (author / admin). +type User struct { + gorm.Model + Username string `gorm:"uniqueIndex;not null;size:255" json:"username"` + Password string `gorm:"not null" json:"-"` + DisplayName string `gorm:"size:255" json:"display_name"` + Avatar string `gorm:"size:512" json:"avatar"` + Birthday *time.Time `json:"birthday"` + Gender string `gorm:"size:16" json:"gender"` + Email string `gorm:"size:255" json:"email"` + Status int `gorm:"default:1" json:"status"` + Role string `gorm:"size:32;default:author" json:"role"` +} + +// SetPassword hashes the plain-text password with bcrypt and stores it. +func (u *User) SetPassword(plain string) error { + hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost) + if err != nil { + return err + } + u.Password = string(hash) + return nil +} + +// CheckPassword compares a plain-text password against the stored bcrypt hash. +func (u *User) CheckPassword(plain string) bool { + err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(plain)) + return err == nil +} diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html new file mode 100644 index 0000000..ae6bb18 --- /dev/null +++ b/templates/admin/dashboard.html @@ -0,0 +1,42 @@ +{{define "dashboard"}} +{{template "header" .}} +
+
+
+

{{index .Tr "dash_title"}}

+

{{index .Tr "dash_welcome"}} {{.Username}}!

+
+
+ +
+
+ + +
+
+

{{index .Tr "dash_posts"}}

+

0

+
+
+

{{index .Tr "dash_pages"}}

+

0

+
+
+

{{index .Tr "dash_users"}}

+

1

+
+
+ + +
+

{{index .Tr "dash_mgmt_title"}}

+

{{index .Tr "dash_mgmt_desc"}}

+
+
+{{template "footer" .}} +{{end}} diff --git a/templates/layouts/base.html b/templates/layouts/base.html new file mode 100644 index 0000000..f66e715 --- /dev/null +++ b/templates/layouts/base.html @@ -0,0 +1,88 @@ +{{define "header"}} + + + + + + {{.Title}} - {{index .Tr "site_title"}} + + + + + + + +
+{{end}} + +{{define "footer"}} +
+ + +
+
+ {{index .Tr "footer_text"}} +
+
+ + + +{{end}} diff --git a/templates/pages/home.html b/templates/pages/home.html new file mode 100644 index 0000000..10faa5a --- /dev/null +++ b/templates/pages/home.html @@ -0,0 +1,38 @@ +{{define "home"}} +{{template "header" .}} +
+

+ {{index .Tr "home_welcome"}} +

+

+ {{index .Tr "home_subtitle"}} +

+ +
+ +
+

{{index .Tr "home_latest"}}

+
+
+

{{index .Tr "home_post1_tit"}}

+

{{index .Tr "home_post1_dsc"}}

+
+
+

{{index .Tr "home_post2_tit"}}

+

{{index .Tr "home_post2_dsc"}}

+
+
+

{{index .Tr "home_post3_tit"}}

+

{{index .Tr "home_post3_dsc"}}

+
+
+
+{{template "footer" .}} +{{end}} diff --git a/templates/pages/login.html b/templates/pages/login.html new file mode 100644 index 0000000..cb506ac --- /dev/null +++ b/templates/pages/login.html @@ -0,0 +1,46 @@ +{{define "login"}} +{{template "header" .}} +
+
+

{{index .Tr "login_title"}}

+ + {{if .Error}} +
+ {{.Error}} +
+ {{end}} + +
+
+ + +
+
+ + +
+ +
+
+
+{{template "footer" .}} +{{end}} diff --git a/templates/pages/profile.html b/templates/pages/profile.html new file mode 100644 index 0000000..2d7d834 --- /dev/null +++ b/templates/pages/profile.html @@ -0,0 +1,99 @@ +{{define "profile"}} +{{template "header" .}} + +
+

{{index .Tr "profile_title"}}

+ + {{if .Success}} +
+ {{.Success}} +
+ {{end}} + {{if .Error}} +
+ {{.Error}} +
+ {{end}} + +
+ +
+

{{index .Tr "profile_avatar"}}

+
+
+ {{if .Profile.Avatar}} + avatar + {{else}} + + + + {{end}} +
+ +
+
+ + +
+

{{index .Tr "profile_title"}}

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+

{{index .Tr "profile_change_password"}}

+

Leave blank if you don't want to change your password.

+ +
+ + +
+ +
+ + +
+
+ + + +
+
+ +{{template "footer" .}} +{{end}}