Compare commits
25 Commits
439bcf5ece
...
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 |
22
.drone.yml
22
.drone.yml
@@ -1,13 +1,17 @@
|
||||
kind: pipeline
|
||||
name: test-docker-build
|
||||
|
||||
|
||||
steps:
|
||||
- name: test-docker-build
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: foo/bar
|
||||
dry_run: true
|
||||
target: final
|
||||
tags:
|
||||
- ${DRONE_COMMIT_SHA}
|
||||
- ${DRONE_BRANCH}
|
||||
- name: test
|
||||
image: docker:dind
|
||||
volumes:
|
||||
- name: dockersock
|
||||
path: /var/run/docker.sock
|
||||
commands:
|
||||
#- echo nameserver 116.202.204.30 > /etc/resolv.conf
|
||||
- docker build -t sneak/gohttpserver .
|
||||
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
|
||||
debug.log
|
||||
/.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
|
||||
FROM golangci/golangci-lint@sha256:9ae3767101cd3468cdaea5b6573dadb358013e05ac38abe37d53646680fd386c AS linter
|
||||
|
||||
RUN mkdir -p /build
|
||||
WORKDIR /build
|
||||
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 .
|
||||
# Lint stage — fast feedback
|
||||
# golangci/golangci-lint:v1.64.8 (2025-03-17)
|
||||
FROM golangci/golangci-lint@sha256:2987913e27f4eca9c8a39129d2c7bc1e74fbcf77f181e01cea607be437aa5cb8 AS lint
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN make fmt-check
|
||||
RUN make lint
|
||||
|
||||
COPY ./ ./
|
||||
#RUN make lint
|
||||
RUN make rice-install && make httpd
|
||||
RUN go mod vendor
|
||||
RUN tar -c . | bzip2 > /src.tbz2
|
||||
# Build stage
|
||||
# golang:1.24-bookworm (Go 1.24)
|
||||
FROM golang@sha256:1a6d4452c65dea36aac2e2d606b01b4a029ec90cc1ae53890540ce6173ea77ac AS builder
|
||||
# Force BuildKit to run the lint stage
|
||||
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:
|
||||
FROM debian:buster-slim AS final
|
||||
# Runtime stage
|
||||
# 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 /build/httpd /app/httpd
|
||||
COPY --from=builder /src.tbz2 /usr/local/src/src.tbz2
|
||||
|
||||
COPY --from=builder /httpd /app/httpd
|
||||
|
||||
WORKDIR /app
|
||||
ENV HOME /app
|
||||
|
||||
ENV PORT 8080
|
||||
ENV DBURL none
|
||||
ENV HOME=/app
|
||||
ENV PORT=8080
|
||||
ENV DBURL=none
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
|
||||
51
Makefile
51
Makefile
@@ -1,30 +1,40 @@
|
||||
ARCH := $(shell uname -m)
|
||||
VERSION := $(shell git describe --always --dirty=-dirty)
|
||||
|
||||
FN := http
|
||||
|
||||
VERSION := $(shell git describe --always --dirty=-dirty)
|
||||
ARCH := $(shell uname -m)
|
||||
UNAME_S := $(shell uname -s)
|
||||
|
||||
GOLDFLAGS += -X main.Version=$(VERSION)
|
||||
GOLDFLAGS += -X main.Buildarch=$(ARCH)
|
||||
GOFLAGS := -ldflags "$(GOLDFLAGS)"
|
||||
|
||||
default: debug
|
||||
default: clean debug
|
||||
|
||||
commit: fmt lint
|
||||
git commit -a
|
||||
|
||||
# get golangci-lint with:
|
||||
# go get github.com/golangci/golangci-lint/cmd/golangci-lint@v1.31.0
|
||||
# get gofumports with:
|
||||
# go get mvdan.cc/gofumpt/gofumports
|
||||
# get gofumpt with:
|
||||
# go install mvdan.cc/gofumpt@latest
|
||||
fmt:
|
||||
gofumports -l -w .
|
||||
gofumpt -l -w .
|
||||
golangci-lint run --fix
|
||||
|
||||
fmt-check:
|
||||
@test -z "$$(gofmt -l .)" || { echo "gofmt found unformatted files:"; gofmt -l .; exit 1; }
|
||||
|
||||
lint:
|
||||
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=1 GOTRACEBACK=all ./$(FN)d
|
||||
@@ -41,16 +51,11 @@ clean:
|
||||
docker:
|
||||
docker build --progress plain .
|
||||
|
||||
./$(FN)d: cmd/$(FN)d/main.go */*.go
|
||||
cd httpserver && \
|
||||
cd ../cmd/$(FN)d && \
|
||||
go build -o ../../$(FN)d $(GOFLAGS) . && \
|
||||
cd ../.. && \
|
||||
rice append -i ./httpserver --exec ./httpd
|
||||
./$(FN)d: cmd/$(FN)d/main.go internal/*/*.go templates/* static/*
|
||||
cd ./cmd/$(FN)d && \
|
||||
go build -o ../../$(FN)d $(GOFLAGS) .
|
||||
|
||||
tools: rice-install
|
||||
go get -v github.com/golangci/golangci-lint/cmd/golangci-lint@v1.31.0
|
||||
go get -v mvdan.cc/gofumpt/gofumports
|
||||
tools:
|
||||
go install mvdan.cc/gofumpt@latest
|
||||
|
||||
rice-install:
|
||||
go get -v github.com/GeertJohan/go.rice/rice
|
||||
.PHONY: default commit fmt fmt-check lint test check build hooks debug debugger run clean docker tools
|
||||
|
||||
@@ -86,3 +86,4 @@ WTFPL (aka public domain):
|
||||
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
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"git.eeqj.de/sneak/gohttpserver/httpserver"
|
||||
"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/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 (
|
||||
Appname string = "CHANGEME"
|
||||
Version string
|
||||
Buildarch string
|
||||
Appname string = "CHANGEME"
|
||||
Version string
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
go 1.15
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/99designs/basicauth-go v0.0.0-20160802081356-2a93ba0f464d
|
||||
github.com/GeertJohan/go.rice v1.0.0
|
||||
github.com/fsnotify/fsnotify v1.4.9 // indirect
|
||||
github.com/getsentry/sentry-go v0.7.0
|
||||
github.com/getsentry/sentry-go v0.15.0
|
||||
github.com/go-chi/chi v4.1.2+incompatible
|
||||
github.com/go-chi/cors v1.1.1
|
||||
github.com/golang/protobuf v1.4.2 // indirect
|
||||
github.com/google/go-cmp v0.5.2 // indirect
|
||||
github.com/joho/godotenv v1.3.0
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/prometheus/client_golang v1.6.0
|
||||
github.com/rs/zerolog v1.20.0
|
||||
github.com/slok/go-http-metrics v0.8.0
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/spf13/viper v1.7.1
|
||||
github.com/stretchr/testify v1.6.1 // indirect
|
||||
golang.org/x/net v0.0.0-20200822124328-c89045814202 // indirect
|
||||
golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
|
||||
gopkg.in/yaml.v2 v2.3.0 // indirect
|
||||
github.com/go-chi/cors v1.2.1
|
||||
github.com/joho/godotenv v1.4.0
|
||||
github.com/prometheus/client_golang v1.14.0
|
||||
github.com/slok/go-http-metrics v0.10.0
|
||||
github.com/spf13/viper v1.14.0
|
||||
go.uber.org/fx v1.18.2
|
||||
modernc.org/sqlite v1.41.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.1.2 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fsnotify/fsnotify v1.6.0 // indirect
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/hcl v1.0.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 (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *server) handleNow() http.HandlerFunc {
|
||||
func (s *Handlers) HandleNow() http.HandlerFunc {
|
||||
type response struct {
|
||||
Now time.Time `json:"now"`
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package httpserver
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
// CHANGEME you probably want to remove this,
|
||||
// this is just a handler/route that throws a panic to test
|
||||
// sentry events.
|
||||
func (s *server) handlePanic() http.HandlerFunc {
|
||||
func (s *Handlers) HandlePanic() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
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 (
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"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"
|
||||
"github.com/go-chi/chi/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
@@ -12,10 +16,27 @@ import (
|
||||
ghmm "github.com/slok/go-http-metrics/middleware"
|
||||
"github.com/slok/go-http-metrics/middleware/std"
|
||||
"github.com/spf13/viper"
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
// the following is from
|
||||
// https://learning-cloud-native-go.github.io/docs/a6.adding_zerolog_logger/
|
||||
type MiddlewareParams struct {
|
||||
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 {
|
||||
h, _, err := net.SplitHostPort(hp)
|
||||
@@ -45,8 +66,7 @@ func (lrw *loggingResponseWriter) WriteHeader(code int) {
|
||||
// type Middleware func(http.Handler) http.Handler
|
||||
// this returns a Middleware that is designed to do every request through the
|
||||
// mux, note the signature:
|
||||
func (s *server) LoggingMiddleware() func(http.Handler) http.Handler {
|
||||
// FIXME this should use https://github.com/google/go-cloud/blob/master/server/requestlog/requestlog.go
|
||||
func (s *Middleware) Logging() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
@@ -54,18 +74,18 @@ func (s *server) LoggingMiddleware() func(http.Handler) http.Handler {
|
||||
ctx := r.Context()
|
||||
defer func() {
|
||||
latency := time.Since(start)
|
||||
s.log.Info().
|
||||
Time("request_start", start).
|
||||
Str("method", r.Method).
|
||||
Str("url", r.URL.String()).
|
||||
Str("useragent", r.UserAgent()).
|
||||
Str("request_id", ctx.Value(middleware.RequestIDKey).(string)).
|
||||
Str("referer", r.Referer()).
|
||||
Str("proto", r.Proto).
|
||||
Str("remoteIP", ipFromHostPort(r.RemoteAddr)).
|
||||
Int("status", lrw.statusCode).
|
||||
Int("latency_ms", int(latency.Milliseconds())).
|
||||
Send()
|
||||
s.log.InfoContext(ctx, "request",
|
||||
"request_start", start,
|
||||
"method", r.Method,
|
||||
"url", r.URL.String(),
|
||||
"useragent", r.UserAgent(),
|
||||
"request_id", ctx.Value(middleware.RequestIDKey).(string),
|
||||
"referer", r.Referer(),
|
||||
"proto", r.Proto,
|
||||
"remoteIP", ipFromHostPort(r.RemoteAddr),
|
||||
"status", lrw.statusCode,
|
||||
"latency_ms", latency.Milliseconds(),
|
||||
)
|
||||
}()
|
||||
|
||||
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{
|
||||
// CHANGEME! these are defaults, change them to suit your needs or
|
||||
// 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 http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) MetricsMiddleware() func(http.Handler) http.Handler {
|
||||
func (s *Middleware) Metrics() func(http.Handler) http.Handler {
|
||||
mdlw := ghmm.New(ghmm.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(
|
||||
"metrics",
|
||||
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 (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/gohttpserver/static"
|
||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/middleware"
|
||||
@@ -11,7 +12,7 @@ import (
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
func (s *server) routes() {
|
||||
func (s *Server) SetupRoutes() {
|
||||
s.router = chi.NewRouter()
|
||||
|
||||
// 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.RequestID)
|
||||
s.router.Use(s.LoggingMiddleware())
|
||||
s.router.Use(s.mw.Logging())
|
||||
|
||||
// add metrics middleware only if we can serve them behind auth
|
||||
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
|
||||
// in middlewares.go.
|
||||
s.router.Use(s.CORSMiddleware())
|
||||
s.router.Use(s.mw.CORS())
|
||||
|
||||
// CHANGEME to suit your needs, or pull from config.
|
||||
// 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
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
|
||||
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) {
|
||||
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
|
||||
// wrapper) on a specific HandleFunc route, you need to take the
|
||||
// .ServeHTTP of the http.Handler to get its HandleFunc, viz:
|
||||
authMiddleware := s.AuthMiddleware()
|
||||
auth := s.mw.Auth()
|
||||
s.router.Get(
|
||||
"/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
|
||||
// CHANGEME remove this
|
||||
s.router.Get(
|
||||
"/panic",
|
||||
s.handlePanic(),
|
||||
s.h.HandlePanic(),
|
||||
)
|
||||
|
||||
s.router.Get(
|
||||
"/.well-known/healthcheck.json",
|
||||
s.handleHealthCheck(),
|
||||
s.h.HandleHealthCheck(),
|
||||
)
|
||||
|
||||
// set up authenticated /metrics route:
|
||||
if viper.GetString("METRICS_USERNAME") != "" {
|
||||
s.router.Group(func(r chi.Router) {
|
||||
r.Use(s.MetricsAuthMiddleware())
|
||||
r.Use(s.mw.MetricsAuth())
|
||||
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