diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
new file mode 100644
index 0000000..d69b1e4
--- /dev/null
+++ b/.gitea/workflows/ci.yml
@@ -0,0 +1,90 @@
+name: CI
+
+on:
+ push:
+ branches: ["**"]
+ pull_request:
+ branches: ["main"]
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: "1.25"
+
+ - name: go vet
+ run: go vet ./...
+
+ - name: gofmt check
+ run: |
+ fmt=$(gofmt -l .)
+ if [ -n "$fmt" ]; then
+ echo "Files needing formatting:"
+ echo "$fmt"
+ exit 1
+ fi
+
+ test:
+ runs-on: ubuntu-latest
+ needs: lint
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: "1.25"
+
+ - name: Run tests
+ run: go test ./... -v -race -cover
+
+ build:
+ runs-on: ubuntu-latest
+ needs: test
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: "1.25"
+
+ - name: Build binary
+ run: go build -o mrixscraft-server ./cmd/server
+
+ - name: Upload artifact
+ if: github.ref == 'refs/heads/main'
+ uses: actions/upload-artifact@v4
+ with:
+ name: mrixscraft-server
+ path: mrixscraft-server
+
+ docker:
+ runs-on: ubuntu-latest
+ needs: build
+ if: github.ref == 'refs/heads/main'
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to Gitea Registry
+ uses: docker/login-action@v3
+ with:
+ registry: gitea.mrixs.me
+ username: ${{ secrets.GITEA_USERNAME }}
+ password: ${{ secrets.GITEA_TOKEN }}
+
+ - name: Build and push
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ push: true
+ tags: |
+ gitea.mrixs.me/Mrixs/MrixsCraft-server:latest
+ gitea.mrixs.me/Mrixs/MrixsCraft-server:${{ github.sha }}
+ cache-from: type=registry,ref=gitea.mrixs.me/Mrixs/MrixsCraft-server:latest
+ cache-to: type=inline
diff --git a/cmd/server/main.go b/cmd/server/main.go
index e7fdbe3..3a19bb2 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -81,7 +81,7 @@ func main() {
Handler: handler,
}
- // Graceful shutdown.
+ // Graceful shutdown on SIGINT/SIGTERM.
done := make(chan os.Signal, 1)
signal.Notify(done, syscall.SIGINT, syscall.SIGTERM)
diff --git a/internal/api/api.go b/internal/api/api.go
index 41c5363..eea3e1c 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -7,6 +7,7 @@ import (
"image/png"
"io"
"net/http"
+ "net/mail"
"os"
"path/filepath"
"strings"
@@ -156,8 +157,12 @@ func (h *Handler) register(w http.ResponseWriter, r *http.Request) {
return
}
- // Basic email validation.
- if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") {
+ // Basic email validation (RFC 5321).
+ if len(req.Email) > 254 {
+ utils.WriteError(w, http.StatusBadRequest, "Email too long (max 254 characters)")
+ return
+ }
+ if _, err := mail.ParseAddress(req.Email); err != nil {
utils.WriteError(w, http.StatusBadRequest, "Invalid email address")
return
}
diff --git a/internal/templates/html/base.html b/internal/templates/html/base.html
new file mode 100644
index 0000000..5dfa98f
--- /dev/null
+++ b/internal/templates/html/base.html
@@ -0,0 +1,103 @@
+
+
+
+
+
+ {{.Title}} — MrixsCraft
+
+
+
+
+ {{template "content" .}}
+
+
+
diff --git a/internal/templates/html/index.html b/internal/templates/html/index.html
new file mode 100644
index 0000000..8c43d5c
--- /dev/null
+++ b/internal/templates/html/index.html
@@ -0,0 +1,17 @@
+{{define "content"}}
+
+
Добро пожаловать в MrixsCraft
+
Приватный Minecraft-сервер с модпаками. Зарегистрируйся, скачай лаунчер и играй.
+
+ Начать играть
+
+
+
+
Как начать
+
+ - Зарегистрируйся на сайте
+ - Скачай лаунчер для своей ОС
+ - Авторизуйся, выбирай модпак и нажимай PLAY
+
+
+{{end}}
diff --git a/internal/templates/html/login.html b/internal/templates/html/login.html
new file mode 100644
index 0000000..fe58358
--- /dev/null
+++ b/internal/templates/html/login.html
@@ -0,0 +1,42 @@
+{{define "content"}}
+
+
+
+{{end}}
diff --git a/internal/templates/html/register.html b/internal/templates/html/register.html
new file mode 100644
index 0000000..ca5009d
--- /dev/null
+++ b/internal/templates/html/register.html
@@ -0,0 +1,43 @@
+{{define "content"}}
+
+
Регистрация
+
+
+
Уже есть аккаунт? Войти
+
+
+
+{{end}}
diff --git a/internal/templates/templates.go b/internal/templates/templates.go
index b77101a..15a6fc9 100644
--- a/internal/templates/templates.go
+++ b/internal/templates/templates.go
@@ -1,7 +1,4 @@
-// package templates handles Go html/template rendering for the site and admin panel.
-//
-// This is a placeholder implementation. Actual templates will be added
-// when the web UI is designed.
+// package templates handles Go html/template rendering for the website.
package templates
import (
@@ -15,14 +12,20 @@ import (
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/database"
)
-// Handler serves template-rendered pages.
-type Handler struct {
- db *database.DB
- cfg *config.Config
- layout *template.Template
+// pageData is passed to all templates.
+type pageData struct {
+ Title string
}
-// NewHandler creates a new templates handler and parses embedded/ondisk templates.
+// Handler serves template-rendered pages.
+type Handler struct {
+ db *database.DB
+ cfg *config.Config
+ tmpl *template.Template
+ loaded bool
+}
+
+// NewHandler creates a new templates handler and parses on-disk templates.
func NewHandler(db *database.DB, cfg *config.Config) *Handler {
h := &Handler{db: db, cfg: cfg}
h.parseTemplates()
@@ -43,36 +46,47 @@ func (h *Handler) index(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
- if h.layout == nil {
- w.WriteHeader(http.StatusOK)
- w.Write([]byte("MrixsCraft
"))
+ if !h.loaded {
+ fallback(w, "MrixsCraft")
return
}
- h.layout.ExecuteTemplate(w, "index", nil)
+ h.render(w, "index.html", pageData{Title: "Главная"})
}
func (h *Handler) loginPage(w http.ResponseWriter, r *http.Request) {
- if h.layout == nil {
- w.WriteHeader(http.StatusOK)
- w.Write([]byte("Login
"))
+ if !h.loaded {
+ fallback(w, "Login")
return
}
- h.layout.ExecuteTemplate(w, "login", nil)
+ h.render(w, "login.html", pageData{Title: "Вход"})
}
func (h *Handler) registerPage(w http.ResponseWriter, r *http.Request) {
- if h.layout == nil {
- w.WriteHeader(http.StatusOK)
- w.Write([]byte("Register
"))
+ if !h.loaded {
+ fallback(w, "Register")
return
}
- h.layout.ExecuteTemplate(w, "register", nil)
+ h.render(w, "register.html", pageData{Title: "Регистрация"})
+}
+
+// render executes the named template with data, writing to w.
+func (h *Handler) render(w http.ResponseWriter, name string, data pageData) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := h.tmpl.ExecuteTemplate(w, name, data); err != nil {
+ log.Printf("Template error (%s): %v", name, err)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ }
+}
+
+// fallback writes a minimal placeholder when templates are missing.
+func fallback(w http.ResponseWriter, title string) {
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("" + title + "
"))
}
// ── Template parsing ───────────────────────────────────────────
func (h *Handler) parseTemplates() {
- // Look for templates in common locations.
dirs := []string{
filepath.Join("internal", "templates", "html"),
"templates",
@@ -81,16 +95,18 @@ func (h *Handler) parseTemplates() {
if _, err := os.Stat(dir); err != nil {
continue
}
- tmpl, err := template.ParseGlob(filepath.Join(dir, "*.html"))
- if err == nil && tmpl != nil {
- h.layout = tmpl
+ pattern := filepath.Join(dir, "*.html")
+ tmpl, err := template.New("").Funcs(template.FuncMap{}).ParseGlob(pattern)
+ if err != nil {
+ log.Printf("Template parse error in %s: %v", dir, err)
+ continue
+ }
+ if tmpl != nil {
+ h.tmpl = tmpl
+ h.loaded = true
log.Printf("Loaded templates from %s", dir)
return
}
- if err != nil {
- log.Printf("Template parse error in %s: %v", dir, err)
- }
}
- // No templates found — handler will use placeholder responses.
log.Println("No HTML templates found; using placeholder responses")
}
diff --git a/migrations/002_migration_history.sql b/migrations/002_migration_history.sql
new file mode 100644
index 0000000..ef7808e
--- /dev/null
+++ b/migrations/002_migration_history.sql
@@ -0,0 +1,12 @@
+-- 002_migration_history.sql — Track applied migrations.
+-- Run manually: psql $DATABASE_URL -f migrations/002_migration_history.sql
+
+CREATE TABLE IF NOT EXISTS migration_history (
+ id SERIAL PRIMARY KEY,
+ filename VARCHAR(255) UNIQUE NOT NULL,
+ applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Record this migration.
+INSERT INTO migration_history (filename) VALUES ('002_migration_history.sql')
+ ON CONFLICT (filename) DO NOTHING;
diff --git a/migrations/README.md b/migrations/README.md
new file mode 100644
index 0000000..e669485
--- /dev/null
+++ b/migrations/README.md
@@ -0,0 +1,14 @@
+# Migrations
+
+Applied manually in order:
+
+```bash
+psql "$DATABASE_URL" -f migrations/001_init.sql # schema
+psql "$DATABASE_URL" -f migrations/002_migration_history.sql # tracking
+```
+
+Check applied:
+
+```sql
+SELECT * FROM migration_history ORDER BY id;
+```