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) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 01:49:04 +08:00
co-authored by Claude Opus 4.8
commit c82d4482d4
20 changed files with 1592 additions and 0 deletions
+22
View File
@@ -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
+128
View File
@@ -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: <auto-generated> # 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
+125
View File
@@ -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
}
+59
View File
@@ -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
)
+140
View File
@@ -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=
+19
View File
@@ -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)
}
}
+64
View File
@@ -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, "/")
}
}
+44
View File
@@ -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
}
+17
View File
@@ -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)
}
}
+144
View File
@@ -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")
}
}
+204
View File
@@ -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
}
+75
View File
@@ -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)
}
}
+100
View File
@@ -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()
}
}
+86
View File
@@ -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
}
+52
View File
@@ -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
}
+42
View File
@@ -0,0 +1,42 @@
{{define "dashboard"}}
{{template "header" .}}
<section class="max-w-5xl mx-auto px-4 py-12">
<div class="flex items-center justify-between mb-8">
<div>
<h2 class="text-3xl font-bold text-gray-900">{{index .Tr "dash_title"}}</h2>
<p class="text-gray-500 mt-1">{{index .Tr "dash_welcome"}} <span class="font-medium text-gray-700">{{.Username}}</span>!</p>
</div>
<form action="/logout" method="post" class="m-0">
<button
type="submit"
class="bg-gray-200 text-gray-700 px-4 py-2 rounded-lg font-medium hover:bg-gray-300 transition-colors cursor-pointer"
>
{{index .Tr "logout"}}
</button>
</form>
</div>
<!-- Stats -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<p class="text-sm text-gray-500 uppercase tracking-wider">{{index .Tr "dash_posts"}}</p>
<p class="text-3xl font-bold text-gray-900 mt-1">0</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<p class="text-sm text-gray-500 uppercase tracking-wider">{{index .Tr "dash_pages"}}</p>
<p class="text-3xl font-bold text-gray-900 mt-1">0</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<p class="text-sm text-gray-500 uppercase tracking-wider">{{index .Tr "dash_users"}}</p>
<p class="text-3xl font-bold text-gray-900 mt-1">1</p>
</div>
</div>
<!-- Placeholder for future features -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-8 text-center">
<h3 class="text-xl font-semibold text-gray-800 mb-2">{{index .Tr "dash_mgmt_title"}}</h3>
<p class="text-gray-500">{{index .Tr "dash_mgmt_desc"}}</p>
</div>
</section>
{{template "footer" .}}
{{end}}
+88
View File
@@ -0,0 +1,88 @@
{{define "header"}}
<!DOCTYPE html>
<html lang="{{.Lang}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} - {{index .Tr "site_title"}}</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-50 min-h-screen flex flex-col">
<!-- Navigation -->
<nav class="bg-white shadow-sm border-b border-gray-200">
<div class="max-w-5xl mx-auto px-4 py-3 flex items-center justify-between">
<a href="/" class="text-xl font-bold text-gray-800 hover:text-blue-600 transition-colors">
{{index .Tr "site_title"}}
</a>
<div class="flex items-center gap-4">
<a href="/" class="text-gray-600 hover:text-blue-600 transition-colors">{{index .Tr "home"}}</a>
{{if .IsLoggedIn}}
<!-- Avatar Dropdown (click to toggle) -->
<div class="relative" id="avatarDropdown">
<button type="button" id="avatarBtn" class="w-9 h-9 rounded-full overflow-hidden border-2 border-gray-300 hover:border-blue-500 transition-colors flex items-center justify-center bg-gray-200 text-gray-600 font-bold text-sm cursor-pointer" onclick="toggleDropdown()">
{{if .Avatar}}
<img src="/uploads/avatars/{{.Avatar}}" alt="avatar" class="w-full h-full object-cover">
{{else}}
<svg class="w-5 h-5 text-gray-500 pointer-events-none" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v1.2c0 .66.54 1.2 1.2 1.2h16.8c.66 0 1.2-.54 1.2-1.2v-1.2c0-3.2-6.4-4.8-9.6-4.8z"/>
</svg>
{{end}}
</button>
<div id="dropdownMenu" class="absolute right-0 mt-2 w-44 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50 hidden">
<a href="/profile" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors">
{{index .Tr "profile"}}
</a>
{{if eq .Role "admin"}}
<a href="/admin" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors">
{{index .Tr "admin_panel"}}
</a>
{{end}}
<div class="border-t border-gray-100 my-1"></div>
<form action="/logout" method="post" class="m-0">
<button type="submit" class="w-full text-left block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors cursor-pointer bg-transparent border-none">
{{index .Tr "logout"}}
</button>
</form>
</div>
</div>
{{else}}
<a href="/login" class="text-gray-600 hover:text-blue-600 transition-colors">{{index .Tr "login"}}</a>
{{end}}
<!-- Language Switcher -->
<a href="?lang={{.SwitchLang}}" class="text-sm text-gray-400 hover:text-blue-600 transition-colors border border-gray-300 rounded px-2 py-1">
{{index .Tr "lang_switch"}}
</a>
</div>
</div>
</nav>
<!-- Main Content -->
<main class="flex-1">
{{end}}
{{define "footer"}}
</main>
<!-- Footer -->
<footer class="bg-white border-t border-gray-200 py-6 mt-auto">
<div class="max-w-5xl mx-auto px-4 text-center text-gray-500 text-sm">
{{index .Tr "footer_text"}}
</div>
</footer>
<script>
function toggleDropdown() {
var menu = document.getElementById('dropdownMenu');
menu.classList.toggle('hidden');
}
// Close dropdown when clicking outside
document.addEventListener('click', function(e) {
var dropdown = document.getElementById('avatarDropdown');
var menu = document.getElementById('dropdownMenu');
if (dropdown && !dropdown.contains(e.target)) {
menu.classList.add('hidden');
}
});
</script>
</body>
</html>
{{end}}
+38
View File
@@ -0,0 +1,38 @@
{{define "home"}}
{{template "header" .}}
<section class="max-w-5xl mx-auto px-4 py-20 text-center">
<h1 class="text-5xl font-extrabold text-gray-900 mb-4">
{{index .Tr "home_welcome"}}
</h1>
<p class="text-xl text-gray-600 mb-8 max-w-2xl mx-auto">
{{index .Tr "home_subtitle"}}
</p>
<div class="flex justify-center gap-4">
<a href="/login" class="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors">
{{index .Tr "home_sign_in"}}
</a>
<a href="/admin" class="inline-block bg-gray-200 text-gray-700 px-6 py-3 rounded-lg font-medium hover:bg-gray-300 transition-colors">
{{index .Tr "home_to_dash"}}
</a>
</div>
</section>
<section class="max-w-5xl mx-auto px-4 py-16">
<h2 class="text-3xl font-bold text-gray-900 mb-8 text-center">{{index .Tr "home_latest"}}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-2">{{index .Tr "home_post1_tit"}}</h3>
<p class="text-gray-500 text-sm">{{index .Tr "home_post1_dsc"}}</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-2">{{index .Tr "home_post2_tit"}}</h3>
<p class="text-gray-500 text-sm">{{index .Tr "home_post2_dsc"}}</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-2">{{index .Tr "home_post3_tit"}}</h3>
<p class="text-gray-500 text-sm">{{index .Tr "home_post3_dsc"}}</p>
</div>
</div>
</section>
{{template "footer" .}}
{{end}}
+46
View File
@@ -0,0 +1,46 @@
{{define "login"}}
{{template "header" .}}
<section class="max-w-md mx-auto px-4 py-20">
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
<h2 class="text-2xl font-bold text-gray-900 mb-6 text-center">{{index .Tr "login_title"}}</h2>
{{if .Error}}
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm">
{{.Error}}
</div>
{{end}}
<form action="/login" method="post" class="space-y-5">
<div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "login_username"}}</label>
<input
type="text"
id="username"
name="username"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
placeholder="{{index .Tr "login_ph_user"}}"
>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "login_password"}}</label>
<input
type="password"
id="password"
name="password"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
placeholder="{{index .Tr "login_ph_pass"}}"
>
</div>
<button
type="submit"
class="w-full bg-blue-600 text-white py-2 px-4 rounded-lg font-medium hover:bg-blue-700 transition-colors cursor-pointer"
>
{{index .Tr "login_submit"}}
</button>
</form>
</div>
</section>
{{template "footer" .}}
{{end}}
+99
View File
@@ -0,0 +1,99 @@
{{define "profile"}}
{{template "header" .}}
<section class="max-w-2xl mx-auto px-4 py-10">
<h2 class="text-2xl font-bold text-gray-900 mb-8">{{index .Tr "profile_title"}}</h2>
{{if .Success}}
<div class="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg mb-6 text-sm">
{{.Success}}
</div>
{{end}}
{{if .Error}}
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm">
{{.Error}}
</div>
{{end}}
<form action="/profile" method="post" enctype="multipart/form-data" class="space-y-8">
<!-- Avatar Section -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">{{index .Tr "profile_avatar"}}</h3>
<div class="flex items-center gap-5">
<div class="w-20 h-20 rounded-full overflow-hidden border-2 border-gray-300 flex items-center justify-center bg-gray-200">
{{if .Profile.Avatar}}
<img src="/uploads/avatars/{{.Profile.Avatar}}" alt="avatar" class="w-full h-full object-cover">
{{else}}
<svg class="w-10 h-10 text-gray-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v1.2c0 .66.54 1.2 1.2 1.2h16.8c.66 0 1.2-.54 1.2-1.2v-1.2c0-3.2-6.4-4.8-9.6-4.8z"/>
</svg>
{{end}}
</div>
<label class="cursor-pointer bg-gray-200 text-gray-700 px-4 py-2 rounded-lg font-medium hover:bg-gray-300 transition-colors text-sm">
{{index .Tr "profile_change_avatar"}}
<input type="file" name="avatar" accept="image/*" class="hidden" onchange="this.form.submit()">
</label>
</div>
</div>
<!-- Basic Info -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
<h3 class="text-lg font-semibold text-gray-800">{{index .Tr "profile_title"}}</h3>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "profile_display_name"}}</label>
<input type="text" name="display_name" value="{{.Profile.DisplayName}}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "profile_gender"}}</label>
<select name="gender" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors bg-white">
<option value="male" {{if eq .Profile.Gender "male"}}selected{{end}}>{{index .Tr "gender_male"}}</option>
<option value="female" {{if eq .Profile.Gender "female"}}selected{{end}}>{{index .Tr "gender_female"}}</option>
<option value="other" {{if eq .Profile.Gender "other"}}selected{{end}}>{{index .Tr "gender_other"}}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "profile_birthday"}}</label>
<input type="date" name="birthday"
value="{{if .Profile.Birthday}}{{.Profile.Birthday.Format "2006-01-02"}}{{end}}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "profile_email"}}</label>
<input type="email" name="email" value="{{.Profile.Email}}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
</div>
</div>
<!-- Password Change -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
<h3 class="text-lg font-semibold text-gray-800">{{index .Tr "profile_change_password"}}</h3>
<p class="text-sm text-gray-500">Leave blank if you don't want to change your password.</p>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "profile_current_password"}}</label>
<input type="password" name="current_password"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "profile_new_password"}}</label>
<input type="password" name="new_password"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
</div>
</div>
<!-- Save Button -->
<button type="submit"
class="w-full bg-blue-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-blue-700 transition-colors cursor-pointer">
{{index .Tr "profile_save"}}
</button>
</form>
</section>
{{template "footer" .}}
{{end}}