Add auth, rest api, svelte frontend, build structure
81
.air.toml
@ -3,50 +3,57 @@ testdata_dir = "testdata"
|
|||||||
tmp_dir = "tmp"
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
[build]
|
[build]
|
||||||
args_bin = []
|
args_bin = []
|
||||||
bin = "./tmp/main"
|
bin = "./tmp/main"
|
||||||
cmd = "go build -o ./tmp/main ."
|
cmd = "go build -o ./tmp/main ."
|
||||||
delay = 1000
|
delay = 1000
|
||||||
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
|
exclude_dir = [
|
||||||
exclude_file = []
|
"assets",
|
||||||
exclude_regex = ["_test.go"]
|
"tmp",
|
||||||
exclude_unchanged = false
|
"vendor",
|
||||||
follow_symlink = false
|
"testdata",
|
||||||
full_bin = ""
|
"web/frontend/node_modules",
|
||||||
include_dir = []
|
"web/static",
|
||||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
]
|
||||||
include_file = []
|
exclude_file = []
|
||||||
kill_delay = "0s"
|
exclude_regex = ["_test.go"]
|
||||||
log = "build-errors.log"
|
exclude_unchanged = false
|
||||||
poll = false
|
follow_symlink = false
|
||||||
poll_interval = 0
|
full_bin = ""
|
||||||
post_cmd = []
|
include_dir = []
|
||||||
pre_cmd = []
|
include_ext = ["go", "ts", "svelte", "html"]
|
||||||
rerun = false
|
include_file = ["main.go", "vite.config.ts"]
|
||||||
rerun_delay = 500
|
kill_delay = "0s"
|
||||||
send_interrupt = false
|
log = "build-errors.log"
|
||||||
stop_on_error = false
|
poll = false
|
||||||
|
poll_interval = 0
|
||||||
|
post_cmd = []
|
||||||
|
pre_cmd = []
|
||||||
|
rerun = false
|
||||||
|
rerun_delay = 500
|
||||||
|
send_interrupt = false
|
||||||
|
stop_on_error = false
|
||||||
|
|
||||||
[color]
|
[color]
|
||||||
app = ""
|
app = ""
|
||||||
build = "yellow"
|
build = "yellow"
|
||||||
main = "magenta"
|
main = "magenta"
|
||||||
runner = "green"
|
runner = "green"
|
||||||
watcher = "cyan"
|
watcher = "cyan"
|
||||||
|
|
||||||
[log]
|
[log]
|
||||||
main_only = false
|
main_only = false
|
||||||
silent = false
|
silent = false
|
||||||
time = false
|
time = false
|
||||||
|
|
||||||
[misc]
|
[misc]
|
||||||
clean_on_exit = false
|
clean_on_exit = false
|
||||||
|
|
||||||
[proxy]
|
[proxy]
|
||||||
app_port = 0
|
app_port = 0
|
||||||
enabled = false
|
enabled = false
|
||||||
proxy_port = 0
|
proxy_port = 0
|
||||||
|
|
||||||
[screen]
|
[screen]
|
||||||
clear_on_rebuild = false
|
clear_on_rebuild = false
|
||||||
keep_scroll = true
|
keep_scroll = true
|
||||||
|
7
.sqlfluff
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
[sqlfluff]
|
||||||
|
dialect = sqlite
|
||||||
|
templater = placeholder
|
||||||
|
processes = -1
|
||||||
|
|
||||||
|
[sqlfluff:templater:placeholder]
|
||||||
|
param_style = question_mark
|
40
Makefile
@ -1,27 +1,17 @@
|
|||||||
.PHONY: dev build
|
TARGET = ./tmp/server
|
||||||
|
FRONTEND = ./web/frontend
|
||||||
|
|
||||||
dev:
|
SVELTE_COMPONENTS = status
|
||||||
@if command -v $(HOME)/go/bin/air > /dev/null; then \
|
|
||||||
AIR_CMD="$(HOME)/go/bin/air"; \
|
|
||||||
elif command -v air > /dev/null; then \
|
|
||||||
AIR_CMD="air"; \
|
|
||||||
else \
|
|
||||||
read -p "air is not installed. Install it? [Y/n] " choice; \
|
|
||||||
if [ "$$choice" != "n" ] && [ "$$choice" != "N" ]; then \
|
|
||||||
echo "Installing..."; \
|
|
||||||
go install github.com/air-verse/air@latest; \
|
|
||||||
AIR_CMD="$(HOME)/go/bin/air"; \
|
|
||||||
else \
|
|
||||||
echo "Exiting..."; \
|
|
||||||
exit 1; \
|
|
||||||
fi; \
|
|
||||||
fi; \
|
|
||||||
echo "Starting Air..."; \
|
|
||||||
$$AIR_CMD
|
|
||||||
|
|
||||||
build:
|
SVELTE_TARGETS = $(addprefix $(FRONTEND)/public/build/,$(addsuffix .ts, $(SVELTE_COMPONENTS)))
|
||||||
@echo "Generate Tailwind CSS..."
|
|
||||||
go generate
|
.PHONY: $(TARGET)
|
||||||
@echo "Building Go server..."
|
.NOTPARALLEL:
|
||||||
go build -o tmp/server main.go
|
|
||||||
@echo "Build complete."
|
$(TARGET): $(SVELTE_TARGETS)
|
||||||
|
$(info ===> BUILD Go server)
|
||||||
|
@go build -o $(TARGET) main.go
|
||||||
|
|
||||||
|
$(SVELTE_TARGETS): $(SVELTE_SRC)
|
||||||
|
$(info ===> BUILD frontend)
|
||||||
|
cd $(FRONTEND) && npm install && npm run build
|
||||||
|
5
go.mod
@ -12,15 +12,20 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
|
github.com/gorilla/sessions v1.4.0 // indirect
|
||||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.24 // indirect
|
github.com/mattn/go-sqlite3 v1.14.24 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/olivere/vite v0.1.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
go.uber.org/atomic v1.11.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
|
golang.org/x/crypto v0.38.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||||
golang.org/x/sys v0.33.0 // indirect
|
golang.org/x/sys v0.33.0 // indirect
|
||||||
|
golang.org/x/time v0.11.0 // indirect
|
||||||
modernc.org/libc v1.65.7 // indirect
|
modernc.org/libc v1.65.7 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
10
go.sum
@ -8,6 +8,10 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
|
|||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
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/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
@ -23,6 +27,8 @@ github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBW
|
|||||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/olivere/vite v0.1.0 h1:Wi5zTtS3BbnOrfG+oRT7KZOI9lp48gRv59VptSBmPO4=
|
||||||
|
github.com/olivere/vite v0.1.0/go.mod h1:ef1SWmGSWAYJxSuY2Bu90YLQ7hUBxYmejIVuFGsIIe8=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
@ -31,6 +37,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT
|
|||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
|
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||||
|
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
||||||
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||||
@ -40,6 +48,8 @@ golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
|||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||||
|
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||||
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
|
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
|
||||||
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
|
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
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
@ -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
@ -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
@ -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"
|
"git.clustercockpit.org/moebiusband/go-http-skeleton/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PageData struct {
|
|
||||||
Title string
|
|
||||||
}
|
|
||||||
|
|
||||||
func RootHandler() http.HandlerFunc {
|
func RootHandler() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
slog.Info("Render root handler")
|
slog.Info("Render root handler")
|
||||||
tpl := template.Must(template.ParseFS(web.Templates, "templates/index.html", "templates/base.html"))
|
tpl := template.Must(template.ParseFS(web.Templates, "templates/index.html", "templates/base.html"))
|
||||||
|
|
||||||
// basefile, err := web.Templates.ReadFile("templates/base.html")
|
data := web.PageData{
|
||||||
// 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{
|
|
||||||
Title: "DyeForYarn",
|
Title: "DyeForYarn",
|
||||||
}
|
}
|
||||||
if err := tpl.ExecuteTemplate(w, "base", data); err != nil {
|
if err := tpl.ExecuteTemplate(w, "base", data); err != nil {
|
||||||
|
@ -5,7 +5,7 @@ import (
|
|||||||
"net/http"
|
"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) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if err := recover(); err != nil {
|
if err := recover(); err != nil {
|
||||||
|
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
@ -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 (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -21,6 +22,11 @@ func Connect(dsnURI string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
repo = New(dbConn)
|
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
|
return dbConn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetRepository() (*Queries, error) {
|
func GetRepository() *Queries {
|
||||||
if repo == nil {
|
if repo == nil {
|
||||||
slog.Error("Database connection not initialized!")
|
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()
|
v, dirty, err := m.Version()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == migrate.ErrNilVersion {
|
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 {
|
} else {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if v < Version {
|
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 {
|
} 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)
|
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)
|
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 {
|
if err != nil {
|
||||||
return m, err
|
return m, err
|
||||||
}
|
}
|
||||||
@ -75,7 +75,7 @@ func MigrateDB(db string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
v, dirty, err := m.Version()
|
_, dirty, err := m.Version()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == migrate.ErrNilVersion {
|
if err == migrate.ErrNilVersion {
|
||||||
slog.Warn("Legacy database without version or missing database file!")
|
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 {
|
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)
|
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
@ -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
@ -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
@ -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
|
-- name: GetUser :one
|
||||||
SELECT * FROM authors
|
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;
|
WHERE id = ? LIMIT 1;
|
||||||
|
|
||||||
-- name: ListAuthors :many
|
-- name: ListNews :many
|
||||||
SELECT * FROM authors
|
SELECT * FROM news
|
||||||
ORDER BY name;
|
ORDER BY news_date;
|
||||||
|
|
||||||
-- name: CreateAuthor :one
|
-- name: ListActiveNews :many
|
||||||
INSERT INTO authors (
|
SELECT * FROM news
|
||||||
name, bio
|
WHERE display = 1
|
||||||
) VALUES (
|
ORDER BY news_date;
|
||||||
?, ?
|
|
||||||
)
|
|
||||||
RETURNING *;
|
|
||||||
|
|
||||||
-- name: UpdateAuthor :exec
|
-- name: CreateNewsEntry :exec
|
||||||
UPDATE authors
|
INSERT INTO news (
|
||||||
set name = ?,
|
news_title, news_text, news_date,
|
||||||
bio = ?
|
news_publish, display
|
||||||
|
) VALUES (?, ?, ?, ?, ?);
|
||||||
|
|
||||||
|
-- name: UpdateNewsEntry :exec
|
||||||
|
UPDATE news
|
||||||
|
SET
|
||||||
|
news_title = ?, news_text = ?,
|
||||||
|
news_date = ?, news_publish = ?, display = ?
|
||||||
WHERE id = ?;
|
WHERE id = ?;
|
||||||
|
|
||||||
-- name: DeleteAuthor :exec
|
-- name: DeleteNewsEntry :exec
|
||||||
DELETE FROM authors
|
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 = ?;
|
WHERE id = ?;
|
||||||
|
117
main.go
@ -1,26 +1,26 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.clustercockpit.org/moebiusband/go-http-skeleton/internal/auth"
|
||||||
"git.clustercockpit.org/moebiusband/go-http-skeleton/internal/handlers"
|
"git.clustercockpit.org/moebiusband/go-http-skeleton/internal/handlers"
|
||||||
"git.clustercockpit.org/moebiusband/go-http-skeleton/internal/middleware"
|
"git.clustercockpit.org/moebiusband/go-http-skeleton/internal/middleware"
|
||||||
"git.clustercockpit.org/moebiusband/go-http-skeleton/internal/repository"
|
"git.clustercockpit.org/moebiusband/go-http-skeleton/internal/repository"
|
||||||
|
"git.clustercockpit.org/moebiusband/go-http-skeleton/web"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed web/static/*
|
|
||||||
var static embed.FS
|
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
_, jsonLogger := os.LookupEnv("JSON_LOGGER")
|
_, jsonLogger := os.LookupEnv("JSON_LOGGER")
|
||||||
_, debug := os.LookupEnv("DEBUG")
|
_, debug := os.LookupEnv("DEBUG")
|
||||||
@ -48,10 +48,14 @@ func init() {
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var flagMigrateDB, flagRevertDB, flagForceDB bool
|
var flagMigrateDB, flagRevertDB, flagForceDB bool
|
||||||
|
var flagNewUser, flagDelUser string
|
||||||
|
|
||||||
flag.BoolVar(&flagMigrateDB, "migrate-db", false, "Migrate database to supported version and exit")
|
flag.BoolVar(&flagMigrateDB, "migrate-db", false, "Migrate database to supported version and exit")
|
||||||
flag.BoolVar(&flagRevertDB, "revert-db", false, "Migrate database to previous version and exit")
|
flag.BoolVar(&flagRevertDB, "revert-db", false, "Migrate database to previous version and exit")
|
||||||
flag.BoolVar(&flagForceDB, "force-db", false, "Force database version, clear dirty flag and exit")
|
flag.BoolVar(&flagForceDB, "force-db", false, "Force database version, clear dirty flag and exit")
|
||||||
|
flag.StringVar(&flagNewUser, "add-user", "", "Add a new user. Argument format: <username>:<password>")
|
||||||
|
flag.StringVar(&flagDelUser, "del-user", "", "Remove a existing user. Argument format: <username>")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
err := godotenv.Load()
|
err := godotenv.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -60,7 +64,7 @@ func main() {
|
|||||||
|
|
||||||
dbURL := os.Getenv("DB")
|
dbURL := os.Getenv("DB")
|
||||||
if dbURL == "" {
|
if dbURL == "" {
|
||||||
dbURL = "file:app.db"
|
dbURL = "app.db"
|
||||||
}
|
}
|
||||||
|
|
||||||
if flagMigrateDB {
|
if flagMigrateDB {
|
||||||
@ -70,6 +74,7 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
slog.Info("MigrateDB Success: Migrated database at location.\n", "version", repository.Version)
|
slog.Info("MigrateDB Success: Migrated database at location.\n", "version", repository.Version)
|
||||||
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
if flagRevertDB {
|
if flagRevertDB {
|
||||||
@ -79,6 +84,7 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
slog.Info("RevertDB Success: Reverted database", "version", (repository.Version - 1))
|
slog.Info("RevertDB Success: Reverted database", "version", (repository.Version - 1))
|
||||||
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
if flagForceDB {
|
if flagForceDB {
|
||||||
@ -87,7 +93,8 @@ func main() {
|
|||||||
slog.Error("ForceDB Failed: Could not force database version", "version", repository.Version, "error", err)
|
slog.Error("ForceDB Failed: Could not force database version", "version", repository.Version, "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
slog.Error("ForceDB Success: Forced database version", "version", repository.Version)
|
slog.Info("ForceDB Success: Forced database version", "version", repository.Version)
|
||||||
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
repository.Connect(dbURL)
|
repository.Connect(dbURL)
|
||||||
@ -98,20 +105,58 @@ func main() {
|
|||||||
}
|
}
|
||||||
addr := ":" + port
|
addr := ":" + port
|
||||||
|
|
||||||
|
auth.Init()
|
||||||
|
|
||||||
|
if flagNewUser != "" {
|
||||||
|
parts := strings.SplitN(flagNewUser, ":", 2)
|
||||||
|
if len(parts) != 2 || len(parts[0]) == 0 {
|
||||||
|
slog.Error("Add User: Could not parse supplied argument format: No changes.\n"+
|
||||||
|
"Want: <username>::<password>\n", "have", flagNewUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
q := repository.GetRepository()
|
||||||
|
if err := q.CreateUser(ctx, repository.CreateUserParams{
|
||||||
|
UserName: parts[0], UserPass: &parts[1],
|
||||||
|
}); err != nil {
|
||||||
|
slog.Error("Add User: Could not add new user authentication", "username", parts[0], "error", err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
} else {
|
||||||
|
slog.Info("add new user", "username", parts[0])
|
||||||
|
}
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if flagDelUser != "" {
|
||||||
|
ctx := context.Background()
|
||||||
|
q := repository.GetRepository()
|
||||||
|
if err := q.DeleteUser(ctx, flagDelUser); err != nil {
|
||||||
|
slog.Error("Delete User: Could not delete user", "username", flagDelUser, "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
} else {
|
||||||
|
slog.Info("deleted user from DB", "username", flagDelUser)
|
||||||
|
}
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// Use an embedded filesystem rooted at "web/static"
|
sfs, err := fs.Sub(web.StaticAssets, "frontend/dist")
|
||||||
fs, err := fs.Sub(static, "web/static")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to create sub filesystem", "error", err)
|
slog.Error("Failed to create sub filesystem", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Serve files from the embedded /web/static directory at /static
|
mux.Handle("GET /static/", http.StripPrefix("/static", http.FileServer(http.FS(sfs))))
|
||||||
fileServer := http.FileServer(http.FS(fs))
|
|
||||||
mux.Handle("GET /static/", http.StripPrefix("/static/", fileServer))
|
afs, err := fs.Sub(web.StaticAssets, "frontend/dist/assets")
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to create sub filesystem", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mux.Handle("GET /assets/", http.StripPrefix("/assets", http.FileServer(http.FS(afs))))
|
||||||
|
|
||||||
mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
||||||
data, err := static.ReadFile("web/static/favicon.ico")
|
data, err := web.StaticAssets.ReadFile("frontend/dist/favicon.ico")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
@ -120,7 +165,7 @@ func main() {
|
|||||||
w.Write(data)
|
w.Write(data)
|
||||||
})
|
})
|
||||||
mux.HandleFunc("GET /robots.txt", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /robots.txt", func(w http.ResponseWriter, r *http.Request) {
|
||||||
data, err := static.ReadFile("web/static/robots.txt")
|
data, err := web.StaticAssets.ReadFile("frontend/dist/robots.txt")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
@ -134,10 +179,54 @@ func main() {
|
|||||||
w.Write([]byte(`OK`))
|
w.Write([]byte(`OK`))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /login", func(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
rw.Header().Add("Content-Type", "text/html; charset=utf-8")
|
||||||
|
web.RenderTemplate(rw, "login", web.PageData{Title: "Login"})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("GET /imprint", func(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
rw.Header().Add("Content-Type", "text/html; charset=utf-8")
|
||||||
|
web.RenderTemplate(rw, "imprint", web.PageData{Title: "Imprint"})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("GET /privacy", func(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
rw.Header().Add("Content-Type", "text/html; charset=utf-8")
|
||||||
|
web.RenderTemplate(rw, "privacy", web.PageData{Title: "Privacy"})
|
||||||
|
})
|
||||||
|
|
||||||
|
authHandle := auth.GetAuthInstance()
|
||||||
|
mux.Handle("POST /login", authHandle.Login(
|
||||||
|
func(rw http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
rw.Header().Add("Content-Type", "text/html; charset=utf-8")
|
||||||
|
rw.WriteHeader(http.StatusUnauthorized)
|
||||||
|
web.RenderTemplate(rw, "login", web.PageData{
|
||||||
|
Title: "Login failed - ClusterCockpit",
|
||||||
|
MsgType: "alert-warning",
|
||||||
|
Message: err.Error(),
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
mux.Handle("POST /logout", authHandle.Logout(
|
||||||
|
http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
rw.Header().Add("Content-Type", "text/html; charset=utf-8")
|
||||||
|
rw.WriteHeader(http.StatusOK)
|
||||||
|
web.RenderTemplate(rw, "login", web.PageData{
|
||||||
|
Title: "Bye - ClusterCockpit",
|
||||||
|
MsgType: "alert-info",
|
||||||
|
Message: "Logout successful",
|
||||||
|
})
|
||||||
|
})))
|
||||||
|
|
||||||
mux.HandleFunc("GET /", handlers.RootHandler())
|
mux.HandleFunc("GET /", handlers.RootHandler())
|
||||||
|
|
||||||
|
securedMux := http.NewServeMux()
|
||||||
|
securedMux.HandleFunc("GET /", handlers.AdminHandler())
|
||||||
|
|
||||||
|
securedChain := &middleware.Chain{}
|
||||||
|
securedChain.Use(middleware.SecuredCheck)
|
||||||
|
|
||||||
|
mux.Handle("GET /admin/", http.StripPrefix("/admin", securedChain.Then(securedMux)))
|
||||||
|
|
||||||
chain := &middleware.Chain{}
|
chain := &middleware.Chain{}
|
||||||
chain.Use(middleware.RecoverMiddleware)
|
chain.Use(middleware.Recover)
|
||||||
wrappedMux := chain.Then(mux)
|
wrappedMux := chain.Then(mux)
|
||||||
|
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
|
24
web/frontend/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
47
web/frontend/README.md
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
# Svelte + TS + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Svelte and TypeScript in Vite.
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
|
||||||
|
|
||||||
|
## Need an official Svelte framework?
|
||||||
|
|
||||||
|
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
|
||||||
|
|
||||||
|
## Technical considerations
|
||||||
|
|
||||||
|
**Why use this over SvelteKit?**
|
||||||
|
|
||||||
|
- It brings its own routing solution which might not be preferable for some users.
|
||||||
|
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
|
||||||
|
|
||||||
|
This template contains as little as possible to get started with Vite + TypeScript + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
|
||||||
|
|
||||||
|
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
|
||||||
|
|
||||||
|
**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?**
|
||||||
|
|
||||||
|
Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information.
|
||||||
|
|
||||||
|
**Why include `.vscode/extensions.json`?**
|
||||||
|
|
||||||
|
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
|
||||||
|
|
||||||
|
**Why enable `allowJs` in the TS template?**
|
||||||
|
|
||||||
|
While `allowJs: false` would indeed prevent the use of `.js` files in the project, it does not prevent the use of JavaScript syntax in `.svelte` files. In addition, it would force `checkJs: false`, bringing the worst of both worlds: not being able to guarantee the entire codebase is TypeScript, and also having worse typechecking for the existing JavaScript. In addition, there are valid use cases in which a mixed codebase may be relevant.
|
||||||
|
|
||||||
|
**Why is HMR not preserving my local component state?**
|
||||||
|
|
||||||
|
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/rixo/svelte-hmr#svelte-hmr).
|
||||||
|
|
||||||
|
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// store.ts
|
||||||
|
// An extremely simple external store
|
||||||
|
import { writable } from 'svelte/store'
|
||||||
|
export default writable(0)
|
||||||
|
```
|
13
web/frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Vite + Svelte + TS</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
1412
web/frontend/package-lock.json
generated
Normal file
20
web/frontend/package.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
|
"@tsconfig/svelte": "^5.0.4",
|
||||||
|
"svelte": "^5.28.1",
|
||||||
|
"svelte-check": "^4.1.6",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"vite": "^6.3.5"
|
||||||
|
}
|
||||||
|
}
|
@ -115,6 +115,25 @@ p {
|
|||||||
transition: background 0.3s ease-in-out;
|
transition: background 0.3s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content {
|
||||||
|
flex: 1 0 auto;
|
||||||
|
margin-top: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
.content-section {
|
.content-section {
|
||||||
padding-top: 200px;
|
padding-top: 200px;
|
||||||
}
|
}
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 151 KiB |
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
Before Width: | Height: | Size: 180 KiB After Width: | Height: | Size: 180 KiB |
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
Before Width: | Height: | Size: 487 KiB After Width: | Height: | Size: 487 KiB |
Before Width: | Height: | Size: 288 KiB After Width: | Height: | Size: 288 KiB |
Before Width: | Height: | Size: 380 KiB After Width: | Height: | Size: 380 KiB |
Before Width: | Height: | Size: 450 KiB After Width: | Height: | Size: 450 KiB |
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
Before Width: | Height: | Size: 451 KiB After Width: | Height: | Size: 451 KiB |
Before Width: | Height: | Size: 462 KiB After Width: | Height: | Size: 462 KiB |
Before Width: | Height: | Size: 268 KiB After Width: | Height: | Size: 268 KiB |
Before Width: | Height: | Size: 540 KiB After Width: | Height: | Size: 540 KiB |
Before Width: | Height: | Size: 411 KiB After Width: | Height: | Size: 411 KiB |
Before Width: | Height: | Size: 368 KiB After Width: | Height: | Size: 368 KiB |
Before Width: | Height: | Size: 460 KiB After Width: | Height: | Size: 460 KiB |
Before Width: | Height: | Size: 320 KiB After Width: | Height: | Size: 320 KiB |
Before Width: | Height: | Size: 286 KiB After Width: | Height: | Size: 286 KiB |
Before Width: | Height: | Size: 458 KiB After Width: | Height: | Size: 458 KiB |
Before Width: | Height: | Size: 443 KiB After Width: | Height: | Size: 443 KiB |
Before Width: | Height: | Size: 571 KiB After Width: | Height: | Size: 571 KiB |
Before Width: | Height: | Size: 420 KiB After Width: | Height: | Size: 420 KiB |
Before Width: | Height: | Size: 424 KiB After Width: | Height: | Size: 424 KiB |
Before Width: | Height: | Size: 498 KiB After Width: | Height: | Size: 498 KiB |
Before Width: | Height: | Size: 480 KiB After Width: | Height: | Size: 480 KiB |
Before Width: | Height: | Size: 345 KiB After Width: | Height: | Size: 345 KiB |
Before Width: | Height: | Size: 568 KiB After Width: | Height: | Size: 568 KiB |
Before Width: | Height: | Size: 417 KiB After Width: | Height: | Size: 417 KiB |
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 201 KiB |
Before Width: | Height: | Size: 310 KiB After Width: | Height: | Size: 310 KiB |
Before Width: | Height: | Size: 520 KiB After Width: | Height: | Size: 520 KiB |
Before Width: | Height: | Size: 366 KiB After Width: | Height: | Size: 366 KiB |
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 279 KiB |