Add auth, rest api, svelte frontend, build structure
This commit is contained in:
131
internal/api/rest.go
Normal file
131
internal/api/rest.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.clustercockpit.org/moebiusband/go-http-skeleton/internal/repository"
|
||||
)
|
||||
|
||||
type ErrorResponse struct {
|
||||
// Statustext of Errorcode
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error"` // Error Message
|
||||
}
|
||||
|
||||
func MountApiEndpoints(r *http.ServeMux) {
|
||||
r.HandleFunc("POST /api/news/", createNewsItem)
|
||||
r.HandleFunc("GET /api/news/", readNewsItems)
|
||||
r.HandleFunc("GET /api/news/{id}", readNewsItem)
|
||||
r.HandleFunc("PATCH /api/news/", updateNewsItem)
|
||||
r.HandleFunc("DELETE /api/news/{id}", deleteNewsItem)
|
||||
}
|
||||
|
||||
func handleError(err error, statusCode int, rw http.ResponseWriter) {
|
||||
slog.Warn("rest error", "error", err)
|
||||
rw.Header().Add("Content-Type", "application/json")
|
||||
rw.WriteHeader(statusCode)
|
||||
json.NewEncoder(rw).Encode(ErrorResponse{
|
||||
Status: http.StatusText(statusCode),
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
func decode(r io.Reader, val any) error {
|
||||
dec := json.NewDecoder(r)
|
||||
dec.DisallowUnknownFields()
|
||||
return dec.Decode(val)
|
||||
}
|
||||
|
||||
func createNewsItem(rw http.ResponseWriter, r *http.Request) {
|
||||
req := repository.CreateNewsEntryParams{}
|
||||
if err := decode(r.Body, &req); err != nil {
|
||||
handleError(fmt.Errorf("parsing request body failed: %w", err), http.StatusBadRequest, rw)
|
||||
return
|
||||
}
|
||||
|
||||
repo := repository.GetRepository()
|
||||
err := repo.CreateNewsEntry(r.Context(), req)
|
||||
if err != nil {
|
||||
handleError(err, http.StatusBadRequest, rw)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func readNewsItems(rw http.ResponseWriter, r *http.Request) {
|
||||
repo := repository.GetRepository()
|
||||
items, err := repo.ListNews(r.Context())
|
||||
if err != nil {
|
||||
handleError(err, http.StatusBadRequest, rw)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Debug("/api/news returned", "newscount", len(items))
|
||||
rw.Header().Add("Content-Type", "application/json")
|
||||
bw := bufio.NewWriter(rw)
|
||||
defer bw.Flush()
|
||||
|
||||
if err := json.NewEncoder(bw).Encode(items); err != nil {
|
||||
handleError(err, http.StatusInternalServerError, rw)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func readNewsItem(rw http.ResponseWriter, r *http.Request) {
|
||||
repo := repository.GetRepository()
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
handleError(err, http.StatusBadRequest, rw)
|
||||
return
|
||||
}
|
||||
|
||||
item, err := repo.GetNewsEntry(r.Context(), id)
|
||||
if err != nil {
|
||||
handleError(err, http.StatusBadRequest, rw)
|
||||
return
|
||||
}
|
||||
|
||||
rw.Header().Add("Content-Type", "application/json")
|
||||
bw := bufio.NewWriter(rw)
|
||||
defer bw.Flush()
|
||||
|
||||
if err := json.NewEncoder(bw).Encode(item); err != nil {
|
||||
handleError(err, http.StatusInternalServerError, rw)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func updateNewsItem(rw http.ResponseWriter, r *http.Request) {
|
||||
req := repository.UpdateNewsEntryParams{}
|
||||
if err := decode(r.Body, &req); err != nil {
|
||||
handleError(fmt.Errorf("parsing request body failed: %w", err), http.StatusBadRequest, rw)
|
||||
return
|
||||
}
|
||||
|
||||
repo := repository.GetRepository()
|
||||
err := repo.UpdateNewsEntry(r.Context(), req)
|
||||
if err != nil {
|
||||
handleError(err, http.StatusBadRequest, rw)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func deleteNewsItem(rw http.ResponseWriter, r *http.Request) {
|
||||
repo := repository.GetRepository()
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
handleError(err, http.StatusBadRequest, rw)
|
||||
return
|
||||
}
|
||||
|
||||
err = repo.DeleteNewsEntry(r.Context(), id)
|
||||
if err != nil {
|
||||
handleError(err, http.StatusBadRequest, rw)
|
||||
return
|
||||
}
|
||||
}
|
248
internal/auth/auth.go
Normal file
248
internal/auth/auth.go
Normal file
@@ -0,0 +1,248 @@
|
||||
// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
|
||||
// All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.clustercockpit.org/moebiusband/go-http-skeleton/internal/repository"
|
||||
"github.com/gorilla/sessions"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
type Authenticator interface {
|
||||
CanLogin(user *repository.AppUser, username string, rw http.ResponseWriter, r *http.Request) (*repository.AppUser, bool)
|
||||
Login(user *repository.AppUser, rw http.ResponseWriter, r *http.Request) (*repository.AppUser, error)
|
||||
}
|
||||
|
||||
var (
|
||||
initOnce sync.Once
|
||||
authInstance *Authentication
|
||||
)
|
||||
|
||||
var ipUserLimiters sync.Map
|
||||
|
||||
func getIPUserLimiter(ip, username string) *rate.Limiter {
|
||||
key := ip + ":" + username
|
||||
limiter, ok := ipUserLimiters.Load(key)
|
||||
if !ok {
|
||||
newLimiter := rate.NewLimiter(rate.Every(time.Hour/10), 10)
|
||||
ipUserLimiters.Store(key, newLimiter)
|
||||
return newLimiter
|
||||
}
|
||||
return limiter.(*rate.Limiter)
|
||||
}
|
||||
|
||||
type Authentication struct {
|
||||
sessionStore *sessions.CookieStore
|
||||
LocalAuth *LocalAuthenticator
|
||||
authenticators []Authenticator
|
||||
SessionMaxAge time.Duration
|
||||
}
|
||||
|
||||
func (auth *Authentication) AuthViaSession(
|
||||
rw http.ResponseWriter,
|
||||
r *http.Request,
|
||||
) (*repository.AppUser, error) {
|
||||
session, err := auth.sessionStore.Get(r, "session")
|
||||
if err != nil {
|
||||
slog.Error("Error while getting session store")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if session.IsNew {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// TODO: Check if session keys exist
|
||||
username, _ := session.Values["username"].(string)
|
||||
return &repository.AppUser{
|
||||
UserName: username,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func Init() {
|
||||
initOnce.Do(func() {
|
||||
authInstance = &Authentication{}
|
||||
|
||||
sessKey := os.Getenv("SESSION_KEY")
|
||||
if sessKey == "" {
|
||||
slog.Warn("environment variable 'SESSION_KEY' not set (will use non-persistent random key)")
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
slog.Error("Error while initializing authentication -> failed to generate random bytes for session key")
|
||||
os.Exit(1)
|
||||
}
|
||||
authInstance.sessionStore = sessions.NewCookieStore(bytes)
|
||||
} else {
|
||||
bytes, err := base64.StdEncoding.DecodeString(sessKey)
|
||||
if err != nil {
|
||||
slog.Error("Error while initializing authentication -> decoding session key failed")
|
||||
os.Exit(1)
|
||||
}
|
||||
authInstance.sessionStore = sessions.NewCookieStore(bytes)
|
||||
}
|
||||
|
||||
if d, err := time.ParseDuration("24h"); err == nil {
|
||||
authInstance.SessionMaxAge = d
|
||||
}
|
||||
|
||||
authInstance.LocalAuth = &LocalAuthenticator{}
|
||||
if err := authInstance.LocalAuth.Init(); err != nil {
|
||||
slog.Error("Error while initializing authentication -> localAuth init failed")
|
||||
os.Exit(1)
|
||||
}
|
||||
authInstance.authenticators = append(authInstance.authenticators, authInstance.LocalAuth)
|
||||
})
|
||||
}
|
||||
|
||||
func GetAuthInstance() *Authentication {
|
||||
if authInstance == nil {
|
||||
slog.Error("Authentication module not initialized!")
|
||||
}
|
||||
|
||||
return authInstance
|
||||
}
|
||||
|
||||
func (auth *Authentication) SaveSession(rw http.ResponseWriter,
|
||||
r *http.Request, user *repository.AppUser,
|
||||
) error {
|
||||
session, err := auth.sessionStore.New(r, "session")
|
||||
if err != nil {
|
||||
slog.Error("session creation failed", "error", err.Error())
|
||||
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
||||
return err
|
||||
}
|
||||
|
||||
if auth.SessionMaxAge != 0 {
|
||||
session.Options.MaxAge = int(auth.SessionMaxAge.Seconds())
|
||||
}
|
||||
session.Options.Secure = false
|
||||
session.Options.SameSite = http.SameSiteStrictMode
|
||||
session.Values["username"] = user.UserName
|
||||
if err := auth.sessionStore.Save(r, rw, session); err != nil {
|
||||
slog.Warn("session save failed", "error", err.Error())
|
||||
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (auth *Authentication) Login(
|
||||
onfailure func(rw http.ResponseWriter, r *http.Request, loginErr error),
|
||||
) http.Handler {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
ip = r.RemoteAddr
|
||||
}
|
||||
|
||||
username := r.FormValue("username")
|
||||
|
||||
limiter := getIPUserLimiter(ip, username)
|
||||
if !limiter.Allow() {
|
||||
slog.Warn("AUTH/RATE > Too many login attempts for combination", "ip", ip, "username", username)
|
||||
onfailure(rw, r, errors.New("too many login attempts, try again in a few minutes"))
|
||||
return
|
||||
}
|
||||
|
||||
var dbUser repository.AppUser
|
||||
if username != "" {
|
||||
var err error
|
||||
dbUser, err = repository.GetRepository().GetUser(r.Context(), username)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
slog.Error("Error while loading user", "username", username)
|
||||
}
|
||||
}
|
||||
|
||||
for _, authenticator := range auth.authenticators {
|
||||
var ok bool
|
||||
var user *repository.AppUser
|
||||
if user, ok = authenticator.CanLogin(&dbUser, username, rw, r); !ok {
|
||||
continue
|
||||
} else {
|
||||
slog.Debug("Can login with user", "username", user.UserName)
|
||||
}
|
||||
|
||||
user, err := authenticator.Login(user, rw, r)
|
||||
if err != nil {
|
||||
slog.Warn("user login failed", "error", err.Error())
|
||||
onfailure(rw, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := auth.SaveSession(rw, r, user); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("login successfull", "user", user.UserName)
|
||||
ctx := context.WithValue(r.Context(), "user", user)
|
||||
|
||||
if r.FormValue("redirect") != "" {
|
||||
http.RedirectHandler(r.FormValue("redirect"), http.StatusFound).ServeHTTP(rw, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
|
||||
http.RedirectHandler("/", http.StatusFound).ServeHTTP(rw, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
|
||||
slog.Debug("login failed: no authenticator applied")
|
||||
onfailure(rw, r, errors.New("no authenticator applied"))
|
||||
})
|
||||
}
|
||||
|
||||
func (auth *Authentication) Auth(
|
||||
onsuccess http.Handler,
|
||||
onfailure func(rw http.ResponseWriter, r *http.Request, authErr error),
|
||||
) http.Handler {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
user, err := auth.AuthViaSession(rw, r)
|
||||
if err != nil {
|
||||
slog.Info("auth -> authentication failed", "error", err.Error())
|
||||
http.Error(rw, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if user != nil {
|
||||
ctx := context.WithValue(r.Context(), "user", user)
|
||||
onsuccess.ServeHTTP(rw, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("auth -> authentication failed")
|
||||
onfailure(rw, r, errors.New("unauthorized (please login first)"))
|
||||
})
|
||||
}
|
||||
|
||||
func (auth *Authentication) Logout(onsuccess http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
session, err := auth.sessionStore.Get(r, "session")
|
||||
if err != nil {
|
||||
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if !session.IsNew {
|
||||
session.Options.MaxAge = -1
|
||||
if err := auth.sessionStore.Save(r, rw, session); err != nil {
|
||||
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
onsuccess.ServeHTTP(rw, r)
|
||||
})
|
||||
}
|
47
internal/auth/local.go
Normal file
47
internal/auth/local.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
|
||||
// All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"git.clustercockpit.org/moebiusband/go-http-skeleton/internal/repository"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type LocalAuthenticator struct {
|
||||
auth *Authentication
|
||||
}
|
||||
|
||||
var _ Authenticator = (*LocalAuthenticator)(nil)
|
||||
|
||||
func (la *LocalAuthenticator) Init() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (la *LocalAuthenticator) CanLogin(
|
||||
user *repository.AppUser,
|
||||
username string,
|
||||
rw http.ResponseWriter,
|
||||
r *http.Request,
|
||||
) (*repository.AppUser, bool) {
|
||||
return user, user != nil
|
||||
}
|
||||
|
||||
func (la *LocalAuthenticator) Login(
|
||||
user *repository.AppUser,
|
||||
rw http.ResponseWriter,
|
||||
r *http.Request,
|
||||
) (*repository.AppUser, error) {
|
||||
if e := bcrypt.CompareHashAndPassword([]byte(*user.UserPass),
|
||||
[]byte(r.FormValue("password"))); e != nil {
|
||||
slog.Error("AUTH/LOCAL > Authentication for user failed!", "user", user.UserName)
|
||||
return nil, fmt.Errorf("Authentication failed")
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
33
internal/handlers/admin.go
Normal file
33
internal/handlers/admin.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"git.clustercockpit.org/moebiusband/go-http-skeleton/web"
|
||||
"github.com/olivere/vite"
|
||||
)
|
||||
|
||||
func AdminHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tpl := template.Must(template.ParseFS(web.Templates, "templates/admin.html", "templates/base.html"))
|
||||
viteFragment, err := vite.HTMLFragment(vite.Config{
|
||||
FS: web.StaticAssets,
|
||||
IsDev: false,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, "Error instantiating vite fragment", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := web.PageData{
|
||||
Title: "Admin",
|
||||
Vite: viteFragment,
|
||||
}
|
||||
if err := tpl.ExecuteTemplate(w, "base", data); err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
slog.Error("Error executing template", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
@@ -8,29 +8,12 @@ import (
|
||||
"git.clustercockpit.org/moebiusband/go-http-skeleton/web"
|
||||
)
|
||||
|
||||
type PageData struct {
|
||||
Title string
|
||||
}
|
||||
|
||||
func RootHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Info("Render root handler")
|
||||
tpl := template.Must(template.ParseFS(web.Templates, "templates/index.html", "templates/base.html"))
|
||||
|
||||
// basefile, err := web.Templates.ReadFile("templates/base.html")
|
||||
// if err != nil {
|
||||
// http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
// slog.Error("Error reading template", "error", err)
|
||||
// return
|
||||
// }
|
||||
// file, err := web.Templates.ReadFile("templates/index.html")
|
||||
// if err != nil {
|
||||
// http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
// slog.Error("Error reading template", "error", err)
|
||||
// return
|
||||
// }
|
||||
|
||||
data := PageData{
|
||||
data := web.PageData{
|
||||
Title: "DyeForYarn",
|
||||
}
|
||||
if err := tpl.ExecuteTemplate(w, "base", data); err != nil {
|
||||
|
@@ -5,7 +5,7 @@ import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func RecoverMiddleware(next http.Handler) http.Handler {
|
||||
func Recover(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
|
27
internal/middleware/securedCheck.go
Normal file
27
internal/middleware/securedCheck.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.clustercockpit.org/moebiusband/go-http-skeleton/internal/auth"
|
||||
"git.clustercockpit.org/moebiusband/go-http-skeleton/web"
|
||||
)
|
||||
|
||||
func SecuredCheck(next http.Handler) http.Handler {
|
||||
authHandle := auth.GetAuthInstance()
|
||||
|
||||
return authHandle.Auth(
|
||||
// On success;
|
||||
next,
|
||||
|
||||
// On failure:
|
||||
func(rw http.ResponseWriter, r *http.Request, err error) {
|
||||
rw.WriteHeader(http.StatusUnauthorized)
|
||||
web.RenderTemplate(rw, "login", web.PageData{
|
||||
Title: "Authentication failed - ClusterCockpit",
|
||||
MsgType: "alert-danger",
|
||||
Message: err.Error(),
|
||||
Redirect: r.RequestURI,
|
||||
})
|
||||
})
|
||||
}
|
31
internal/repository/db.go
Normal file
31
internal/repository/db.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type DBTX interface {
|
||||
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
|
||||
PrepareContext(context.Context, string) (*sql.Stmt, error)
|
||||
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
|
||||
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
|
||||
}
|
||||
|
||||
func New(db DBTX) *Queries {
|
||||
return &Queries{db: db}
|
||||
}
|
||||
|
||||
type Queries struct {
|
||||
db DBTX
|
||||
}
|
||||
|
||||
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
|
||||
return &Queries{
|
||||
db: tx,
|
||||
}
|
||||
}
|
@@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
@@ -21,6 +22,11 @@ func Connect(dsnURI string) {
|
||||
}
|
||||
|
||||
repo = New(dbConn)
|
||||
err = checkDBVersion(dbConn)
|
||||
if err != nil {
|
||||
slog.Error("DB Connection: Failed DB version check", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -32,10 +38,11 @@ func GetConnection() (*sql.DB, error) {
|
||||
return dbConn, nil
|
||||
}
|
||||
|
||||
func GetRepository() (*Queries, error) {
|
||||
func GetRepository() *Queries {
|
||||
if repo == nil {
|
||||
slog.Error("Database connection not initialized!")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
return repo, nil
|
||||
return repo
|
||||
}
|
||||
|
@@ -36,14 +36,14 @@ func checkDBVersion(db *sql.DB) error {
|
||||
v, dirty, err := m.Version()
|
||||
if err != nil {
|
||||
if err == migrate.ErrNilVersion {
|
||||
slog.Warn("Legacy database without version or missing database file!")
|
||||
slog.Error("Legacy database without version or missing database file!")
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if v < Version {
|
||||
return fmt.Errorf("unsupported database version %d, need %d.\nPlease backup your database file and run cc-backend -migrate-db", v, Version)
|
||||
return fmt.Errorf("unsupported database version %d, need %d.\nPlease backup your database file and run server -migrate-db", v, Version)
|
||||
} else if v > Version {
|
||||
return fmt.Errorf("unsupported database version %d, need %d.\nPlease refer to documentation how to downgrade db with external migrate tool", v, Version)
|
||||
}
|
||||
@@ -61,7 +61,7 @@ func getMigrateInstance(dsnURI string) (m *migrate.Migrate, err error) {
|
||||
slog.Error("failed to get instance", "Error", err)
|
||||
}
|
||||
|
||||
m, err = migrate.NewWithSourceInstance("iofs", d, dsnURI)
|
||||
m, err = migrate.NewWithSourceInstance("iofs", d, "sqlite3://"+dsnURI)
|
||||
if err != nil {
|
||||
return m, err
|
||||
}
|
||||
@@ -75,7 +75,7 @@ func MigrateDB(db string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
v, dirty, err := m.Version()
|
||||
_, dirty, err := m.Version()
|
||||
if err != nil {
|
||||
if err == migrate.ErrNilVersion {
|
||||
slog.Warn("Legacy database without version or missing database file!")
|
||||
@@ -84,10 +84,6 @@ func MigrateDB(db string) error {
|
||||
}
|
||||
}
|
||||
|
||||
if v < Version {
|
||||
slog.Info("unsupported database version %d, need %d.\nPlease backup your database file and run cc-backend -migrate-db", v, Version)
|
||||
}
|
||||
|
||||
if dirty {
|
||||
return fmt.Errorf("last migration to version %d has failed, please fix the db manually and force version with -force-db flag", Version)
|
||||
}
|
||||
|
@@ -1,11 +0,0 @@
|
||||
CREATE TABLE news (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
bio TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE retailer (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
bio TEXT
|
||||
);
|
23
internal/repository/migrations/01_schema.up.sql
Normal file
23
internal/repository/migrations/01_schema.up.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
CREATE TABLE IF NOT EXISTS news (
|
||||
id INTEGER PRIMARY KEY,
|
||||
news_title TEXT NOT NULL,
|
||||
news_text TEXT NOT NULL,
|
||||
news_date DATETIME,
|
||||
news_publish DATETIME,
|
||||
display TINYINT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS retailer (
|
||||
id INTEGER PRIMARY KEY,
|
||||
shopname TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
country TEXT NOT NULL,
|
||||
display TINYINT NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_user (
|
||||
user_name TEXT PRIMARY KEY,
|
||||
user_pass TEXT DEFAULT NULL,
|
||||
realname TEXT DEFAULT NULL,
|
||||
email TEXT DEFAULT NULL
|
||||
);
|
33
internal/repository/models.go
Normal file
33
internal/repository/models.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type AppUser struct {
|
||||
UserName string `db:"user_name" json:"userName"`
|
||||
UserPass *string `db:"user_pass" json:"userPass"`
|
||||
Realname *string `db:"realname" json:"realname"`
|
||||
Email *string `db:"email" json:"email"`
|
||||
}
|
||||
|
||||
type News struct {
|
||||
ID int64 `db:"id" json:"id"`
|
||||
NewsTitle string `db:"news_title" json:"newsTitle"`
|
||||
NewsText string `db:"news_text" json:"newsText"`
|
||||
NewsDate *time.Time `db:"news_date" json:"newsDate"`
|
||||
NewsPublish *time.Time `db:"news_publish" json:"newsPublish"`
|
||||
Display int64 `db:"display" json:"display"`
|
||||
}
|
||||
|
||||
type Retailer struct {
|
||||
ID int64 `db:"id" json:"id"`
|
||||
Shopname string `db:"shopname" json:"shopname"`
|
||||
Url string `db:"url" json:"url"`
|
||||
Country string `db:"country" json:"country"`
|
||||
Display int64 `db:"display" json:"display"`
|
||||
}
|
386
internal/repository/query.sql.go
Normal file
386
internal/repository/query.sql.go
Normal file
@@ -0,0 +1,386 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: query.sql
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
const createNewsEntry = `-- name: CreateNewsEntry :exec
|
||||
INSERT INTO news (
|
||||
news_title, news_text, news_date,
|
||||
news_publish, display
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
type CreateNewsEntryParams struct {
|
||||
NewsTitle string `db:"news_title" json:"newsTitle"`
|
||||
NewsText string `db:"news_text" json:"newsText"`
|
||||
NewsDate *time.Time `db:"news_date" json:"newsDate"`
|
||||
NewsPublish *time.Time `db:"news_publish" json:"newsPublish"`
|
||||
Display int64 `db:"display" json:"display"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateNewsEntry(ctx context.Context, arg CreateNewsEntryParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createNewsEntry,
|
||||
arg.NewsTitle,
|
||||
arg.NewsText,
|
||||
arg.NewsDate,
|
||||
arg.NewsPublish,
|
||||
arg.Display,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const createRetailer = `-- name: CreateRetailer :exec
|
||||
INSERT INTO retailer (
|
||||
shopname, url, country, display
|
||||
) VALUES (?, ?, ?, ?)
|
||||
`
|
||||
|
||||
type CreateRetailerParams struct {
|
||||
Shopname string `db:"shopname" json:"shopname"`
|
||||
Url string `db:"url" json:"url"`
|
||||
Country string `db:"country" json:"country"`
|
||||
Display int64 `db:"display" json:"display"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateRetailer(ctx context.Context, arg CreateRetailerParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createRetailer,
|
||||
arg.Shopname,
|
||||
arg.Url,
|
||||
arg.Country,
|
||||
arg.Display,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const createUser = `-- name: CreateUser :exec
|
||||
INSERT INTO app_user (
|
||||
user_name, user_pass
|
||||
)
|
||||
VALUES (?, ?)
|
||||
`
|
||||
|
||||
type CreateUserParams struct {
|
||||
UserName string `db:"user_name" json:"userName"`
|
||||
UserPass *string `db:"user_pass" json:"userPass"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createUser, arg.UserName, arg.UserPass)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteNewsEntry = `-- name: DeleteNewsEntry :exec
|
||||
DELETE FROM news
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteNewsEntry(ctx context.Context, id int64) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteNewsEntry, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteRetailer = `-- name: DeleteRetailer :exec
|
||||
DELETE FROM news
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteRetailer(ctx context.Context, id int64) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteRetailer, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteUser = `-- name: DeleteUser :exec
|
||||
DELETE FROM app_user
|
||||
WHERE user_name = ?
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteUser(ctx context.Context, userName string) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteUser, userName)
|
||||
return err
|
||||
}
|
||||
|
||||
const getNewsEntry = `-- name: GetNewsEntry :one
|
||||
SELECT id, news_title, news_text, news_date, news_publish, display FROM news
|
||||
WHERE id = ? LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetNewsEntry(ctx context.Context, id int64) (News, error) {
|
||||
row := q.db.QueryRowContext(ctx, getNewsEntry, id)
|
||||
var i News
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.NewsTitle,
|
||||
&i.NewsText,
|
||||
&i.NewsDate,
|
||||
&i.NewsPublish,
|
||||
&i.Display,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUser = `-- name: GetUser :one
|
||||
SELECT user_name, user_pass, realname, email FROM app_user
|
||||
WHERE user_name = ? LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUser(ctx context.Context, userName string) (AppUser, error) {
|
||||
row := q.db.QueryRowContext(ctx, getUser, userName)
|
||||
var i AppUser
|
||||
err := row.Scan(
|
||||
&i.UserName,
|
||||
&i.UserPass,
|
||||
&i.Realname,
|
||||
&i.Email,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listActiveNews = `-- name: ListActiveNews :many
|
||||
SELECT id, news_title, news_text, news_date, news_publish, display FROM news
|
||||
WHERE display = 1
|
||||
ORDER BY news_date
|
||||
`
|
||||
|
||||
func (q *Queries) ListActiveNews(ctx context.Context) ([]News, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listActiveNews)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []News
|
||||
for rows.Next() {
|
||||
var i News
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.NewsTitle,
|
||||
&i.NewsText,
|
||||
&i.NewsDate,
|
||||
&i.NewsPublish,
|
||||
&i.Display,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listActiveRetailers = `-- name: ListActiveRetailers :many
|
||||
SELECT id, shopname, url, country, display FROM retailer
|
||||
WHERE display = 1
|
||||
ORDER BY shopname
|
||||
`
|
||||
|
||||
func (q *Queries) ListActiveRetailers(ctx context.Context) ([]Retailer, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listActiveRetailers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Retailer
|
||||
for rows.Next() {
|
||||
var i Retailer
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Shopname,
|
||||
&i.Url,
|
||||
&i.Country,
|
||||
&i.Display,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listNews = `-- name: ListNews :many
|
||||
SELECT id, news_title, news_text, news_date, news_publish, display FROM news
|
||||
ORDER BY news_date
|
||||
`
|
||||
|
||||
func (q *Queries) ListNews(ctx context.Context) ([]News, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listNews)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []News
|
||||
for rows.Next() {
|
||||
var i News
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.NewsTitle,
|
||||
&i.NewsText,
|
||||
&i.NewsDate,
|
||||
&i.NewsPublish,
|
||||
&i.Display,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listRetailers = `-- name: ListRetailers :many
|
||||
SELECT id, shopname, url, country, display FROM retailer
|
||||
ORDER BY shopname
|
||||
`
|
||||
|
||||
func (q *Queries) ListRetailers(ctx context.Context) ([]Retailer, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listRetailers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Retailer
|
||||
for rows.Next() {
|
||||
var i Retailer
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Shopname,
|
||||
&i.Url,
|
||||
&i.Country,
|
||||
&i.Display,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listUsers = `-- name: ListUsers :many
|
||||
SELECT user_name, user_pass, realname, email FROM app_user
|
||||
ORDER BY user_name
|
||||
`
|
||||
|
||||
func (q *Queries) ListUsers(ctx context.Context) ([]AppUser, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listUsers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []AppUser
|
||||
for rows.Next() {
|
||||
var i AppUser
|
||||
if err := rows.Scan(
|
||||
&i.UserName,
|
||||
&i.UserPass,
|
||||
&i.Realname,
|
||||
&i.Email,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateNewsEntry = `-- name: UpdateNewsEntry :exec
|
||||
UPDATE news
|
||||
SET
|
||||
news_title = ?, news_text = ?,
|
||||
news_date = ?, news_publish = ?, display = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type UpdateNewsEntryParams struct {
|
||||
NewsTitle string `db:"news_title" json:"newsTitle"`
|
||||
NewsText string `db:"news_text" json:"newsText"`
|
||||
NewsDate *time.Time `db:"news_date" json:"newsDate"`
|
||||
NewsPublish *time.Time `db:"news_publish" json:"newsPublish"`
|
||||
Display int64 `db:"display" json:"display"`
|
||||
ID int64 `db:"id" json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateNewsEntry(ctx context.Context, arg UpdateNewsEntryParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateNewsEntry,
|
||||
arg.NewsTitle,
|
||||
arg.NewsText,
|
||||
arg.NewsDate,
|
||||
arg.NewsPublish,
|
||||
arg.Display,
|
||||
arg.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateRetailer = `-- name: UpdateRetailer :exec
|
||||
UPDATE retailer
|
||||
SET shopname = ?, url = ?, country = ?, display = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type UpdateRetailerParams struct {
|
||||
Shopname string `db:"shopname" json:"shopname"`
|
||||
Url string `db:"url" json:"url"`
|
||||
Country string `db:"country" json:"country"`
|
||||
Display int64 `db:"display" json:"display"`
|
||||
ID int64 `db:"id" json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateRetailer(ctx context.Context, arg UpdateRetailerParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateRetailer,
|
||||
arg.Shopname,
|
||||
arg.Url,
|
||||
arg.Country,
|
||||
arg.Display,
|
||||
arg.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateUser = `-- name: UpdateUser :exec
|
||||
UPDATE app_user
|
||||
SET user_pass = ?
|
||||
WHERE user_name = ?
|
||||
`
|
||||
|
||||
type UpdateUserParams struct {
|
||||
UserPass *string `db:"user_pass" json:"userPass"`
|
||||
UserName string `db:"user_name" json:"userName"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateUser, arg.UserPass, arg.UserName)
|
||||
return err
|
||||
}
|
@@ -1,25 +1,75 @@
|
||||
-- name: GetAuthor :one
|
||||
SELECT * FROM authors
|
||||
-- name: GetUser :one
|
||||
SELECT * FROM app_user
|
||||
WHERE user_name = ? LIMIT 1;
|
||||
--
|
||||
-- name: ListUsers :many
|
||||
SELECT * FROM app_user
|
||||
ORDER BY user_name;
|
||||
|
||||
-- name: CreateUser :exec
|
||||
INSERT INTO app_user (
|
||||
user_name, user_pass
|
||||
)
|
||||
VALUES (?, ?);
|
||||
|
||||
-- name: UpdateUser :exec
|
||||
UPDATE app_user
|
||||
SET user_pass = ?
|
||||
WHERE user_name = ?;
|
||||
|
||||
-- name: DeleteUser :exec
|
||||
DELETE FROM app_user
|
||||
WHERE user_name = ?;
|
||||
--
|
||||
-- name: GetNewsEntry :one
|
||||
SELECT * FROM news
|
||||
WHERE id = ? LIMIT 1;
|
||||
|
||||
-- name: ListAuthors :many
|
||||
SELECT * FROM authors
|
||||
ORDER BY name;
|
||||
-- name: ListNews :many
|
||||
SELECT * FROM news
|
||||
ORDER BY news_date;
|
||||
|
||||
-- name: CreateAuthor :one
|
||||
INSERT INTO authors (
|
||||
name, bio
|
||||
) VALUES (
|
||||
?, ?
|
||||
)
|
||||
RETURNING *;
|
||||
-- name: ListActiveNews :many
|
||||
SELECT * FROM news
|
||||
WHERE display = 1
|
||||
ORDER BY news_date;
|
||||
|
||||
-- name: UpdateAuthor :exec
|
||||
UPDATE authors
|
||||
set name = ?,
|
||||
bio = ?
|
||||
-- name: CreateNewsEntry :exec
|
||||
INSERT INTO news (
|
||||
news_title, news_text, news_date,
|
||||
news_publish, display
|
||||
) VALUES (?, ?, ?, ?, ?);
|
||||
|
||||
-- name: UpdateNewsEntry :exec
|
||||
UPDATE news
|
||||
SET
|
||||
news_title = ?, news_text = ?,
|
||||
news_date = ?, news_publish = ?, display = ?
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: DeleteAuthor :exec
|
||||
DELETE FROM authors
|
||||
-- name: DeleteNewsEntry :exec
|
||||
DELETE FROM news
|
||||
WHERE id = ?;
|
||||
--
|
||||
-- name: ListRetailers :many
|
||||
SELECT * FROM retailer
|
||||
ORDER BY shopname;
|
||||
|
||||
-- name: ListActiveRetailers :many
|
||||
SELECT * FROM retailer
|
||||
WHERE display = 1
|
||||
ORDER BY shopname;
|
||||
|
||||
-- name: CreateRetailer :exec
|
||||
INSERT INTO retailer (
|
||||
shopname, url, country, display
|
||||
) VALUES (?, ?, ?, ?);
|
||||
|
||||
-- name: UpdateRetailer :exec
|
||||
UPDATE retailer
|
||||
SET shopname = ?, url = ?, country = ?, display = ?
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: DeleteRetailer :exec
|
||||
DELETE FROM news
|
||||
WHERE id = ?;
|
||||
|
Reference in New Issue
Block a user