Compare commits
26 Commits
aa228bebaa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d6ca4b815 | |||
| 4482529f6a | |||
| 0f53e8f659 | |||
| 4d746027dc | |||
| f7ab09c2c3 | |||
| fb347b96df | |||
| a8412af0c2 | |||
| 625280c327 | |||
| 01073aca78 | |||
| dd778174a7 | |||
| 49709ad3d2 | |||
| 3f49d528e7 | |||
| 46b67f8a6e | |||
| 5fc22c36b0 | |||
| 75442d261d | |||
| 0c3797ec30 | |||
| b78ec08cd1 | |||
| 2cb70e2fe7 | |||
| f6d2579b51 | |||
| 1390c2b97f | |||
| f13b3c80de | |||
| 39aaf00a6f | |||
| b60ab8b2de | |||
| b82a257df3 | |||
| 61228b4586 | |||
| 4e663d848a |
22
.drone.yml
22
.drone.yml
@@ -1,13 +1,17 @@
|
|||||||
kind: pipeline
|
kind: pipeline
|
||||||
name: test-docker-build
|
name: test-docker-build
|
||||||
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: test-docker-build
|
- name: test
|
||||||
image: plugins/docker
|
image: docker:dind
|
||||||
settings:
|
volumes:
|
||||||
repo: foo/bar
|
- name: dockersock
|
||||||
dry_run: true
|
path: /var/run/docker.sock
|
||||||
target: final
|
commands:
|
||||||
tags:
|
#- echo nameserver 116.202.204.30 > /etc/resolv.conf
|
||||||
- ${DRONE_COMMIT_SHA}
|
- docker build -t sneak/gohttpserver .
|
||||||
- ${DRONE_BRANCH}
|
volumes:
|
||||||
|
- name: dockersock
|
||||||
|
host:
|
||||||
|
path: /var/run/docker.sock
|
||||||
|
|||||||
12
.gitea/workflows/check.yml
Normal file
12
.gitea/workflows/check.yml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
name: check
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- run: docker build .
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
/httpd
|
/httpd
|
||||||
debug.log
|
debug.log
|
||||||
/.env
|
/.env
|
||||||
|
/cmd/httpd/httpd
|
||||||
|
|||||||
80
CLAUDE.md
Normal file
80
CLAUDE.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# Repository Rules
|
||||||
|
|
||||||
|
Last Updated 2026-01-10
|
||||||
|
|
||||||
|
These rules MUST be followed at all times, it is very important.
|
||||||
|
|
||||||
|
* Do NOT stop working while there are still incomplete TODOs in the todo list
|
||||||
|
or in TODO.md. Continue implementing until all tasks are complete or you are
|
||||||
|
explicitly told to stop.
|
||||||
|
|
||||||
|
* Never use `git add -A` - add specific changes to a deliberate commit. A
|
||||||
|
commit should contain one change. After each change, make a commit with a
|
||||||
|
good one-line summary.
|
||||||
|
|
||||||
|
* NEVER modify the linter config without asking first.
|
||||||
|
|
||||||
|
* NEVER modify tests to exclude special cases or otherwise get them to pass
|
||||||
|
without asking first. In almost all cases, the code should be changed,
|
||||||
|
NOT the tests. If you think the test needs to be changed, make your case
|
||||||
|
for that and ask for permission to proceed, then stop. You need explicit
|
||||||
|
user approval to modify existing tests. (You do not need user approval
|
||||||
|
for writing NEW tests.)
|
||||||
|
|
||||||
|
* When linting, assume the linter config is CORRECT, and that each item
|
||||||
|
output by the linter is something that legitimately needs fixing in the
|
||||||
|
code.
|
||||||
|
|
||||||
|
* When running tests, use `make test`.
|
||||||
|
|
||||||
|
* Before commits, run `make check`. This runs `make lint` and `make test`
|
||||||
|
and `make check-fmt`. Any issues discovered MUST be resolved before
|
||||||
|
committing unless explicitly told otherwise.
|
||||||
|
|
||||||
|
* When fixing a bug, write a failing test for the bug FIRST. Add
|
||||||
|
appropriate logging to the test to ensure it is written correctly. Commit
|
||||||
|
that. Then go about fixing the bug until the test passes (without
|
||||||
|
modifying the test further). Then commit that.
|
||||||
|
|
||||||
|
* When adding a new feature, do the same - implement a test first (TDD). It
|
||||||
|
doesn't have to be super complex. Commit the test, then commit the
|
||||||
|
feature.
|
||||||
|
|
||||||
|
* When adding a new feature, use a feature branch. When the feature is
|
||||||
|
completely finished and the code is up to standards (passes `make check`)
|
||||||
|
then and only then can the feature branch be merged into `main` and the
|
||||||
|
branch deleted.
|
||||||
|
|
||||||
|
* Write godoc documentation comments for all exported types and functions as
|
||||||
|
you go along.
|
||||||
|
|
||||||
|
* ALWAYS be consistent in naming. If you name something one thing in one
|
||||||
|
place, name it the EXACT SAME THING in another place.
|
||||||
|
|
||||||
|
* Be descriptive and specific in naming. `wl` is bad;
|
||||||
|
`SourceHostWhitelist` is good. `ConnsPerHost` is bad;
|
||||||
|
`MaxConnectionsPerHost` is good.
|
||||||
|
|
||||||
|
* This is not prototype or teaching code - this is designed for production.
|
||||||
|
Any security issues (such as denial of service) or other web
|
||||||
|
vulnerabilities are P1 bugs and must be added to TODO.md at the top.
|
||||||
|
|
||||||
|
* As this is production code, no stubbing of implementations unless
|
||||||
|
specifically instructed. We need working implementations.
|
||||||
|
|
||||||
|
* NEVER silently fall back to a different setting when a user's parameter
|
||||||
|
explicitly specifies a value. If a user requests format=webp and WebP
|
||||||
|
encoding is not supported, return an error - do NOT silently output PNG
|
||||||
|
instead. If a user specifies fit=invalid and that fit mode doesn't exist,
|
||||||
|
return an error - do NOT silently default to "cover". Silent fallbacks
|
||||||
|
violate the principle of least surprise and mask bugs. The only acceptable
|
||||||
|
defaults are for OMITTED parameters, never for INVALID explicit values.
|
||||||
|
|
||||||
|
* Avoid vendoring deps unless specifically instructed to. NEVER commit
|
||||||
|
the vendor directory, NEVER commit compiled binaries. If these
|
||||||
|
directories or files exist, add them to .gitignore (and commit the
|
||||||
|
.gitignore) if they are not already in there. Keep the entire git
|
||||||
|
repository (with history) small - under 20MiB, unless you specifically
|
||||||
|
must commit larger files (e.g. test fixture example media files). Only
|
||||||
|
OUR source code and immediately supporting files (such as test examples)
|
||||||
|
goes into the repo/history.
|
||||||
1225
CONVENTIONS.md
Normal file
1225
CONVENTIONS.md
Normal file
File diff suppressed because it is too large
Load Diff
58
Dockerfile
58
Dockerfile
@@ -1,41 +1,35 @@
|
|||||||
## lint image
|
# Lint stage — fast feedback
|
||||||
FROM golangci/golangci-lint@sha256:9ae3767101cd3468cdaea5b6573dadb358013e05ac38abe37d53646680fd386c AS linter
|
# golangci/golangci-lint:v1.64.8 (2025-03-17)
|
||||||
|
FROM golangci/golangci-lint@sha256:2987913e27f4eca9c8a39129d2c7bc1e74fbcf77f181e01cea607be437aa5cb8 AS lint
|
||||||
RUN mkdir -p /build
|
WORKDIR /src
|
||||||
WORKDIR /build
|
COPY go.mod go.sum ./
|
||||||
COPY ./ ./
|
|
||||||
RUN golangci-lint run
|
|
||||||
|
|
||||||
## build image:
|
|
||||||
FROM golang:1.15-buster AS builder
|
|
||||||
|
|
||||||
RUN apt update && apt install -y make bzip2
|
|
||||||
|
|
||||||
RUN mkdir -p /build
|
|
||||||
WORKDIR /build
|
|
||||||
|
|
||||||
COPY go.mod .
|
|
||||||
COPY go.sum .
|
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN make fmt-check
|
||||||
|
RUN make lint
|
||||||
|
|
||||||
COPY ./ ./
|
# Build stage
|
||||||
#RUN make lint
|
# golang:1.24-bookworm (Go 1.24)
|
||||||
RUN make rice-install && make httpd
|
FROM golang@sha256:1a6d4452c65dea36aac2e2d606b01b4a029ec90cc1ae53890540ce6173ea77ac AS builder
|
||||||
RUN go mod vendor
|
# Force BuildKit to run the lint stage
|
||||||
RUN tar -c . | bzip2 > /src.tbz2
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
|
WORKDIR /build
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN make test
|
||||||
|
RUN make build && cp ./httpd /httpd
|
||||||
|
|
||||||
## output image:
|
# Runtime stage
|
||||||
FROM debian:buster-slim AS final
|
# debian:bookworm-slim (2025-03)
|
||||||
|
FROM debian@sha256:74d56e3931e0d5a1dd51f8c8a2466d21de84a271cd3b5a733b803aa91abf4421 AS final
|
||||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||||
COPY --from=builder /build/httpd /app/httpd
|
COPY --from=builder /httpd /app/httpd
|
||||||
COPY --from=builder /src.tbz2 /usr/local/src/src.tbz2
|
|
||||||
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV HOME /app
|
ENV HOME=/app
|
||||||
|
ENV PORT=8080
|
||||||
ENV PORT 8080
|
ENV DBURL=none
|
||||||
ENV DBURL none
|
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
|
|||||||
51
Makefile
51
Makefile
@@ -1,30 +1,40 @@
|
|||||||
ARCH := $(shell uname -m)
|
|
||||||
VERSION := $(shell git describe --always --dirty=-dirty)
|
|
||||||
|
|
||||||
FN := http
|
FN := http
|
||||||
|
|
||||||
|
VERSION := $(shell git describe --always --dirty=-dirty)
|
||||||
|
ARCH := $(shell uname -m)
|
||||||
UNAME_S := $(shell uname -s)
|
UNAME_S := $(shell uname -s)
|
||||||
|
|
||||||
GOLDFLAGS += -X main.Version=$(VERSION)
|
GOLDFLAGS += -X main.Version=$(VERSION)
|
||||||
GOLDFLAGS += -X main.Buildarch=$(ARCH)
|
|
||||||
GOFLAGS := -ldflags "$(GOLDFLAGS)"
|
GOFLAGS := -ldflags "$(GOLDFLAGS)"
|
||||||
|
|
||||||
default: debug
|
default: clean debug
|
||||||
|
|
||||||
commit: fmt lint
|
commit: fmt lint
|
||||||
git commit -a
|
git commit -a
|
||||||
|
|
||||||
# get golangci-lint with:
|
# get gofumpt with:
|
||||||
# go get github.com/golangci/golangci-lint/cmd/golangci-lint@v1.31.0
|
# go install mvdan.cc/gofumpt@latest
|
||||||
# get gofumports with:
|
|
||||||
# go get mvdan.cc/gofumpt/gofumports
|
|
||||||
fmt:
|
fmt:
|
||||||
gofumports -l -w .
|
gofumpt -l -w .
|
||||||
golangci-lint run --fix
|
golangci-lint run --fix
|
||||||
|
|
||||||
|
fmt-check:
|
||||||
|
@test -z "$$(gofmt -l .)" || { echo "gofmt found unformatted files:"; gofmt -l .; exit 1; }
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
golangci-lint run
|
golangci-lint run
|
||||||
sh -c 'test -z "$$(gofmt -l .)"'
|
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
check: fmt-check lint test
|
||||||
|
|
||||||
|
build: ./$(FN)d
|
||||||
|
|
||||||
|
hooks:
|
||||||
|
@mkdir -p .git/hooks
|
||||||
|
@printf '#!/bin/sh\nmake fmt-check lint\n' > .git/hooks/pre-commit
|
||||||
|
@chmod +x .git/hooks/pre-commit
|
||||||
|
@echo "Pre-commit hook installed."
|
||||||
|
|
||||||
debug: ./$(FN)d
|
debug: ./$(FN)d
|
||||||
DEBUG=1 GOTRACEBACK=all ./$(FN)d
|
DEBUG=1 GOTRACEBACK=all ./$(FN)d
|
||||||
@@ -41,16 +51,11 @@ clean:
|
|||||||
docker:
|
docker:
|
||||||
docker build --progress plain .
|
docker build --progress plain .
|
||||||
|
|
||||||
./$(FN)d: cmd/$(FN)d/main.go */*.go
|
./$(FN)d: cmd/$(FN)d/main.go internal/*/*.go templates/* static/*
|
||||||
cd httpserver && \
|
cd ./cmd/$(FN)d && \
|
||||||
cd ../cmd/$(FN)d && \
|
go build -o ../../$(FN)d $(GOFLAGS) .
|
||||||
go build -o ../../$(FN)d $(GOFLAGS) . && \
|
|
||||||
cd ../.. && \
|
|
||||||
rice append -i ./httpserver --exec ./httpd
|
|
||||||
|
|
||||||
tools: rice-install
|
tools:
|
||||||
go get -v github.com/golangci/golangci-lint/cmd/golangci-lint@v1.31.0
|
go install mvdan.cc/gofumpt@latest
|
||||||
go get -v mvdan.cc/gofumpt/gofumports
|
|
||||||
|
|
||||||
rice-install:
|
.PHONY: default commit fmt fmt-check lint test check build hooks debug debugger run clean docker tools
|
||||||
go get -v github.com/GeertJohan/go.rice/rice
|
|
||||||
|
|||||||
@@ -86,3 +86,4 @@ WTFPL (aka public domain):
|
|||||||
0. You just DO WHAT THE FUCK YOU WANT TO.
|
0. You just DO WHAT THE FUCK YOU WANT TO.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
71
TODO.md
Normal file
71
TODO.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# TODO: Blog Posts CRUD Implementation
|
||||||
|
|
||||||
|
## Completed
|
||||||
|
|
||||||
|
- [x] Add SQLite dependency (`modernc.org/sqlite` - pure Go, no CGO)
|
||||||
|
- [x] Create models package (`internal/models/models.go`)
|
||||||
|
- Post struct with JSON tags
|
||||||
|
- CreatePostRequest and UpdatePostRequest structs
|
||||||
|
- [x] Update database package (`internal/database/database.go`)
|
||||||
|
- SQLite connection with WAL mode
|
||||||
|
- Schema auto-creation on startup
|
||||||
|
- CRUD methods: CreatePost, GetPost, ListPosts, UpdatePost, DeletePost
|
||||||
|
- [x] Create post handlers (`internal/handlers/posts.go`)
|
||||||
|
- HandleListPosts, HandleGetPost, HandleCreatePost, HandleUpdatePost, HandleDeletePost
|
||||||
|
- [x] Register API routes (`internal/server/routes.go`)
|
||||||
|
- GET /api/v1/posts
|
||||||
|
- POST /api/v1/posts
|
||||||
|
- GET /api/v1/posts/{id}
|
||||||
|
- PUT /api/v1/posts/{id}
|
||||||
|
- DELETE /api/v1/posts/{id}
|
||||||
|
- [x] Set default DBURL (`internal/config/config.go`)
|
||||||
|
- Default: `file:./data.db?_journal_mode=WAL`
|
||||||
|
|
||||||
|
## Optional (Future Enhancements)
|
||||||
|
|
||||||
|
- [ ] Add HTML templates for web UI
|
||||||
|
- `templates/posts.html` - List all posts
|
||||||
|
- `templates/post.html` - View single post
|
||||||
|
- `templates/post_form.html` - Create/edit form
|
||||||
|
- [ ] Add web routes for HTML views
|
||||||
|
- GET /posts - List page
|
||||||
|
- GET /posts/new - Create form
|
||||||
|
- GET /posts/{id} - View page
|
||||||
|
- GET /posts/{id}/edit - Edit form
|
||||||
|
- [ ] Add authentication for create/update/delete operations
|
||||||
|
- [ ] Add pagination for list endpoint
|
||||||
|
- [ ] Add unit tests
|
||||||
|
|
||||||
|
## Testing the API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a post
|
||||||
|
curl -X POST http://localhost:8080/api/v1/posts \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"title":"Hello","body":"World","author":"me"}'
|
||||||
|
|
||||||
|
# List all posts
|
||||||
|
curl http://localhost:8080/api/v1/posts
|
||||||
|
|
||||||
|
# Get a single post
|
||||||
|
curl http://localhost:8080/api/v1/posts/1
|
||||||
|
|
||||||
|
# Update a post
|
||||||
|
curl -X PUT http://localhost:8080/api/v1/posts/1 \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"title":"Updated Title","published":true}'
|
||||||
|
|
||||||
|
# Delete a post
|
||||||
|
curl -X DELETE http://localhost:8080/api/v1/posts/1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files Modified/Created
|
||||||
|
|
||||||
|
| File | Status |
|
||||||
|
|------|--------|
|
||||||
|
| `go.mod` | Modified - added modernc.org/sqlite |
|
||||||
|
| `internal/models/models.go` | Created |
|
||||||
|
| `internal/database/database.go` | Modified - added SQLite and CRUD |
|
||||||
|
| `internal/handlers/posts.go` | Created |
|
||||||
|
| `internal/server/routes.go` | Modified - added post routes |
|
||||||
|
| `internal/config/config.go` | Modified - added default DBURL |
|
||||||
@@ -1,17 +1,37 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"git.eeqj.de/sneak/gohttpserver/internal/config"
|
||||||
|
"git.eeqj.de/sneak/gohttpserver/internal/database"
|
||||||
"git.eeqj.de/sneak/gohttpserver/httpserver"
|
"git.eeqj.de/sneak/gohttpserver/internal/globals"
|
||||||
|
"git.eeqj.de/sneak/gohttpserver/internal/handlers"
|
||||||
|
"git.eeqj.de/sneak/gohttpserver/internal/healthcheck"
|
||||||
|
"git.eeqj.de/sneak/gohttpserver/internal/logger"
|
||||||
|
"git.eeqj.de/sneak/gohttpserver/internal/middleware"
|
||||||
|
"git.eeqj.de/sneak/gohttpserver/internal/server"
|
||||||
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
Appname string = "CHANGEME"
|
Appname string = "CHANGEME"
|
||||||
Version string
|
Version string
|
||||||
Buildarch string
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
os.Exit(httpserver.Run(Appname, Version, Buildarch))
|
globals.Appname = Appname
|
||||||
|
globals.Version = Version
|
||||||
|
|
||||||
|
fx.New(
|
||||||
|
fx.Provide(
|
||||||
|
config.New,
|
||||||
|
database.New,
|
||||||
|
globals.New,
|
||||||
|
handlers.New,
|
||||||
|
logger.New,
|
||||||
|
server.New,
|
||||||
|
middleware.New,
|
||||||
|
healthcheck.New,
|
||||||
|
),
|
||||||
|
fx.Invoke(func(*server.Server) {}),
|
||||||
|
).Run()
|
||||||
}
|
}
|
||||||
|
|||||||
71
go.mod
71
go.mod
@@ -1,29 +1,56 @@
|
|||||||
module git.eeqj.de/sneak/gohttpserver
|
module git.eeqj.de/sneak/gohttpserver
|
||||||
|
|
||||||
go 1.15
|
go 1.24.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/99designs/basicauth-go v0.0.0-20160802081356-2a93ba0f464d
|
github.com/99designs/basicauth-go v0.0.0-20160802081356-2a93ba0f464d
|
||||||
github.com/GeertJohan/go.rice v1.0.0
|
github.com/getsentry/sentry-go v0.15.0
|
||||||
github.com/fsnotify/fsnotify v1.4.9 // indirect
|
|
||||||
github.com/getsentry/sentry-go v0.7.0
|
|
||||||
github.com/go-chi/chi v4.1.2+incompatible
|
github.com/go-chi/chi v4.1.2+incompatible
|
||||||
github.com/go-chi/cors v1.1.1
|
github.com/go-chi/cors v1.2.1
|
||||||
github.com/golang/protobuf v1.4.2 // indirect
|
github.com/joho/godotenv v1.4.0
|
||||||
github.com/google/go-cmp v0.5.2 // indirect
|
github.com/prometheus/client_golang v1.14.0
|
||||||
github.com/joho/godotenv v1.3.0
|
github.com/slok/go-http-metrics v0.10.0
|
||||||
github.com/kr/text v0.2.0 // indirect
|
github.com/spf13/viper v1.14.0
|
||||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
|
go.uber.org/fx v1.18.2
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
modernc.org/sqlite v1.41.0
|
||||||
github.com/prometheus/client_golang v1.6.0
|
)
|
||||||
github.com/rs/zerolog v1.20.0
|
|
||||||
github.com/slok/go-http-metrics v0.8.0
|
require (
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/spf13/viper v1.7.1
|
github.com/cespare/xxhash/v2 v2.1.2 // indirect
|
||||||
github.com/stretchr/testify v1.6.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
golang.org/x/net v0.0.0-20200822124328-c89045814202 // indirect
|
github.com/fsnotify/fsnotify v1.6.0 // indirect
|
||||||
golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980 // indirect
|
github.com/golang/protobuf v1.5.2 // indirect
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
|
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||||
gopkg.in/yaml.v2 v2.3.0 // indirect
|
github.com/magiconair/properties v1.8.6 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.0.6 // indirect
|
||||||
|
github.com/prometheus/client_model v0.3.0 // indirect
|
||||||
|
github.com/prometheus/common v0.37.0 // indirect
|
||||||
|
github.com/prometheus/procfs v0.8.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/spf13/afero v1.9.3 // indirect
|
||||||
|
github.com/spf13/cast v1.5.0 // indirect
|
||||||
|
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
|
github.com/subosito/gotenv v1.4.1 // indirect
|
||||||
|
go.uber.org/atomic v1.9.0 // indirect
|
||||||
|
go.uber.org/dig v1.15.0 // indirect
|
||||||
|
go.uber.org/multierr v1.8.0 // indirect
|
||||||
|
go.uber.org/zap v1.21.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||||
|
golang.org/x/sys v0.36.0 // indirect
|
||||||
|
golang.org/x/text v0.4.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.28.1 // indirect
|
||||||
|
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
modernc.org/libc v1.66.10 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
package httpserver
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *server) handleHealthCheck() http.HandlerFunc {
|
|
||||||
type response struct {
|
|
||||||
Status string `json:"status"`
|
|
||||||
Now string `json:"now"`
|
|
||||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
|
||||||
UptimeHuman string `json:"uptime_human"`
|
|
||||||
Version string `json:"version"`
|
|
||||||
Appname string `json:"appname"`
|
|
||||||
Maintenance bool `json:"maintenance_mode"`
|
|
||||||
}
|
|
||||||
return func(w http.ResponseWriter, req *http.Request) {
|
|
||||||
resp := &response{
|
|
||||||
Status: "ok",
|
|
||||||
Now: time.Now().UTC().Format(time.RFC3339Nano),
|
|
||||||
UptimeSeconds: int64(s.uptime().Seconds()),
|
|
||||||
UptimeHuman: s.uptime().String(),
|
|
||||||
Maintenance: s.maintenance(),
|
|
||||||
Appname: s.appname,
|
|
||||||
Version: s.version,
|
|
||||||
}
|
|
||||||
s.respondJSON(w, req, resp, 200)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package httpserver
|
|
||||||
|
|
||||||
import (
|
|
||||||
"html/template"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *server) handleIndex() http.HandlerFunc {
|
|
||||||
indexTemplate := template.Must(template.New("index").Parse(s.templateFiles.MustString("index.html")))
|
|
||||||
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
err := indexTemplate.ExecuteTemplate(w, "index", nil)
|
|
||||||
if err != nil {
|
|
||||||
log.Println(err.Error())
|
|
||||||
http.Error(w, http.StatusText(500), 500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package httpserver
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *server) handleLogin() http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
fmt.Fprintf(w, "hello login")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
package httpserver
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *server) serveUntilShutdown() {
|
|
||||||
listenAddr := fmt.Sprintf(":%d", s.port)
|
|
||||||
s.httpServer = &http.Server{
|
|
||||||
Addr: listenAddr,
|
|
||||||
ReadTimeout: 10 * time.Second,
|
|
||||||
WriteTimeout: 10 * time.Second,
|
|
||||||
MaxHeaderBytes: 1 << 20,
|
|
||||||
Handler: s,
|
|
||||||
}
|
|
||||||
|
|
||||||
// add routes
|
|
||||||
// this does any necessary setup in each handler
|
|
||||||
s.routes()
|
|
||||||
|
|
||||||
s.log.Info().Str("listenaddr", listenAddr).Msg("http begin listen")
|
|
||||||
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
||||||
s.log.Error().Msgf("listen:%+s\n", err)
|
|
||||||
if s.cancelFunc != nil {
|
|
||||||
s.cancelFunc()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) respondJSON(w http.ResponseWriter, r *http.Request, data interface{}, status int) {
|
|
||||||
w.WriteHeader(status)
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
if data != nil {
|
|
||||||
err := json.NewEncoder(w).Encode(data)
|
|
||||||
if err != nil {
|
|
||||||
s.log.Error().Err(err).Msg("json encode error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) decodeJSON(w http.ResponseWriter, r *http.Request, v interface{}) error { // nolint
|
|
||||||
return json.NewDecoder(r.Body).Decode(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
||||||
s.router.ServeHTTP(w, r)
|
|
||||||
}
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
package httpserver
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
rice "github.com/GeertJohan/go.rice"
|
|
||||||
"github.com/rs/zerolog"
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
"github.com/spf13/viper"
|
|
||||||
|
|
||||||
"github.com/getsentry/sentry-go"
|
|
||||||
"github.com/go-chi/chi"
|
|
||||||
|
|
||||||
// spooky action at a distance!
|
|
||||||
// this populates the environment
|
|
||||||
// from a ./.env file automatically
|
|
||||||
// for development configuration.
|
|
||||||
// .env contents should be things like
|
|
||||||
// `DBURL=postgres://user:pass@.../`
|
|
||||||
// (without the backticks, of course)
|
|
||||||
_ "github.com/joho/godotenv/autoload"
|
|
||||||
)
|
|
||||||
|
|
||||||
type server struct {
|
|
||||||
appname string
|
|
||||||
version string
|
|
||||||
buildarch string
|
|
||||||
databaseURL string
|
|
||||||
startupTime time.Time
|
|
||||||
port int
|
|
||||||
exitCode int
|
|
||||||
sentryEnabled bool
|
|
||||||
log *zerolog.Logger
|
|
||||||
ctx context.Context
|
|
||||||
cancelFunc context.CancelFunc
|
|
||||||
httpServer *http.Server
|
|
||||||
router *chi.Mux
|
|
||||||
staticFiles *rice.Box
|
|
||||||
templateFiles *rice.Box
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewServer(options ...func(s *server)) *server {
|
|
||||||
n := new(server)
|
|
||||||
n.startupTime = time.Now()
|
|
||||||
n.version = "unknown"
|
|
||||||
for _, opt := range options {
|
|
||||||
opt(n)
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
// this is where we come in from package main.
|
|
||||||
func Run(appname, version, buildarch string) int {
|
|
||||||
s := NewServer(func(i *server) {
|
|
||||||
i.appname = appname
|
|
||||||
if version != "" {
|
|
||||||
i.version = version
|
|
||||||
}
|
|
||||||
i.buildarch = buildarch
|
|
||||||
i.staticFiles = rice.MustFindBox("static")
|
|
||||||
i.templateFiles = rice.MustFindBox("templates")
|
|
||||||
})
|
|
||||||
|
|
||||||
// this does nothing if SENTRY_DSN is unset in env.
|
|
||||||
|
|
||||||
// TODO remove:
|
|
||||||
if s.sentryEnabled {
|
|
||||||
sentry.CaptureMessage("It works!")
|
|
||||||
}
|
|
||||||
|
|
||||||
s.configure()
|
|
||||||
s.setupLogging()
|
|
||||||
|
|
||||||
// logging before sentry, because sentry logs
|
|
||||||
s.enableSentry()
|
|
||||||
|
|
||||||
s.databaseURL = viper.GetString("DBURL")
|
|
||||||
s.port = viper.GetInt("PORT")
|
|
||||||
|
|
||||||
return s.serve()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) enableSentry() {
|
|
||||||
s.sentryEnabled = false
|
|
||||||
|
|
||||||
if viper.GetString("SENTRY_DSN") == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := sentry.Init(sentry.ClientOptions{
|
|
||||||
Dsn: viper.GetString("SENTRY_DSN"),
|
|
||||||
Release: fmt.Sprintf("%s-%s", s.appname, s.version),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal().Err(err).Msg("sentry init failure")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.log.Info().Msg("sentry error reporting activated")
|
|
||||||
s.sentryEnabled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) serve() int {
|
|
||||||
s.ctx, s.cancelFunc = context.WithCancel(context.Background())
|
|
||||||
|
|
||||||
// signal watcher
|
|
||||||
go func() {
|
|
||||||
c := make(chan os.Signal, 1)
|
|
||||||
signal.Ignore(syscall.SIGPIPE)
|
|
||||||
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
|
||||||
// block and wait for signal
|
|
||||||
sig := <-c
|
|
||||||
s.log.Info().Msgf("signal received: %+v", sig)
|
|
||||||
if s.cancelFunc != nil {
|
|
||||||
// cancelling the main context will trigger a clean
|
|
||||||
// shutdown.
|
|
||||||
s.cancelFunc()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
go s.serveUntilShutdown()
|
|
||||||
|
|
||||||
for range s.ctx.Done() {
|
|
||||||
// aforementioned clean shutdown upon main context
|
|
||||||
// cancellation
|
|
||||||
}
|
|
||||||
s.cleanShutdown()
|
|
||||||
return s.exitCode
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) cleanupForExit() {
|
|
||||||
s.log.Info().Msg("cleaning up")
|
|
||||||
// FIXME unimplemented
|
|
||||||
// close database connections or whatever
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) cleanShutdown() {
|
|
||||||
// initiate clean shutdown
|
|
||||||
s.exitCode = 0
|
|
||||||
ctxShutdown, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
if err := s.httpServer.Shutdown(ctxShutdown); err != nil {
|
|
||||||
s.log.Error().
|
|
||||||
Err(err).
|
|
||||||
Msg("server clean shutdown failed")
|
|
||||||
}
|
|
||||||
if shutdownCancel != nil {
|
|
||||||
shutdownCancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
s.cleanupForExit()
|
|
||||||
|
|
||||||
if s.sentryEnabled {
|
|
||||||
sentry.Flush(2 * time.Second)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) uptime() time.Duration {
|
|
||||||
return time.Since(s.startupTime)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) maintenance() bool {
|
|
||||||
return viper.GetBool("MAINTENANCE_MODE")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) configure() {
|
|
||||||
viper.SetConfigName(s.appname)
|
|
||||||
viper.SetConfigType("yaml")
|
|
||||||
// path to look for the config file in:
|
|
||||||
viper.AddConfigPath(fmt.Sprintf("/etc/%s", s.appname))
|
|
||||||
// call multiple times to add many search paths:
|
|
||||||
viper.AddConfigPath(fmt.Sprintf("$HOME/.config/%s", s.appname))
|
|
||||||
// viper.SetEnvPrefix(strings.ToUpper(s.appname))
|
|
||||||
viper.AutomaticEnv()
|
|
||||||
|
|
||||||
viper.SetDefault("DEBUG", "false")
|
|
||||||
viper.SetDefault("MAINTENANCE_MODE", "false")
|
|
||||||
viper.SetDefault("PORT", "8080")
|
|
||||||
viper.SetDefault("DBURL", "")
|
|
||||||
viper.SetDefault("SENTRY_DSN", "")
|
|
||||||
viper.SetDefault("METRICS_USERNAME", "")
|
|
||||||
viper.SetDefault("METRICS_PASSWORD", "")
|
|
||||||
|
|
||||||
if err := viper.ReadInConfig(); err != nil {
|
|
||||||
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
|
|
||||||
// Config file not found; ignore error if desired
|
|
||||||
} else {
|
|
||||||
// Config file was found but another error was produced
|
|
||||||
log.Panic().
|
|
||||||
Err(err).
|
|
||||||
Msg("config file malformed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// if viper.GetBool("DEBUG") {
|
|
||||||
// pp.Print(viper.AllSettings())
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) setupLogging() {
|
|
||||||
// always log in UTC
|
|
||||||
zerolog.TimestampFunc = func() time.Time {
|
|
||||||
return time.Now().UTC()
|
|
||||||
}
|
|
||||||
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
|
||||||
|
|
||||||
tty := false
|
|
||||||
if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 {
|
|
||||||
tty = true
|
|
||||||
}
|
|
||||||
|
|
||||||
var writers []io.Writer
|
|
||||||
|
|
||||||
if tty {
|
|
||||||
// this does cool colorization for console/dev
|
|
||||||
consoleWriter := zerolog.NewConsoleWriter(
|
|
||||||
func(w *zerolog.ConsoleWriter) {
|
|
||||||
// Customize time format
|
|
||||||
w.TimeFormat = time.RFC3339Nano
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
writers = append(writers, consoleWriter)
|
|
||||||
} else {
|
|
||||||
// log json in prod for the machines
|
|
||||||
writers = append(writers, os.Stdout)
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
// this is how you log to a file, if you do that
|
|
||||||
// sort of thing still
|
|
||||||
logfile := viper.GetString("Logfile")
|
|
||||||
if logfile != "" {
|
|
||||||
logfileDir := filepath.Dir(logfile)
|
|
||||||
err := goutil.Mkdirp(logfileDir)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Msg("unable to create log dir")
|
|
||||||
}
|
|
||||||
|
|
||||||
hp.logfh, err = os.OpenFile(logfile, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
|
|
||||||
if err != nil {
|
|
||||||
panic("unable to open logfile: " + err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
writers = append(writers, hp.logfh)
|
|
||||||
*/
|
|
||||||
|
|
||||||
multi := zerolog.MultiLevelWriter(writers...)
|
|
||||||
logger := zerolog.New(multi).With().Timestamp().Logger().With().Caller().Logger()
|
|
||||||
|
|
||||||
s.log = &logger
|
|
||||||
// log.Logger = logger
|
|
||||||
|
|
||||||
if viper.GetBool("debug") {
|
|
||||||
zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
|
||||||
s.log.Debug().Bool("debug", true).Send()
|
|
||||||
}
|
|
||||||
|
|
||||||
s.identify()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) identify() {
|
|
||||||
s.log.Info().
|
|
||||||
Str("appname", s.appname).
|
|
||||||
Str("version", s.version).
|
|
||||||
Str("buildarch", s.buildarch).
|
|
||||||
Msg("starting")
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
body {
|
|
||||||
background: purple;
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>Title</title>
|
|
||||||
<link rel="stylesheet" href="/s/css/style.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Hello, world!</h1>
|
|
||||||
<script src="/s/js/scripts.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
98
internal/config/config.go
Normal file
98
internal/config/config.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/gohttpserver/internal/globals"
|
||||||
|
"git.eeqj.de/sneak/gohttpserver/internal/logger"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
"go.uber.org/fx"
|
||||||
|
|
||||||
|
// spooky action at a distance!
|
||||||
|
// this populates the environment
|
||||||
|
// from a ./.env file automatically
|
||||||
|
// for development configuration.
|
||||||
|
// .env contents should be things like
|
||||||
|
// `DBURL=postgres://user:pass@.../`
|
||||||
|
// (without the backticks, of course)
|
||||||
|
_ "github.com/joho/godotenv/autoload"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ConfigParams struct {
|
||||||
|
fx.In
|
||||||
|
Globals *globals.Globals
|
||||||
|
Logger *logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
DBURL string
|
||||||
|
Debug bool
|
||||||
|
MaintenanceMode bool
|
||||||
|
DevelopmentMode bool
|
||||||
|
DevAdminUsername string
|
||||||
|
DevAdminPassword string
|
||||||
|
MetricsPassword string
|
||||||
|
MetricsUsername string
|
||||||
|
Port int
|
||||||
|
SentryDSN string
|
||||||
|
params *ConfigParams
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
||||||
|
log := params.Logger.Get()
|
||||||
|
name := params.Globals.Appname
|
||||||
|
|
||||||
|
viper.SetConfigName(name)
|
||||||
|
viper.SetConfigType("yaml")
|
||||||
|
// path to look for the config file in:
|
||||||
|
viper.AddConfigPath(fmt.Sprintf("/etc/%s", name))
|
||||||
|
// call multiple times to add many search paths:
|
||||||
|
viper.AddConfigPath(fmt.Sprintf("$HOME/.config/%s", name))
|
||||||
|
// viper.SetEnvPrefix(strings.ToUpper(s.appname))
|
||||||
|
viper.AutomaticEnv()
|
||||||
|
|
||||||
|
viper.SetDefault("DEBUG", "false")
|
||||||
|
viper.SetDefault("MAINTENANCE_MODE", "false")
|
||||||
|
viper.SetDefault("DEVELOPMENT_MODE", "false")
|
||||||
|
viper.SetDefault("DEV_ADMIN_USERNAME", "")
|
||||||
|
viper.SetDefault("DEV_ADMIN_PASSWORD", "")
|
||||||
|
viper.SetDefault("PORT", "8080")
|
||||||
|
viper.SetDefault("DBURL", "file:./data.db?_journal_mode=WAL")
|
||||||
|
viper.SetDefault("SENTRY_DSN", "")
|
||||||
|
viper.SetDefault("METRICS_USERNAME", "")
|
||||||
|
viper.SetDefault("METRICS_PASSWORD", "")
|
||||||
|
|
||||||
|
if err := viper.ReadInConfig(); err != nil {
|
||||||
|
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
|
||||||
|
// Config file not found; ignore error if desired
|
||||||
|
} else {
|
||||||
|
// Config file was found but another error was produced
|
||||||
|
log.Error("config file malformed", "error", err)
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &Config{
|
||||||
|
DBURL: viper.GetString("DBURL"),
|
||||||
|
Debug: viper.GetBool("debug"),
|
||||||
|
Port: viper.GetInt("PORT"),
|
||||||
|
SentryDSN: viper.GetString("SENTRY_DSN"),
|
||||||
|
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
|
||||||
|
DevelopmentMode: viper.GetBool("DEVELOPMENT_MODE"),
|
||||||
|
DevAdminUsername: viper.GetString("DEV_ADMIN_USERNAME"),
|
||||||
|
DevAdminPassword: viper.GetString("DEV_ADMIN_PASSWORD"),
|
||||||
|
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
||||||
|
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
||||||
|
log: log,
|
||||||
|
params: ¶ms,
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.Debug {
|
||||||
|
params.Logger.EnableDebugLogging()
|
||||||
|
s.log = params.Logger.Get()
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
206
internal/database/database.go
Normal file
206
internal/database/database.go
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/gohttpserver/internal/config"
|
||||||
|
"git.eeqj.de/sneak/gohttpserver/internal/logger"
|
||||||
|
"git.eeqj.de/sneak/gohttpserver/internal/models"
|
||||||
|
"go.uber.org/fx"
|
||||||
|
|
||||||
|
// spooky action at a distance!
|
||||||
|
// this populates the environment
|
||||||
|
// from a ./.env file automatically
|
||||||
|
// for development configuration.
|
||||||
|
// .env contents should be things like
|
||||||
|
// `DBURL=postgres://user:pass@.../`
|
||||||
|
// (without the backticks, of course)
|
||||||
|
_ "github.com/joho/godotenv/autoload"
|
||||||
|
|
||||||
|
// pure Go SQLite driver
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DatabaseParams struct {
|
||||||
|
fx.In
|
||||||
|
Logger *logger.Logger
|
||||||
|
Config *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
type Database struct {
|
||||||
|
db *sql.DB
|
||||||
|
log *slog.Logger
|
||||||
|
params *DatabaseParams
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(lc fx.Lifecycle, params DatabaseParams) (*Database, error) {
|
||||||
|
s := new(Database)
|
||||||
|
s.params = ¶ms
|
||||||
|
s.log = params.Logger.Get()
|
||||||
|
|
||||||
|
s.log.Info("Database instantiated")
|
||||||
|
|
||||||
|
lc.Append(fx.Hook{
|
||||||
|
OnStart: func(ctx context.Context) error {
|
||||||
|
s.log.Info("Database OnStart Hook")
|
||||||
|
return s.connect(ctx)
|
||||||
|
},
|
||||||
|
OnStop: func(ctx context.Context) error {
|
||||||
|
s.log.Info("Database OnStop Hook")
|
||||||
|
if s.db != nil {
|
||||||
|
return s.db.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Database) connect(ctx context.Context) error {
|
||||||
|
dbURL := s.params.Config.DBURL
|
||||||
|
if dbURL == "" {
|
||||||
|
dbURL = "file:./data.db?_journal_mode=WAL"
|
||||||
|
}
|
||||||
|
|
||||||
|
s.log.Info("connecting to database", "url", dbURL)
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite", dbURL)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("failed to open database", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.PingContext(ctx); err != nil {
|
||||||
|
s.log.Error("failed to ping database", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.db = db
|
||||||
|
s.log.Info("database connected")
|
||||||
|
|
||||||
|
return s.createSchema(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Database) createSchema(ctx context.Context) error {
|
||||||
|
schema := `
|
||||||
|
CREATE TABLE IF NOT EXISTS posts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
author TEXT NOT NULL,
|
||||||
|
published INTEGER DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`
|
||||||
|
|
||||||
|
_, err := s.db.ExecContext(ctx, schema)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("failed to create schema", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.log.Info("database schema initialized")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Database) CreatePost(ctx context.Context, req *models.CreatePostRequest) (*models.Post, error) {
|
||||||
|
now := time.Now()
|
||||||
|
result, err := s.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO posts (title, body, author, published, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
req.Title, req.Body, req.Author, req.Published, now, now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.GetPost(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Database) GetPost(ctx context.Context, id int64) (*models.Post, error) {
|
||||||
|
post := &models.Post{}
|
||||||
|
err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT id, title, body, author, published, created_at, updated_at FROM posts WHERE id = ?`,
|
||||||
|
id,
|
||||||
|
).Scan(&post.ID, &post.Title, &post.Body, &post.Author, &post.Published, &post.CreatedAt, &post.UpdatedAt)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return post, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Database) ListPosts(ctx context.Context) ([]*models.Post, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx,
|
||||||
|
`SELECT id, title, body, author, published, created_at, updated_at FROM posts ORDER BY created_at DESC`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var posts []*models.Post
|
||||||
|
for rows.Next() {
|
||||||
|
post := &models.Post{}
|
||||||
|
if err := rows.Scan(&post.ID, &post.Title, &post.Body, &post.Author, &post.Published, &post.CreatedAt, &post.UpdatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
posts = append(posts, post)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return posts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Database) UpdatePost(ctx context.Context, id int64, req *models.UpdatePostRequest) (*models.Post, error) {
|
||||||
|
post, err := s.GetPost(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if post == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Title != nil {
|
||||||
|
post.Title = *req.Title
|
||||||
|
}
|
||||||
|
if req.Body != nil {
|
||||||
|
post.Body = *req.Body
|
||||||
|
}
|
||||||
|
if req.Author != nil {
|
||||||
|
post.Author = *req.Author
|
||||||
|
}
|
||||||
|
if req.Published != nil {
|
||||||
|
post.Published = *req.Published
|
||||||
|
}
|
||||||
|
post.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
_, err = s.db.ExecContext(ctx,
|
||||||
|
`UPDATE posts SET title = ?, body = ?, author = ?, published = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
post.Title, post.Body, post.Author, post.Published, post.UpdatedAt, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return post, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Database) DeletePost(ctx context.Context, id int64) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `DELETE FROM posts WHERE id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
24
internal/globals/globals.go
Normal file
24
internal/globals/globals.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package globals
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go.uber.org/fx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// these get populated from main() and copied into the Globals object.
|
||||||
|
var (
|
||||||
|
Appname string
|
||||||
|
Version string
|
||||||
|
)
|
||||||
|
|
||||||
|
type Globals struct {
|
||||||
|
Appname string
|
||||||
|
Version string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(lc fx.Lifecycle) (*Globals, error) {
|
||||||
|
n := &Globals{
|
||||||
|
Appname: Appname,
|
||||||
|
Version: Version,
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
57
internal/handlers/handlers.go
Normal file
57
internal/handlers/handlers.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/gohttpserver/internal/database"
|
||||||
|
"git.eeqj.de/sneak/gohttpserver/internal/globals"
|
||||||
|
"git.eeqj.de/sneak/gohttpserver/internal/healthcheck"
|
||||||
|
"git.eeqj.de/sneak/gohttpserver/internal/logger"
|
||||||
|
"go.uber.org/fx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HandlersParams struct {
|
||||||
|
fx.In
|
||||||
|
Logger *logger.Logger
|
||||||
|
Globals *globals.Globals
|
||||||
|
Database *database.Database
|
||||||
|
Healthcheck *healthcheck.Healthcheck
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handlers struct {
|
||||||
|
params *HandlersParams
|
||||||
|
log *slog.Logger
|
||||||
|
hc *healthcheck.Healthcheck
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) {
|
||||||
|
s := new(Handlers)
|
||||||
|
s.params = ¶ms
|
||||||
|
s.log = params.Logger.Get()
|
||||||
|
s.hc = params.Healthcheck
|
||||||
|
lc.Append(fx.Hook{
|
||||||
|
OnStart: func(ctx context.Context) error {
|
||||||
|
// FIXME compile some templates here or something
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Handlers) respondJSON(w http.ResponseWriter, r *http.Request, data interface{}, status int) {
|
||||||
|
w.WriteHeader(status)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if data != nil {
|
||||||
|
err := json.NewEncoder(w).Encode(data)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("json encode error", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Handlers) decodeJSON(w http.ResponseWriter, r *http.Request, v interface{}) error { // nolint
|
||||||
|
return json.NewDecoder(r.Body).Decode(v)
|
||||||
|
}
|
||||||
12
internal/handlers/healthcheck.go
Normal file
12
internal/handlers/healthcheck.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Handlers) HandleHealthCheck() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
resp := s.hc.Healthcheck()
|
||||||
|
s.respondJSON(w, req, resp, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
19
internal/handlers/index.go
Normal file
19
internal/handlers/index.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/gohttpserver/templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Handlers) HandleIndex() http.HandlerFunc {
|
||||||
|
t := templates.GetParsed()
|
||||||
|
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := t.ExecuteTemplate(w, "index.html", nil)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("template execution failed", "error", err)
|
||||||
|
http.Error(w, http.StatusText(500), 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
internal/handlers/login.go
Normal file
19
internal/handlers/login.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/gohttpserver/templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Handlers) HandleLoginGET() http.HandlerFunc {
|
||||||
|
t := templates.GetParsed()
|
||||||
|
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := t.ExecuteTemplate(w, "login.html", nil)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("template execution failed", "error", err)
|
||||||
|
http.Error(w, http.StatusText(500), 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
package httpserver
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *server) handleNow() http.HandlerFunc {
|
func (s *Handlers) HandleNow() http.HandlerFunc {
|
||||||
type response struct {
|
type response struct {
|
||||||
Now time.Time `json:"now"`
|
Now time.Time `json:"now"`
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package httpserver
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
// CHANGEME you probably want to remove this,
|
// CHANGEME you probably want to remove this,
|
||||||
// this is just a handler/route that throws a panic to test
|
// this is just a handler/route that throws a panic to test
|
||||||
// sentry events.
|
// sentry events.
|
||||||
func (s *server) handlePanic() http.HandlerFunc {
|
func (s *Handlers) HandlePanic() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
panic("y tho")
|
panic("y tho")
|
||||||
}
|
}
|
||||||
137
internal/handlers/posts.go
Normal file
137
internal/handlers/posts.go
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/gohttpserver/internal/models"
|
||||||
|
"github.com/go-chi/chi"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Handlers) HandleListPosts() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
posts, err := s.params.Database.ListPosts(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("failed to list posts", "error", err)
|
||||||
|
s.respondJSON(w, r, map[string]string{"error": "internal server error"}, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if posts == nil {
|
||||||
|
posts = []*models.Post{}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.respondJSON(w, r, posts, http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Handlers) HandleGetPost() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idStr := chi.URLParam(r, "id")
|
||||||
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
s.respondJSON(w, r, map[string]string{"error": "invalid id"}, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
post, err := s.params.Database.GetPost(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("failed to get post", "error", err, "id", id)
|
||||||
|
s.respondJSON(w, r, map[string]string{"error": "internal server error"}, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if post == nil {
|
||||||
|
s.respondJSON(w, r, map[string]string{"error": "not found"}, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.respondJSON(w, r, post, http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Handlers) HandleCreatePost() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req models.CreatePostRequest
|
||||||
|
if err := s.decodeJSON(w, r, &req); err != nil {
|
||||||
|
s.respondJSON(w, r, map[string]string{"error": "invalid request body"}, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Title == "" || req.Body == "" || req.Author == "" {
|
||||||
|
s.respondJSON(w, r, map[string]string{"error": "title, body, and author are required"}, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
post, err := s.params.Database.CreatePost(r.Context(), &req)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("failed to create post", "error", err)
|
||||||
|
s.respondJSON(w, r, map[string]string{"error": "internal server error"}, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.respondJSON(w, r, post, http.StatusCreated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Handlers) HandleUpdatePost() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idStr := chi.URLParam(r, "id")
|
||||||
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
s.respondJSON(w, r, map[string]string{"error": "invalid id"}, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req models.UpdatePostRequest
|
||||||
|
if err := s.decodeJSON(w, r, &req); err != nil {
|
||||||
|
s.respondJSON(w, r, map[string]string{"error": "invalid request body"}, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
post, err := s.params.Database.UpdatePost(r.Context(), id, &req)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("failed to update post", "error", err, "id", id)
|
||||||
|
s.respondJSON(w, r, map[string]string{"error": "internal server error"}, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if post == nil {
|
||||||
|
s.respondJSON(w, r, map[string]string{"error": "not found"}, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.respondJSON(w, r, post, http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Handlers) HandleDeletePost() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idStr := chi.URLParam(r, "id")
|
||||||
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
s.respondJSON(w, r, map[string]string{"error": "invalid id"}, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
post, err := s.params.Database.GetPost(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("failed to get post for delete", "error", err, "id", id)
|
||||||
|
s.respondJSON(w, r, map[string]string{"error": "internal server error"}, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if post == nil {
|
||||||
|
s.respondJSON(w, r, map[string]string{"error": "not found"}, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.params.Database.DeletePost(r.Context(), id); err != nil {
|
||||||
|
s.log.Error("failed to delete post", "error", err, "id", id)
|
||||||
|
s.respondJSON(w, r, map[string]string{"error": "internal server error"}, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.respondJSON(w, r, nil, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
34
internal/handlers/signup.go
Normal file
34
internal/handlers/signup.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/gohttpserver/templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Handlers) HandleSignupGET() http.HandlerFunc {
|
||||||
|
t := templates.GetParsed()
|
||||||
|
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := t.ExecuteTemplate(w, "signup.html", nil)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("template execution failed", "error", err)
|
||||||
|
http.Error(w, http.StatusText(500), 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Handlers) HandleSignupPOST() http.HandlerFunc {
|
||||||
|
t := templates.GetParsed()
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
_ = r.ParseForm()
|
||||||
|
|
||||||
|
err := t.ExecuteTemplate(w, "signup.html", nil)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("template execution failed", "error", err)
|
||||||
|
http.Error(w, http.StatusText(500), 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
71
internal/healthcheck/healthcheck.go
Normal file
71
internal/healthcheck/healthcheck.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package healthcheck
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/gohttpserver/internal/config"
|
||||||
|
"git.eeqj.de/sneak/gohttpserver/internal/database"
|
||||||
|
"git.eeqj.de/sneak/gohttpserver/internal/globals"
|
||||||
|
"git.eeqj.de/sneak/gohttpserver/internal/logger"
|
||||||
|
"go.uber.org/fx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HealthcheckParams struct {
|
||||||
|
fx.In
|
||||||
|
Globals *globals.Globals
|
||||||
|
Config *config.Config
|
||||||
|
Logger *logger.Logger
|
||||||
|
Database *database.Database
|
||||||
|
}
|
||||||
|
|
||||||
|
type Healthcheck struct {
|
||||||
|
StartupTime time.Time
|
||||||
|
log *slog.Logger
|
||||||
|
params *HealthcheckParams
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(lc fx.Lifecycle, params HealthcheckParams) (*Healthcheck, error) {
|
||||||
|
s := new(Healthcheck)
|
||||||
|
s.params = ¶ms
|
||||||
|
s.log = params.Logger.Get()
|
||||||
|
|
||||||
|
lc.Append(fx.Hook{
|
||||||
|
OnStart: func(ctx context.Context) error {
|
||||||
|
s.StartupTime = time.Now()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
OnStop: func(ctx context.Context) error {
|
||||||
|
// FIXME do server shutdown here
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type HealthcheckResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Now string `json:"now"`
|
||||||
|
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||||
|
UptimeHuman string `json:"uptime_human"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Appname string `json:"appname"`
|
||||||
|
Maintenance bool `json:"maintenance_mode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Healthcheck) uptime() time.Duration {
|
||||||
|
return time.Since(s.StartupTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Healthcheck) Healthcheck() *HealthcheckResponse {
|
||||||
|
resp := &HealthcheckResponse{
|
||||||
|
Status: "ok",
|
||||||
|
Now: time.Now().UTC().Format(time.RFC3339Nano),
|
||||||
|
UptimeSeconds: int64(s.uptime().Seconds()),
|
||||||
|
UptimeHuman: s.uptime().String(),
|
||||||
|
Appname: s.params.Globals.Appname,
|
||||||
|
Version: s.params.Globals.Version,
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
68
internal/logger/logger.go
Normal file
68
internal/logger/logger.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package logger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/gohttpserver/internal/globals"
|
||||||
|
"go.uber.org/fx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LoggerParams struct {
|
||||||
|
fx.In
|
||||||
|
Globals *globals.Globals
|
||||||
|
}
|
||||||
|
|
||||||
|
type Logger struct {
|
||||||
|
log *slog.Logger
|
||||||
|
level *slog.LevelVar
|
||||||
|
params LoggerParams
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(lc fx.Lifecycle, params LoggerParams) (*Logger, error) {
|
||||||
|
l := new(Logger)
|
||||||
|
l.level = new(slog.LevelVar)
|
||||||
|
l.level.Set(slog.LevelInfo)
|
||||||
|
|
||||||
|
// TTY detection for dev vs prod output
|
||||||
|
tty := false
|
||||||
|
if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 {
|
||||||
|
tty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var handler slog.Handler
|
||||||
|
if tty {
|
||||||
|
// Text output for development
|
||||||
|
handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||||
|
Level: l.level,
|
||||||
|
AddSource: true,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// JSON output for production
|
||||||
|
handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||||
|
Level: l.level,
|
||||||
|
AddSource: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
l.log = slog.New(handler)
|
||||||
|
l.params = params
|
||||||
|
|
||||||
|
return l, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) EnableDebugLogging() {
|
||||||
|
l.level.Set(slog.LevelDebug)
|
||||||
|
l.log.Debug("debug logging enabled", "debug", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) Get() *slog.Logger {
|
||||||
|
return l.log
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) Identify() {
|
||||||
|
l.log.Info("starting",
|
||||||
|
"appname", l.params.Globals.Appname,
|
||||||
|
"version", l.params.Globals.Version,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
package httpserver
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/gohttpserver/internal/config"
|
||||||
|
"git.eeqj.de/sneak/gohttpserver/internal/globals"
|
||||||
|
"git.eeqj.de/sneak/gohttpserver/internal/logger"
|
||||||
basicauth "github.com/99designs/basicauth-go"
|
basicauth "github.com/99designs/basicauth-go"
|
||||||
"github.com/go-chi/chi/middleware"
|
"github.com/go-chi/chi/middleware"
|
||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
@@ -12,10 +16,27 @@ import (
|
|||||||
ghmm "github.com/slok/go-http-metrics/middleware"
|
ghmm "github.com/slok/go-http-metrics/middleware"
|
||||||
"github.com/slok/go-http-metrics/middleware/std"
|
"github.com/slok/go-http-metrics/middleware/std"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
// the following is from
|
type MiddlewareParams struct {
|
||||||
// https://learning-cloud-native-go.github.io/docs/a6.adding_zerolog_logger/
|
fx.In
|
||||||
|
Logger *logger.Logger
|
||||||
|
Globals *globals.Globals
|
||||||
|
Config *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
type Middleware struct {
|
||||||
|
log *slog.Logger
|
||||||
|
params *MiddlewareParams
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(lc fx.Lifecycle, params MiddlewareParams) (*Middleware, error) {
|
||||||
|
s := new(Middleware)
|
||||||
|
s.params = ¶ms
|
||||||
|
s.log = params.Logger.Get()
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
func ipFromHostPort(hp string) string {
|
func ipFromHostPort(hp string) string {
|
||||||
h, _, err := net.SplitHostPort(hp)
|
h, _, err := net.SplitHostPort(hp)
|
||||||
@@ -45,8 +66,7 @@ func (lrw *loggingResponseWriter) WriteHeader(code int) {
|
|||||||
// type Middleware func(http.Handler) http.Handler
|
// type Middleware func(http.Handler) http.Handler
|
||||||
// this returns a Middleware that is designed to do every request through the
|
// this returns a Middleware that is designed to do every request through the
|
||||||
// mux, note the signature:
|
// mux, note the signature:
|
||||||
func (s *server) LoggingMiddleware() func(http.Handler) http.Handler {
|
func (s *Middleware) Logging() func(http.Handler) http.Handler {
|
||||||
// FIXME this should use https://github.com/google/go-cloud/blob/master/server/requestlog/requestlog.go
|
|
||||||
return func(next http.Handler) http.Handler {
|
return func(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) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
@@ -54,18 +74,18 @@ func (s *server) LoggingMiddleware() func(http.Handler) http.Handler {
|
|||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
defer func() {
|
defer func() {
|
||||||
latency := time.Since(start)
|
latency := time.Since(start)
|
||||||
s.log.Info().
|
s.log.InfoContext(ctx, "request",
|
||||||
Time("request_start", start).
|
"request_start", start,
|
||||||
Str("method", r.Method).
|
"method", r.Method,
|
||||||
Str("url", r.URL.String()).
|
"url", r.URL.String(),
|
||||||
Str("useragent", r.UserAgent()).
|
"useragent", r.UserAgent(),
|
||||||
Str("request_id", ctx.Value(middleware.RequestIDKey).(string)).
|
"request_id", ctx.Value(middleware.RequestIDKey).(string),
|
||||||
Str("referer", r.Referer()).
|
"referer", r.Referer(),
|
||||||
Str("proto", r.Proto).
|
"proto", r.Proto,
|
||||||
Str("remoteIP", ipFromHostPort(r.RemoteAddr)).
|
"remoteIP", ipFromHostPort(r.RemoteAddr),
|
||||||
Int("status", lrw.statusCode).
|
"status", lrw.statusCode,
|
||||||
Int("latency_ms", int(latency.Milliseconds())).
|
"latency_ms", latency.Milliseconds(),
|
||||||
Send()
|
)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
next.ServeHTTP(lrw, r)
|
next.ServeHTTP(lrw, r)
|
||||||
@@ -73,7 +93,7 @@ func (s *server) LoggingMiddleware() func(http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) CORSMiddleware() func(http.Handler) http.Handler {
|
func (s *Middleware) CORS() func(http.Handler) http.Handler {
|
||||||
return cors.Handler(cors.Options{
|
return cors.Handler(cors.Options{
|
||||||
// CHANGEME! these are defaults, change them to suit your needs or
|
// CHANGEME! these are defaults, change them to suit your needs or
|
||||||
// read from environment/viper.
|
// read from environment/viper.
|
||||||
@@ -88,17 +108,17 @@ func (s *server) CORSMiddleware() func(http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) AuthMiddleware() func(http.Handler) http.Handler {
|
func (s *Middleware) Auth() func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(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) {
|
||||||
// CHANGEME you'll want to change this to do stuff.
|
// CHANGEME you'll want to change this to do stuff.
|
||||||
s.log.Info().Msg("AUTH: before request")
|
s.log.Info("AUTH: before request")
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) MetricsMiddleware() func(http.Handler) http.Handler {
|
func (s *Middleware) Metrics() func(http.Handler) http.Handler {
|
||||||
mdlw := ghmm.New(ghmm.Config{
|
mdlw := ghmm.New(ghmm.Config{
|
||||||
Recorder: metrics.NewRecorder(metrics.Config{}),
|
Recorder: metrics.NewRecorder(metrics.Config{}),
|
||||||
})
|
})
|
||||||
@@ -107,7 +127,7 @@ func (s *server) MetricsMiddleware() func(http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) MetricsAuthMiddleware() func(http.Handler) http.Handler {
|
func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
||||||
return basicauth.New(
|
return basicauth.New(
|
||||||
"metrics",
|
"metrics",
|
||||||
map[string][]string{
|
map[string][]string{
|
||||||
29
internal/models/models.go
Normal file
29
internal/models/models.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Post struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Published bool `json:"published"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreatePostRequest struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Published bool `json:"published"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdatePostRequest struct {
|
||||||
|
Title *string `json:"title,omitempty"`
|
||||||
|
Body *string `json:"body,omitempty"`
|
||||||
|
Author *string `json:"author,omitempty"`
|
||||||
|
Published *bool `json:"published,omitempty"`
|
||||||
|
}
|
||||||
34
internal/server/http.go
Normal file
34
internal/server/http.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) serveUntilShutdown() {
|
||||||
|
listenAddr := fmt.Sprintf(":%d", s.params.Config.Port)
|
||||||
|
s.httpServer = &http.Server{
|
||||||
|
Addr: listenAddr,
|
||||||
|
ReadTimeout: 10 * time.Second,
|
||||||
|
WriteTimeout: 10 * time.Second,
|
||||||
|
MaxHeaderBytes: 1 << 20,
|
||||||
|
Handler: s,
|
||||||
|
}
|
||||||
|
|
||||||
|
// add routes
|
||||||
|
// this does any necessary setup in each handler
|
||||||
|
s.SetupRoutes()
|
||||||
|
|
||||||
|
s.log.Info("http begin listen", "listenaddr", listenAddr)
|
||||||
|
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
s.log.Error("listen error", "error", err)
|
||||||
|
if s.cancelFunc != nil {
|
||||||
|
s.cancelFunc()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.router.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
package httpserver
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/gohttpserver/static"
|
||||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
"github.com/go-chi/chi/middleware"
|
"github.com/go-chi/chi/middleware"
|
||||||
@@ -11,7 +12,7 @@ import (
|
|||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *server) routes() {
|
func (s *Server) SetupRoutes() {
|
||||||
s.router = chi.NewRouter()
|
s.router = chi.NewRouter()
|
||||||
|
|
||||||
// the mux .Use() takes a http.Handler wrapper func, like most
|
// the mux .Use() takes a http.Handler wrapper func, like most
|
||||||
@@ -22,16 +23,16 @@ func (s *server) routes() {
|
|||||||
|
|
||||||
s.router.Use(middleware.Recoverer)
|
s.router.Use(middleware.Recoverer)
|
||||||
s.router.Use(middleware.RequestID)
|
s.router.Use(middleware.RequestID)
|
||||||
s.router.Use(s.LoggingMiddleware())
|
s.router.Use(s.mw.Logging())
|
||||||
|
|
||||||
// add metrics middleware only if we can serve them behind auth
|
// add metrics middleware only if we can serve them behind auth
|
||||||
if viper.GetString("METRICS_USERNAME") != "" {
|
if viper.GetString("METRICS_USERNAME") != "" {
|
||||||
s.router.Use(s.MetricsMiddleware())
|
s.router.Use(s.mw.Metrics())
|
||||||
}
|
}
|
||||||
|
|
||||||
// set up CORS headers. you'll probably want to configure that
|
// set up CORS headers. you'll probably want to configure that
|
||||||
// in middlewares.go.
|
// in middlewares.go.
|
||||||
s.router.Use(s.CORSMiddleware())
|
s.router.Use(s.mw.CORS())
|
||||||
|
|
||||||
// CHANGEME to suit your needs, or pull from config.
|
// CHANGEME to suit your needs, or pull from config.
|
||||||
// timeout for request context; your handlers must finish within
|
// timeout for request context; your handlers must finish within
|
||||||
@@ -56,39 +57,55 @@ func (s *server) routes() {
|
|||||||
// complete docs: https://github.com/go-chi/chi
|
// complete docs: https://github.com/go-chi/chi
|
||||||
////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
s.router.Get("/", s.handleIndex())
|
s.router.Get("/", s.h.HandleIndex())
|
||||||
|
|
||||||
s.router.Mount("/s", http.StripPrefix("/s", http.FileServer(s.staticFiles.HTTPBox())))
|
s.router.Mount("/s", http.StripPrefix("/s", http.FileServer(http.FS(static.Static))))
|
||||||
|
|
||||||
s.router.Route("/api/v1", func(r chi.Router) {
|
s.router.Route("/api/v1", func(r chi.Router) {
|
||||||
r.Get("/now", s.handleNow())
|
r.Get("/now", s.h.HandleNow())
|
||||||
|
|
||||||
|
// Posts CRUD
|
||||||
|
r.Get("/posts", s.h.HandleListPosts())
|
||||||
|
r.Post("/posts", s.h.HandleCreatePost())
|
||||||
|
r.Get("/posts/{id}", s.h.HandleGetPost())
|
||||||
|
r.Put("/posts/{id}", s.h.HandleUpdatePost())
|
||||||
|
r.Delete("/posts/{id}", s.h.HandleDeletePost())
|
||||||
})
|
})
|
||||||
|
|
||||||
// if you want to use a general purpose middleware (http.Handler
|
// if you want to use a general purpose middleware (http.Handler
|
||||||
// wrapper) on a specific HandleFunc route, you need to take the
|
// wrapper) on a specific HandleFunc route, you need to take the
|
||||||
// .ServeHTTP of the http.Handler to get its HandleFunc, viz:
|
// .ServeHTTP of the http.Handler to get its HandleFunc, viz:
|
||||||
authMiddleware := s.AuthMiddleware()
|
auth := s.mw.Auth()
|
||||||
s.router.Get(
|
s.router.Get(
|
||||||
"/login",
|
"/login",
|
||||||
authMiddleware(s.handleLogin()).ServeHTTP,
|
auth(s.h.HandleLoginGET()).ServeHTTP,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
s.router.Get(
|
||||||
|
"/signup",
|
||||||
|
auth(s.h.HandleSignupGET()).ServeHTTP,
|
||||||
|
)
|
||||||
|
|
||||||
|
s.router.Post(
|
||||||
|
"/signup",
|
||||||
|
auth(s.h.HandleSignupPOST()).ServeHTTP,
|
||||||
|
)
|
||||||
// route that panics for testing
|
// route that panics for testing
|
||||||
// CHANGEME remove this
|
// CHANGEME remove this
|
||||||
s.router.Get(
|
s.router.Get(
|
||||||
"/panic",
|
"/panic",
|
||||||
s.handlePanic(),
|
s.h.HandlePanic(),
|
||||||
)
|
)
|
||||||
|
|
||||||
s.router.Get(
|
s.router.Get(
|
||||||
"/.well-known/healthcheck.json",
|
"/.well-known/healthcheck.json",
|
||||||
s.handleHealthCheck(),
|
s.h.HandleHealthCheck(),
|
||||||
)
|
)
|
||||||
|
|
||||||
// set up authenticated /metrics route:
|
// set up authenticated /metrics route:
|
||||||
if viper.GetString("METRICS_USERNAME") != "" {
|
if viper.GetString("METRICS_USERNAME") != "" {
|
||||||
s.router.Group(func(r chi.Router) {
|
s.router.Group(func(r chi.Router) {
|
||||||
r.Use(s.MetricsAuthMiddleware())
|
r.Use(s.mw.MetricsAuth())
|
||||||
r.Get("/metrics", http.HandlerFunc(promhttp.Handler().ServeHTTP))
|
r.Get("/metrics", http.HandlerFunc(promhttp.Handler().ServeHTTP))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
175
internal/server/server.go
Normal file
175
internal/server/server.go
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/gohttpserver/internal/config"
|
||||||
|
"git.eeqj.de/sneak/gohttpserver/internal/globals"
|
||||||
|
"git.eeqj.de/sneak/gohttpserver/internal/handlers"
|
||||||
|
"git.eeqj.de/sneak/gohttpserver/internal/logger"
|
||||||
|
"git.eeqj.de/sneak/gohttpserver/internal/middleware"
|
||||||
|
"go.uber.org/fx"
|
||||||
|
|
||||||
|
"github.com/getsentry/sentry-go"
|
||||||
|
"github.com/go-chi/chi"
|
||||||
|
|
||||||
|
// spooky action at a distance!
|
||||||
|
// this populates the environment
|
||||||
|
// from a ./.env file automatically
|
||||||
|
// for development configuration.
|
||||||
|
// .env contents should be things like
|
||||||
|
// `DBURL=postgres://user:pass@.../`
|
||||||
|
// (without the backticks, of course)
|
||||||
|
_ "github.com/joho/godotenv/autoload"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServerParams struct {
|
||||||
|
fx.In
|
||||||
|
Logger *logger.Logger
|
||||||
|
Globals *globals.Globals
|
||||||
|
Config *config.Config
|
||||||
|
Middleware *middleware.Middleware
|
||||||
|
Handlers *handlers.Handlers
|
||||||
|
}
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
startupTime time.Time
|
||||||
|
exitCode int
|
||||||
|
sentryEnabled bool
|
||||||
|
log *slog.Logger
|
||||||
|
ctx context.Context
|
||||||
|
cancelFunc context.CancelFunc
|
||||||
|
httpServer *http.Server
|
||||||
|
router *chi.Mux
|
||||||
|
params ServerParams
|
||||||
|
mw *middleware.Middleware
|
||||||
|
h *handlers.Handlers
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(lc fx.Lifecycle, params ServerParams) (*Server, error) {
|
||||||
|
s := new(Server)
|
||||||
|
s.params = params
|
||||||
|
s.mw = params.Middleware
|
||||||
|
s.h = params.Handlers
|
||||||
|
s.log = params.Logger.Get()
|
||||||
|
|
||||||
|
lc.Append(fx.Hook{
|
||||||
|
OnStart: func(ctx context.Context) error {
|
||||||
|
s.startupTime = time.Now()
|
||||||
|
go s.Run() // background FIXME
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
OnStop: func(ctx context.Context) error {
|
||||||
|
// FIXME do server shutdown here
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME change this to use uber/fx DI and an Invoke()
|
||||||
|
// this is where we come in from package main.
|
||||||
|
func (s *Server) Run() {
|
||||||
|
// this does nothing if SENTRY_DSN is unset in env.
|
||||||
|
// TODO remove:
|
||||||
|
if s.sentryEnabled {
|
||||||
|
sentry.CaptureMessage("It works!")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.configure()
|
||||||
|
|
||||||
|
// logging before sentry, because sentry logs
|
||||||
|
s.enableSentry()
|
||||||
|
|
||||||
|
s.serve() // FIXME deal with return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) enableSentry() {
|
||||||
|
s.sentryEnabled = false
|
||||||
|
|
||||||
|
if s.params.Config.SentryDSN == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := sentry.Init(sentry.ClientOptions{
|
||||||
|
Dsn: s.params.Config.SentryDSN,
|
||||||
|
Release: fmt.Sprintf("%s-%s", s.params.Globals.Appname, s.params.Globals.Version),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("sentry init failure", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
s.log.Info("sentry error reporting activated")
|
||||||
|
s.sentryEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) serve() int {
|
||||||
|
// FIXME fx will handle this for us
|
||||||
|
s.ctx, s.cancelFunc = context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
// signal watcher
|
||||||
|
go func() {
|
||||||
|
c := make(chan os.Signal, 1)
|
||||||
|
signal.Ignore(syscall.SIGPIPE)
|
||||||
|
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
||||||
|
// block and wait for signal
|
||||||
|
sig := <-c
|
||||||
|
s.log.Info("signal received", "signal", sig)
|
||||||
|
if s.cancelFunc != nil {
|
||||||
|
// cancelling the main context will trigger a clean
|
||||||
|
// shutdown.
|
||||||
|
s.cancelFunc()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
go s.serveUntilShutdown()
|
||||||
|
|
||||||
|
for range s.ctx.Done() {
|
||||||
|
// aforementioned clean shutdown upon main context
|
||||||
|
// cancellation
|
||||||
|
}
|
||||||
|
s.cleanShutdown()
|
||||||
|
return s.exitCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) cleanupForExit() {
|
||||||
|
s.log.Info("cleaning up")
|
||||||
|
// FIXME unimplemented
|
||||||
|
// close database connections or whatever
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) cleanShutdown() {
|
||||||
|
// initiate clean shutdown
|
||||||
|
s.exitCode = 0
|
||||||
|
ctxShutdown, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
if err := s.httpServer.Shutdown(ctxShutdown); err != nil {
|
||||||
|
s.log.Error("server clean shutdown failed", "error", err)
|
||||||
|
}
|
||||||
|
if shutdownCancel != nil {
|
||||||
|
shutdownCancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
s.cleanupForExit()
|
||||||
|
|
||||||
|
if s.sentryEnabled {
|
||||||
|
sentry.Flush(2 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) MaintenanceMode() bool {
|
||||||
|
return s.params.Config.MaintenanceMode
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) configure() {
|
||||||
|
// FIXME move most of this to dedicated places
|
||||||
|
// if viper.GetBool("DEBUG") {
|
||||||
|
// pp.Print(viper.AllSettings())
|
||||||
|
// }
|
||||||
|
}
|
||||||
7
static/css/bootstrap-4.5.3.min.css
vendored
Normal file
7
static/css/bootstrap-4.5.3.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
41
static/css/style.css
Normal file
41
static/css/style.css
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
body {
|
||||||
|
padding-top: 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bd-placeholder-img {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
text-anchor: middle;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.bd-placeholder-img-lg {
|
||||||
|
font-size: 3.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
width: 340px;
|
||||||
|
margin: 50px auto;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.login-form form {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
background: #f7f7f7;
|
||||||
|
box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
.login-form h2 {
|
||||||
|
margin: 0 0 15px;
|
||||||
|
}
|
||||||
|
.login-form .form-control, .btn {
|
||||||
|
min-height: 38px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.login-form .btn {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
7
static/js/bootstrap-4.5.3.bundle.min.js
vendored
Normal file
7
static/js/bootstrap-4.5.3.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
static/js/jquery-3.5.1.slim.min.js
vendored
Normal file
2
static/js/jquery-3.5.1.slim.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
6
static/static.go
Normal file
6
static/static.go
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package static
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed css js
|
||||||
|
var Static embed.FS
|
||||||
3
templates/htmlfooter.html
Normal file
3
templates/htmlfooter.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<script src="/s/js/jquery-3.5.1.slim.min.js"></script>
|
||||||
|
<script src="/s/js/bootstrap-4.5.3.bundle.min.js"></script>
|
||||||
|
<script src="/s/js/main.js"></script>
|
||||||
4
templates/htmlheader.html
Normal file
4
templates/htmlheader.html
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>{{ .HTMLTitle }}</title>
|
||||||
|
<link rel="stylesheet" href="/s/css/bootstrap-4.5.3.min.css" />
|
||||||
|
<link rel="stylesheet" href="/s/css/style.css" />
|
||||||
81
templates/index.html
Normal file
81
templates/index.html
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
{{ template "htmlheader.html" . }}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{ template "navbar.html" .}}
|
||||||
|
<main role="main">
|
||||||
|
<div class="jumbotron">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="display-3">Hello, world!</h1>
|
||||||
|
<h2><a
|
||||||
|
href="https://git.eeqj.de/sneak/gohttpserver">gohttpserver</a></h2>
|
||||||
|
<p>
|
||||||
|
This is a boilerplate application for you to use as a base for your
|
||||||
|
own sites and services.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Find more info at <a
|
||||||
|
href="https://git.eeqj.de/sneak/gohttpserver">https://git.eeqj.de/sneak/gohttpserver</a>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
This software is provided by <a
|
||||||
|
href="https://sneak.berlin">@sneak</a>
|
||||||
|
and is released unconditionally into the public domain.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a class="btn btn-primary btn-lg" href="#" role="button"
|
||||||
|
>Learn more »</a
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<!-- Example row of columns -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<h2>Batteries Included</h2>
|
||||||
|
<p>
|
||||||
|
This includes a router, bundling of static assets, and example
|
||||||
|
handlers.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a class="btn btn-secondary" href="#" role="button"
|
||||||
|
>View details »</a
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<h2>Best Practices</h2>
|
||||||
|
<p>
|
||||||
|
This template repo follows best practices from experienced
|
||||||
|
application developers.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a class="btn btn-secondary" href="#" role="button"
|
||||||
|
>View details »</a
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<h2>Easy To Use</h2>
|
||||||
|
<p>This repo builds under Docker and is ready to go.</p>
|
||||||
|
<p>
|
||||||
|
<a class="btn btn-secondary" href="#" role="button"
|
||||||
|
>View details »</a
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
</div>
|
||||||
|
<!-- /container -->
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{{ template "pagefooter.html" . }}
|
||||||
|
</body>
|
||||||
|
{{ template "htmlfooter.html" . }}
|
||||||
|
</html>
|
||||||
56
templates/login.html
Normal file
56
templates/login.html
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
{{ template "htmlheader.html" . }}
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
padding-top: 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bd-placeholder-img {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
text-anchor: middle;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.bd-placeholder-img-lg {
|
||||||
|
font-size: 3.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
{{ template "navbar.html" .}}
|
||||||
|
|
||||||
|
<main role="main">
|
||||||
|
<div class="login-form">
|
||||||
|
<form action="/login" method="post">
|
||||||
|
<h2 class="text-center">Log in</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="text" class="form-control" placeholder="Username" required="required">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="password" class="form-control" placeholder="Password" required="required">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<button type="submit" class="btn btn-primary btn-block">Log in</button>
|
||||||
|
</div>
|
||||||
|
<div class="clearfix">
|
||||||
|
<label class="float-left form-check-label"><input type="checkbox"> Remember me</label>
|
||||||
|
<a href="#" class="float-right">Forgot Password?</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p class="text-center"><a href="#">Create an Account</a></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{{ template "pagefooter.html" . }}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
64
templates/navbar.html
Normal file
64
templates/navbar.html
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
|
||||||
|
<a class="navbar-brand" href="/">{{.SiteName}}</a>
|
||||||
|
<button
|
||||||
|
class="navbar-toggler"
|
||||||
|
type="button"
|
||||||
|
data-toggle="collapse"
|
||||||
|
data-target="#navbarsExampleDefault"
|
||||||
|
aria-controls="navbarsExampleDefault"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-label="Toggle navigation"
|
||||||
|
>
|
||||||
|
<span class="navbar-toggler-icon"> </span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="collapse navbar-collapse" id="navbarsExampleDefault">
|
||||||
|
<ul class="navbar-nav mr-auto">
|
||||||
|
<li class="nav-item active">
|
||||||
|
<a class="nav-link" href="#"
|
||||||
|
>Home <span class="sr-only">(current)</span></a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="#">Link</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link disabled" href="#" aria-disabled="true"
|
||||||
|
>Disabled</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a
|
||||||
|
class="nav-link dropdown-toggle"
|
||||||
|
href="#"
|
||||||
|
id="dropdown01"
|
||||||
|
data-toggle="dropdown"
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-expanded="false"
|
||||||
|
>Dropdown</a
|
||||||
|
>
|
||||||
|
<div class="dropdown-menu" aria-labelledby="dropdown01">
|
||||||
|
<a class="dropdown-item" href="#">Action</a>
|
||||||
|
<a class="dropdown-item" href="#">Another action</a>
|
||||||
|
<a class="dropdown-item" href="#"
|
||||||
|
>Something else here</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<form action="POST" class="form-inline my-2 my-lg-0">
|
||||||
|
<input
|
||||||
|
class="form-control mr-sm-2"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search"
|
||||||
|
aria-label="Search"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="btn btn-outline-success my-2 my-sm-0"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
3
templates/pagefooter.html
Normal file
3
templates/pagefooter.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<footer class="container">
|
||||||
|
<p>© No rights reserved - This is in the public domain!</p>
|
||||||
|
</footer>
|
||||||
76
templates/signup.html
Normal file
76
templates/signup.html
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
{{ template "htmlheader.html" .HTMLHeader }}
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
padding-top: 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bd-placeholder-img {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
text-anchor: middle;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.bd-placeholder-img-lg {
|
||||||
|
font-size: 3.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
{{ template "navbar.html" .Navbar}}
|
||||||
|
|
||||||
|
<main role="main">
|
||||||
|
<div class="signup-form">
|
||||||
|
<form action="/signup" method="post">
|
||||||
|
<h2 class="text-center">Create New Account</h2>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<span>Email:</span>
|
||||||
|
<input type="text" class="form-control" name="email"
|
||||||
|
placeholder="user@domain.com" required="required">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<span>Desired Username:</span>
|
||||||
|
<input type="text" class="form-control" name="desiredUsername" placeholder="Username" required="required">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<p>Please use a unique password that you don't use anywhere
|
||||||
|
else. Minimum 12 characters.</p>
|
||||||
|
<span>New Password:</span>
|
||||||
|
<input type="password" class="form-control"
|
||||||
|
name="desiredPassword1" placeholder="Password" required="required">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<span>New Password (again):</span>
|
||||||
|
<input type="password" class="form-control"
|
||||||
|
name="desiredPassword2" placeholder="Password
|
||||||
|
(again)" required="required">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<button type="submit" class="btn btn-primary btn-block">Create
|
||||||
|
New Account</button>
|
||||||
|
</div>
|
||||||
|
<!-- <div class="clearfix">
|
||||||
|
<label class="float-left form-check-label"><input type="checkbox"> Remember me</label>
|
||||||
|
<a href="#" class="float-right">Forgot Password?</a>
|
||||||
|
</div>
|
||||||
|
-->
|
||||||
|
</form>
|
||||||
|
<p class="text-center"><a href="#">Create an Account</a></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{{ template "pagefooter.html" .PageFooter }}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
29
templates/templates.go
Normal file
29
templates/templates.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"text/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed *.html
|
||||||
|
var TemplatesRaw embed.FS
|
||||||
|
var TemplatesParsed *template.Template
|
||||||
|
|
||||||
|
func GetParsed() *template.Template {
|
||||||
|
if TemplatesParsed == nil {
|
||||||
|
TemplatesParsed = template.Must(template.ParseFS(TemplatesRaw, "*"))
|
||||||
|
}
|
||||||
|
return TemplatesParsed
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
func MustString(filename string) string {
|
||||||
|
bytes, error := Templates.ReadFile(filename)
|
||||||
|
if error != nil {
|
||||||
|
panic(error)
|
||||||
|
}
|
||||||
|
var out strings.Builder
|
||||||
|
out.Write(bytes)
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
|
*/
|
||||||
Reference in New Issue
Block a user