This project was made into a library.
This commit is contained in:
parent
be5d0782b6
commit
4bb06cb2a1
29
Makefile
29
Makefile
@ -1,29 +0,0 @@
|
||||
PACKAGE_NAME=justguestbook
|
||||
TARGET_DAEMON=${PACKAGE_NAME}d
|
||||
|
||||
SYSCTL=${shell which systemctl}
|
||||
SYSDDIR_=${shell pkg-config systemd --variable=systemdsystemunitdir}
|
||||
SYSDDIR=${SYSDDIR_:/%=%}
|
||||
DESTDIR=/
|
||||
|
||||
LDFLAGS=-ldflags "-s -w -X main.version=1.0.0" -tags osusergo,netgo
|
||||
|
||||
all: ${TARGET_DAEMON}
|
||||
|
||||
.PHONY: ${TARGET_DAEMON}
|
||||
|
||||
${TARGET_DAEMON}:
|
||||
go build -o bin/$@ ${LDFLAGS} cmd/$@/main.go
|
||||
|
||||
run:
|
||||
bin/${TARGET_DAEMON} -conf configs/config.test.yaml
|
||||
|
||||
install:
|
||||
install -Dm 0755 bin/${TARGET_DAEMON} ${DESTDIR}usr/bin/${TARGET_DAEMON}
|
||||
install -Dm 0644 LICENSE ${DESTDIR}usr/share/licenses/${PACKAGE_NAME}/LICENSE
|
||||
install -Dm 0644 init/systemd/${PACKAGE_NAME}.service ${DESTDIR}${SYSDDIR}/${PACKAGE_NAME}.service
|
||||
|
||||
uninstall:
|
||||
rm ${DESTDIR}usr/bin/${TARGET_DAEMON}
|
||||
rm ${DESTDIR}usr/share/licenses/${PACKAGE_NAME}/LICENSE
|
||||
rm ${DESTDIR}${SYSDDIR}/${PACKAGE_NAME}.service
|
84
README.md
84
README.md
@ -1,86 +1,4 @@
|
||||
justguestbook ver. 1.0.0
|
||||
========================
|
||||
|
||||
## Usage
|
||||
|
||||
justguestbookd -conf /etc/justguestbook.yaml
|
||||
|
||||
## API summary
|
||||
|
||||
GET /?p=&psz=
|
||||
POST /
|
||||
data: captcha_id=&name=&message=[&website=&hide_website=1]
|
||||
DELETE /:entry
|
||||
|
||||
POST /:entry/reply
|
||||
data: reply=
|
||||
DELETE /:entry/reply
|
||||
|
||||
## Public API
|
||||
|
||||
### Get a list of guestbook entries
|
||||
|
||||
GET /?p=&psz=
|
||||
|
||||
#### URL query parameters
|
||||
- `p=` is a page number. By default first page will be returned.
|
||||
- `psz=` is a page size. By default will be used page size set by a service.
|
||||
|
||||
#### HTTP codes
|
||||
- `200` if there are entries.
|
||||
- `500` if for some reason a list cannot be sent.
|
||||
|
||||
### Post a new guestbook entry
|
||||
|
||||
POST /
|
||||
|
||||
#### Fields
|
||||
- `captcha_id` is an ID of CAPTCHA stored in a justcaptcha service.
|
||||
- `name` of a poster. Optional. Service's default if not set.
|
||||
- `message` a poster want to post. Required.
|
||||
- `website` of a poster. Optional.
|
||||
- `hide_website` if true hide website. Optional. Hide by default.
|
||||
|
||||
#### HTTP codes
|
||||
- `201` if created
|
||||
- `422` if some required field doesn't present.
|
||||
- `500` if for some reason post cannot be stored.
|
||||
|
||||
A `500` response should return a `message` back.
|
||||
|
||||
## Administration API
|
||||
|
||||
All such commands must have a password supplied in `X-Password` header.
|
||||
|
||||
### Send a reply to entry
|
||||
|
||||
POST /:entry/reply
|
||||
|
||||
#### Fields
|
||||
- `message` for an entry. Required.
|
||||
|
||||
#### HTTP codes
|
||||
- `201` if created or modified successfully.
|
||||
- `403` if unauthorised.
|
||||
- `404` if there's no such entry.
|
||||
- `500` if there are internal problems with service.
|
||||
|
||||
### Delete an entry
|
||||
|
||||
DELETE /:entry
|
||||
|
||||
#### HTTP codes
|
||||
- `204` if was successfully deleted.
|
||||
- `403` if unauthorised.
|
||||
- `404` if there's no such entry.
|
||||
- `500` if there are internal problems with service.
|
||||
|
||||
### Delete a reply
|
||||
|
||||
DELETE /:entry/reply
|
||||
|
||||
#### HTTP codes
|
||||
- `204` if was successfully deleted.
|
||||
- `403` if unauthorised.
|
||||
- `404` if there's no such entry or reply.
|
||||
- `500` if there are internal problems with service.
|
||||
A library implementing simple guestbook with replies.
|
@ -1,30 +0,0 @@
|
||||
# Maintainer: Alexander "Arav" Andreev <me@arav.top>
|
||||
pkgname=justguestbook-git
|
||||
pkgver=1.0.0
|
||||
pkgrel=1
|
||||
pkgdesc="Just a simple guestbook with owner's replies"
|
||||
arch=('i686' 'x86_64' 'arm' 'armv6h' 'armv7h' 'aarch64')
|
||||
url="https://git.arav.top/Arav/justguestbook"
|
||||
license=('MIT')
|
||||
groups=()
|
||||
depends=()
|
||||
makedepends=('git' 'go')
|
||||
provides=('justguestbook')
|
||||
conflicts=('justguestbook')
|
||||
replaces=()
|
||||
backup=('/etc/justguestbook.yaml')
|
||||
options=()
|
||||
install=
|
||||
source=('justguestbook-git::git+https://git.arav.top/Arav/justguestbook.git')
|
||||
noextract=()
|
||||
md5sums=('SKIP')
|
||||
|
||||
build() {
|
||||
cd "$srcdir/$pkgname"
|
||||
make DESTDIR="$pkgdir/"
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$srcdir/$pkgname"
|
||||
make DESTDIR="$pkgdir/" install
|
||||
}
|
@ -1,90 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"justguestbook/internal/configuration"
|
||||
"justguestbook/internal/database/sqlite"
|
||||
"justguestbook/internal/handlers"
|
||||
"justguestbook/pkg/server"
|
||||
"log"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
var version string
|
||||
|
||||
var configFilePath *string = flag.String("conf", "/etc/justguestbook.yaml", "path to a configuration file")
|
||||
var showVersion *bool = flag.Bool("v", false, "show version")
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
if *showVersion {
|
||||
fmt.Println("justguestbookd ver.", version, "\nCopyright (c) 2022 Alexander \"Arav\" Andreev <me@arav.top>")
|
||||
return
|
||||
}
|
||||
|
||||
log.SetFlags(0)
|
||||
|
||||
config, err := configuration.LoadConfiguration(*configFilePath)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
if len(config.Owner.Password) == 0 {
|
||||
log.Fatalln("empty password is not allowed")
|
||||
}
|
||||
|
||||
db, err := sqlite.New(config.DBPath)
|
||||
if err != nil {
|
||||
log.Fatalln("failed to init DB:", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
hand := handlers.New(config.Owner.Name, config.Owner.Password, config.AnonymousPosterName, config.PageSize, db, config.CaptchaAddr)
|
||||
|
||||
srv := server.NewHttpServer()
|
||||
|
||||
srv.GET("/", hand.Entries)
|
||||
srv.POST("/", hand.New)
|
||||
srv.PUT("/:entry", hand.Update)
|
||||
srv.DELETE("/:entry", hand.Delete)
|
||||
srv.POST("/:entry/reply", hand.Reply)
|
||||
srv.PUT("/:entry/reply", hand.Update)
|
||||
srv.DELETE("/:entry/reply", hand.Delete)
|
||||
|
||||
var network string
|
||||
if !strings.ContainsRune(config.ListenOn, ':') {
|
||||
network = "unix"
|
||||
defer os.Remove(config.ListenOn)
|
||||
} else {
|
||||
ap, err := netip.ParseAddrPort(config.ListenOn)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
if ap.Addr().Is4() {
|
||||
network = "tcp4"
|
||||
} else if ap.Addr().Is6() {
|
||||
network = "tcp6"
|
||||
}
|
||||
}
|
||||
|
||||
if err := srv.Start(network, config.ListenOn); err != nil {
|
||||
log.Fatalln("failed to start a server:", err)
|
||||
}
|
||||
|
||||
doneSignal := make(chan os.Signal, 1)
|
||||
signal.Notify(doneSignal, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
<-doneSignal
|
||||
|
||||
if err := srv.Stop(); err != nil {
|
||||
log.Fatalln("failed to properly shutdown a server:", err)
|
||||
}
|
||||
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
# address:port or /path/to/unix.sock
|
||||
listen_on: "/var/run/justguestbook/g.sock"
|
||||
owner:
|
||||
# Name to be displayed in replies.
|
||||
name: "Admin"
|
||||
# Password for administrative commands.
|
||||
password: ""
|
||||
# A name for anonymous posters.
|
||||
anonymous_poster_name: "Anonymous"
|
||||
# How many entries to display on one page.
|
||||
page_size: 60
|
||||
# Path to SQLite database file where all entries are stored.
|
||||
db_path: "/var/lib/justguestbook/guestbook.sqlite"
|
||||
# Address of justcaptcha service (TCP only)
|
||||
captcha_addr: "http://startpage.arav.home.arpa/captcha/"
|
6
go.mod
6
go.mod
@ -1,10 +1,8 @@
|
||||
module justguestbook
|
||||
module git.arav.top/Arav/justguestbook
|
||||
|
||||
go 1.19
|
||||
|
||||
require (
|
||||
github.com/julienschmidt/httprouter v1.3.0
|
||||
github.com/mattn/go-sqlite3 v1.14.15
|
||||
github.com/mattn/go-sqlite3 v1.14.16
|
||||
github.com/pkg/errors v0.9.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
10
go.sum
10
go.sum
@ -1,10 +1,4 @@
|
||||
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
|
||||
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
||||
github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
|
||||
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
@ -1,39 +0,0 @@
|
||||
[Unit]
|
||||
Description=Just a simple guestbook service with owner's replies
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Restart=on-failure
|
||||
DynamicUser=yes
|
||||
ExecStart=/usr/bin/justguestbookd -conf /etc/justguestbook.yaml
|
||||
|
||||
ReadOnlyPaths=/
|
||||
NoExecPaths=/
|
||||
ExecPaths=/usr/bin/justguestbookd /usr/lib64
|
||||
|
||||
RuntimeDirectory=justguestbook
|
||||
StateDirectory=justguestbook
|
||||
|
||||
AmbientCapabilities=
|
||||
CapabilityBoundingSet=
|
||||
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
NoNewPrivileges=true
|
||||
PrivateDevices=true
|
||||
ProtectClock=true
|
||||
ProtectControlGroups=true
|
||||
ProtectHome=true
|
||||
ProtectKernelLogs=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectSystem=strict
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
RestrictNamespaces=true
|
||||
RestrictRealtime=true
|
||||
RestrictSUIDSGID=true
|
||||
SystemCallArchitectures=native
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
@ -1,45 +0,0 @@
|
||||
package configuration
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Configuration struct {
|
||||
ListenOn string `yaml:"listen_on"`
|
||||
Owner struct {
|
||||
Name string `yaml:"name"`
|
||||
Password string `yaml:"password"`
|
||||
} `yaml:"owner"`
|
||||
AnonymousPosterName string `yaml:"anonymous_poster_name"`
|
||||
PageSize int64 `yaml:"page_size"`
|
||||
DBPath string `yaml:"db_path"`
|
||||
CaptchaAddr string `yaml:"captcha_addr"`
|
||||
}
|
||||
|
||||
func LoadConfiguration(path string) (*Configuration, error) {
|
||||
configFile, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to open configuration file")
|
||||
}
|
||||
defer configFile.Close()
|
||||
|
||||
config := &Configuration{}
|
||||
|
||||
if err := yaml.NewDecoder(configFile).Decode(config); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to parse configuration file")
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// SplitNetworkAddress splits ListenOn option and returns as two strings
|
||||
// network type (e.g. tcp, unix, udp) and address:port or /path/to/prog.socket
|
||||
// to listen on.
|
||||
func (c *Configuration) SplitNetworkAddress() (string, string) {
|
||||
s := strings.Split(c.ListenOn, " ")
|
||||
return s[0], s[1]
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
UPDATE OR REPLACE `entry`
|
||||
SET
|
||||
`entry_id` = ?
|
||||
`name` = ?,
|
||||
`message` = ?,
|
||||
`website` = ?,
|
||||
`hide_website` = ?,
|
||||
WHERE `entry_id` = ?;
|
@ -1,6 +0,0 @@
|
||||
UPDATE OR REPLACE `reply`
|
||||
SET
|
||||
`entry_id` = ?,
|
||||
`created` = ?,
|
||||
`message` = ?,
|
||||
WHERE `entry_id` = ?;
|
@ -1,250 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"justguestbook/internal/guestbook"
|
||||
"justguestbook/pkg/justcaptcha"
|
||||
"justguestbook/pkg/server"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type GuestbookHandlers struct {
|
||||
owner string
|
||||
password string
|
||||
anonymousName string
|
||||
defaultPageSize int64
|
||||
db guestbook.Guestbook
|
||||
captchaAddr string
|
||||
}
|
||||
|
||||
func New(owner, password, anonymousName string, defaultPageSize int64, guestbook guestbook.Guestbook, captchaAddr string) *GuestbookHandlers {
|
||||
return &GuestbookHandlers{
|
||||
owner: owner,
|
||||
password: password,
|
||||
anonymousName: anonymousName,
|
||||
defaultPageSize: defaultPageSize,
|
||||
db: guestbook,
|
||||
captchaAddr: captchaAddr}
|
||||
}
|
||||
|
||||
func (h *GuestbookHandlers) Entries(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
|
||||
var page_num int64 = 1
|
||||
if r.URL.Query().Get("p") != "" {
|
||||
page_num, err = strconv.ParseInt(r.URL.Query().Get("p"), 10, 64)
|
||||
if err != nil {
|
||||
page_num = 1
|
||||
}
|
||||
}
|
||||
|
||||
var page_size int64 = h.defaultPageSize
|
||||
if r.URL.Query().Get("ps") != "" {
|
||||
page_size, err = strconv.ParseInt(r.URL.Query().Get("ps"), 10, 64)
|
||||
if err != nil {
|
||||
page_size = h.defaultPageSize
|
||||
}
|
||||
}
|
||||
|
||||
entries, err := h.db.Entries(page_num, page_size)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
log.Println("failed to retrieve entries:", err)
|
||||
return
|
||||
}
|
||||
|
||||
guestbookEntries := struct {
|
||||
Owner string `json:"owner"`
|
||||
Entries []*guestbook.Entry `json:"entries"`
|
||||
}{
|
||||
Owner: h.owner,
|
||||
Entries: entries}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(&guestbookEntries); err != nil {
|
||||
log.Println("failed to encode entries:", err)
|
||||
http.Error(w, fmt.Sprintln("failed to encode entries:", err.Error()), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *GuestbookHandlers) New(w http.ResponseWriter, r *http.Request) {
|
||||
var entry *guestbook.Entry
|
||||
var err error
|
||||
|
||||
if r.Header.Get("Content-Type") == "application/x-www-form-urlencoded" {
|
||||
r.ParseForm()
|
||||
|
||||
if r.FormValue("captcha_id") == "" {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
solved, err := justcaptcha.CheckCaptcha(r.FormValue("captcha_id"), h.captchaAddr)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
log.Println("justcaptcha:", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !solved {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
name := r.FormValue("name")
|
||||
if name == "" {
|
||||
name = h.anonymousName
|
||||
}
|
||||
|
||||
entry, err = guestbook.NewEntry(name, r.FormValue("message"),
|
||||
r.FormValue("website"), len(r.FormValue("hide_website")) != 0)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
} else if r.Header.Get("Content-Type") == "application/json" {
|
||||
cid := struct {
|
||||
CaptchaID string `json:"captcha_id"`
|
||||
}{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&cid); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
solved, err := justcaptcha.CheckCaptcha(cid.CaptchaID, h.captchaAddr)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
log.Println("justcaptcha:", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !solved {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(entry); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err = h.db.NewEntry(entry)
|
||||
if err != nil {
|
||||
http.Error(w, entry.Message, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
|
||||
func (h *GuestbookHandlers) Reply(w http.ResponseWriter, r *http.Request) {
|
||||
var reply *guestbook.Reply
|
||||
|
||||
if r.Header.Get("X-Password") != h.password {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseInt(server.GetURLParam(r, "entry"), 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Header.Get("Content-Type") == "application/x-www-form-urlencoded" {
|
||||
r.ParseForm()
|
||||
|
||||
reply, err = guestbook.NewReply(id, r.FormValue("reply"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
} else if r.Header.Get("Content-Type") == "application/json" {
|
||||
if err := json.NewDecoder(r.Body).Decode(reply); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.db.NewReply(reply); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
|
||||
func (h *GuestbookHandlers) Update(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-Password") != h.password {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
entryID, err := strconv.ParseInt(server.GetURLParam(r, "entry"), 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasSuffix(r.URL.Path, "reply") {
|
||||
rp := guestbook.Reply{}
|
||||
json.NewDecoder(r.Body).Decode(&rp)
|
||||
|
||||
isCreated, err := h.db.UpdateReply(entryID, &rp)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if isCreated {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(&rp)
|
||||
} else {
|
||||
et := guestbook.Entry{}
|
||||
json.NewDecoder(r.Body).Decode(&et)
|
||||
|
||||
isCreated, err := h.db.UpdateEntry(entryID, &et)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if isCreated {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(&et)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *GuestbookHandlers) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-Password") != h.password {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
entryID, err := strconv.ParseInt(server.GetURLParam(r, "entry"), 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasSuffix(r.URL.Path, "reply") {
|
||||
if err := h.db.DeleteReply(entryID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
} else {
|
||||
if err := h.db.DeleteEntry(entryID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
@ -4,17 +4,18 @@ import (
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"justguestbook/internal/guestbook"
|
||||
|
||||
"git.arav.top/Arav/justguestbook/pkg/guestbook"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed queries/schema.sql
|
||||
queryCreateDatabase string
|
||||
querySchema string
|
||||
|
||||
//go:embed queries/entryGetAll.sql
|
||||
queryGetEntries string
|
||||
queryGetAll string
|
||||
//go:embed queries/entryCount.sql
|
||||
queryCount string
|
||||
|
||||
@ -33,7 +34,7 @@ var (
|
||||
)
|
||||
|
||||
var (
|
||||
stmtGetEntries *sql.Stmt
|
||||
stmtGetAll *sql.Stmt
|
||||
stmtCount *sql.Stmt
|
||||
stmtNewEntry *sql.Stmt
|
||||
stmtUpdateEntry *sql.Stmt
|
||||
@ -44,49 +45,51 @@ var (
|
||||
)
|
||||
|
||||
func initDBStatements(db *sql.DB) error {
|
||||
_, err := db.Exec(queryCreateDatabase)
|
||||
db.Exec("PRAGMA foreign_keys = ON;")
|
||||
|
||||
_, err := db.Exec(querySchema)
|
||||
if err != nil {
|
||||
return err
|
||||
return errors.Wrap(err, "failed to init schema")
|
||||
}
|
||||
|
||||
stmtGetEntries, err = db.Prepare(queryGetEntries)
|
||||
stmtGetAll, err = db.Prepare(queryGetAll)
|
||||
if err != nil {
|
||||
return err
|
||||
return errors.Wrap(err, "failed to prepare queryGetAll")
|
||||
}
|
||||
|
||||
stmtCount, err = db.Prepare(queryCount)
|
||||
if err != nil {
|
||||
return err
|
||||
return errors.Wrap(err, "failed to prepare queryCount")
|
||||
}
|
||||
|
||||
stmtNewEntry, err = db.Prepare(queryNewEntry)
|
||||
if err != nil {
|
||||
return err
|
||||
return errors.Wrap(err, "failed to prepare queryNewEntry")
|
||||
}
|
||||
|
||||
stmtUpdateEntry, err = db.Prepare(queryUpdateEntry)
|
||||
if err != nil {
|
||||
return err
|
||||
return errors.Wrap(err, "failed to prepare queryUpdateEntry")
|
||||
}
|
||||
|
||||
stmtDeleteEntry, err = db.Prepare(queryDeleteEntry)
|
||||
if err != nil {
|
||||
return err
|
||||
return errors.Wrap(err, "failed to prepare queryDeleteEntry")
|
||||
}
|
||||
|
||||
stmtNewReply, err = db.Prepare(queryNewReply)
|
||||
if err != nil {
|
||||
return err
|
||||
return errors.Wrap(err, "failed to prepare queryNewReply")
|
||||
}
|
||||
|
||||
stmtUpdateReply, err = db.Prepare(queryUpdateReply)
|
||||
if err != nil {
|
||||
return err
|
||||
return errors.Wrap(err, "failed to prepare queryUpdateReply")
|
||||
}
|
||||
|
||||
stmtDeleteReply, err = db.Prepare(queryDeleteReply)
|
||||
if err != nil {
|
||||
return err
|
||||
return errors.Wrap(err, "failed to prepare queryDeleteReply")
|
||||
}
|
||||
|
||||
return nil
|
||||
@ -116,7 +119,7 @@ func (d *SQLiteDatabase) Entries(page, pageSize int64) (entries []*guestbook.Ent
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
rows, err := tx.Stmt(stmtGetEntries).Query(pageSize, (page-1)*pageSize)
|
||||
rows, err := tx.Stmt(stmtGetAll).Query(pageSize, (page-1)*pageSize)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@ -128,13 +131,14 @@ func (d *SQLiteDatabase) Entries(page, pageSize int64) (entries []*guestbook.Ent
|
||||
var reply_message sql.NullString
|
||||
if err = rows.Scan(
|
||||
&entry.ID, &entry.Created, &entry.Name,
|
||||
&entry.Website, &entry.Message,
|
||||
&entry.Message, &entry.Website,
|
||||
&reply_created, &reply_message); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if reply_message.Valid /* reply_created is also valid if reply is */ {
|
||||
entry.Reply = &guestbook.Reply{
|
||||
ID: entry.ID,
|
||||
Created: reply_created.String,
|
||||
Message: reply_message.String}
|
||||
}
|
||||
@ -147,22 +151,25 @@ func (d *SQLiteDatabase) Entries(page, pageSize int64) (entries []*guestbook.Ent
|
||||
return
|
||||
}
|
||||
|
||||
func (d *SQLiteDatabase) Count() (int64, error) {
|
||||
// Count returns how much entries are in an `entry` table.
|
||||
func (d *SQLiteDatabase) Count() (count int64, err error) {
|
||||
tx, err := d.db.Begin()
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var count int64
|
||||
err = tx.Stmt(stmtCount).QueryRow().Scan(&count)
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// NewEntry inserts a passed Entry struct and fills its ID field if successful.
|
||||
func (d *SQLiteDatabase) NewEntry(entry *guestbook.Entry) error {
|
||||
tx, err := d.db.Begin()
|
||||
if err != nil {
|
||||
@ -170,93 +177,99 @@ func (d *SQLiteDatabase) NewEntry(entry *guestbook.Entry) error {
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.Stmt(stmtNewEntry).Exec(entry.Created, entry.Name, entry.Message,
|
||||
r, err := tx.Stmt(stmtNewEntry).Exec(entry.Created, entry.Name, entry.Message,
|
||||
entry.Website, entry.HideWebsite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entry.ID, err = r.LastInsertId()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *SQLiteDatabase) UpdateEntry(entryID int64, entry *guestbook.Entry) (bool, error) {
|
||||
// UpdateEntry
|
||||
func (d *SQLiteDatabase) UpdateEntry(entry *guestbook.Entry) (*guestbook.Entry, error) {
|
||||
tx, err := d.db.Begin()
|
||||
if err != nil {
|
||||
return false, err
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
res, err := tx.Stmt(stmtUpdateEntry).Exec(entry.ID, entry.Name, entry.Message, entry.Website, entry.HideWebsite, entryID)
|
||||
row := tx.Stmt(stmtUpdateEntry).QueryRow(entry.Name, entry.Message, entry.Website, entry.HideWebsite, entry.ID)
|
||||
|
||||
uEntry := guestbook.Entry{}
|
||||
err = row.Scan(&uEntry.ID, &uEntry.Created, &uEntry.Name, &uEntry.Message, &uEntry.Website, &uEntry.HideWebsite)
|
||||
if err != nil {
|
||||
return false, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ra, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
tx.Commit()
|
||||
|
||||
return ra > 0, nil
|
||||
return &uEntry, nil
|
||||
}
|
||||
|
||||
func (d *SQLiteDatabase) DeleteEntry(entryID int64) error {
|
||||
func (d *SQLiteDatabase) DeleteEntry(entryID int64) (int64, error) {
|
||||
tx, err := d.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
return -1, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
res, err := tx.Stmt(stmtDeleteEntry).Exec(entryID)
|
||||
if err != nil {
|
||||
return err
|
||||
return -1, err
|
||||
}
|
||||
|
||||
_, err = res.RowsAffected()
|
||||
c, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
return -1, err
|
||||
}
|
||||
|
||||
return nil
|
||||
tx.Commit()
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (d *SQLiteDatabase) NewReply(reply *guestbook.Reply) (err error) {
|
||||
func (d *SQLiteDatabase) NewReply(reply *guestbook.Reply) error {
|
||||
tx, err := d.db.Begin()
|
||||
if err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.Stmt(stmtNewReply).Exec(reply.ID, reply.Created, reply.Message)
|
||||
if err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateEntry
|
||||
func (d *SQLiteDatabase) UpdateReply(entryID int64, reply *guestbook.Reply) (bool, error) {
|
||||
func (d *SQLiteDatabase) UpdateReply(reply *guestbook.Reply) (*guestbook.Reply, error) {
|
||||
tx, err := d.db.Begin()
|
||||
if err != nil {
|
||||
return false, err
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
res, err := tx.Stmt(stmtUpdateReply).Exec(reply.ID, reply.Created, reply.Message, entryID)
|
||||
uReply := guestbook.Reply{}
|
||||
err = tx.Stmt(stmtUpdateReply).QueryRow(
|
||||
reply.Created, reply.Message, reply.ID).Scan(&uReply.ID, &uReply.Created, &uReply.Message)
|
||||
if err != nil {
|
||||
return false, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ra, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
tx.Commit()
|
||||
|
||||
return ra > 0, nil
|
||||
return &uReply, nil
|
||||
}
|
||||
|
||||
func (d *SQLiteDatabase) DeleteReply(entryID int64) error {
|
||||
@ -276,10 +289,21 @@ func (d *SQLiteDatabase) DeleteReply(entryID int64) error {
|
||||
return err
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *SQLiteDatabase) Close() error {
|
||||
stmtCount.Close()
|
||||
stmtDeleteEntry.Close()
|
||||
stmtDeleteReply.Close()
|
||||
stmtGetAll.Close()
|
||||
stmtNewEntry.Close()
|
||||
stmtNewReply.Close()
|
||||
stmtUpdateEntry.Close()
|
||||
stmtUpdateReply.Close()
|
||||
|
||||
return d.db.Close()
|
||||
}
|
||||
|
150
pkg/database/sqlite/database_test.go
Normal file
150
pkg/database/sqlite/database_test.go
Normal file
@ -0,0 +1,150 @@
|
||||
package sqlite_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.arav.top/Arav/justguestbook/pkg/database/sqlite"
|
||||
"git.arav.top/Arav/justguestbook/pkg/guestbook"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
testEMsg = "kek"
|
||||
testRMsg = "lol"
|
||||
)
|
||||
|
||||
func genTestDB() (db *sqlite.SQLiteDatabase, err error) {
|
||||
db, err = sqlite.New(":memory:")
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to init DB")
|
||||
}
|
||||
|
||||
e, err := guestbook.NewEntry("Anonymous", testEMsg, "", true)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to create a new entry")
|
||||
}
|
||||
|
||||
if err = db.NewEntry(e); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to insert a new entry")
|
||||
}
|
||||
|
||||
r, err := guestbook.NewReply(e.ID, testRMsg)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to create a new reply")
|
||||
}
|
||||
|
||||
if err = db.NewReply(r); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to insert a new reply")
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func TestSqliteInsertAndGetAll(t *testing.T) {
|
||||
db, err := genTestDB()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
defer func() {
|
||||
if err = db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
|
||||
es, err := db.Entries(1, 30)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if c, err := db.Count(); err != nil || int64(len(es)) != c {
|
||||
t.Errorf("entries count mismatch (%d != %d). Error: %s", len(es), c, err)
|
||||
}
|
||||
|
||||
if es[0].Reply.Message != testRMsg {
|
||||
t.Error("reply isn't", testRMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqliteUpdateEntry(t *testing.T) {
|
||||
db, err := genTestDB()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
defer func() {
|
||||
if err = db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
|
||||
es0, err := db.Entries(1, 30)
|
||||
if err != nil {
|
||||
t.Error("failed to obtain entries. Error:", err)
|
||||
}
|
||||
|
||||
me := es0[0]
|
||||
|
||||
me.ID = es0[0].ID
|
||||
me.Name = "NotSoAnonymous"
|
||||
|
||||
ne, err := db.UpdateEntry(me)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if ne.Name != me.Name {
|
||||
t.Error(ne.Name, "!=", me.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqliteUpdateReply(t *testing.T) {
|
||||
db, err := genTestDB()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
defer func() {
|
||||
if err = db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
|
||||
es0, err := db.Entries(1, 30)
|
||||
if err != nil {
|
||||
t.Error("failed to obtain entries. Error:", err)
|
||||
}
|
||||
|
||||
mr := es0[0].Reply
|
||||
|
||||
mr.ID = es0[0].ID
|
||||
mr.Message = "bur"
|
||||
|
||||
nr, err := db.UpdateReply(mr)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if nr.Message != mr.Message {
|
||||
t.Error(nr.Message, "!=", mr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqliteDeleteEntry(t *testing.T) {
|
||||
db, err := genTestDB()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
defer func() {
|
||||
if err = db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
|
||||
dc, err := db.DeleteEntry(1)
|
||||
if err != nil {
|
||||
t.Error("cannot delete entry", err, dc)
|
||||
}
|
||||
|
||||
if c, err := db.Count(); err != nil || c != 0 {
|
||||
t.Error(c, err, dc)
|
||||
}
|
||||
|
||||
}
|
8
pkg/database/sqlite/queries/entryUpdate.sql
Normal file
8
pkg/database/sqlite/queries/entryUpdate.sql
Normal file
@ -0,0 +1,8 @@
|
||||
UPDATE OR REPLACE `entry`
|
||||
SET
|
||||
`name` = ?,
|
||||
`message` = ?,
|
||||
`website` = ?,
|
||||
`hide_website` = ?
|
||||
WHERE `entry_id` = ?
|
||||
RETURNING `entry_id`, `created`, `name`, `message`, `website`, `hide_website`;
|
6
pkg/database/sqlite/queries/replyUpdate.sql
Normal file
6
pkg/database/sqlite/queries/replyUpdate.sql
Normal file
@ -0,0 +1,6 @@
|
||||
UPDATE OR REPLACE `reply`
|
||||
SET
|
||||
`created` = ?,
|
||||
`message` = ?
|
||||
WHERE `entry_id` = ?
|
||||
RETURNING `entry_id`, `created`, `message`;
|
@ -6,10 +6,10 @@ type Guestbook interface {
|
||||
Entries(page, pageSize int64) ([]*Entry, error)
|
||||
Count() (int64, error)
|
||||
NewEntry(entry *Entry) error
|
||||
UpdateEntry(entryID int64, entry *Entry) (bool, error)
|
||||
UpdateEntry(entry *Entry) (*Entry, error)
|
||||
DeleteEntry(entryID int64) error
|
||||
NewReply(reply *Reply) error
|
||||
UpdateReply(entryID int64, reply *Reply) (bool, error)
|
||||
UpdateReply(reply *Reply) (*Entry, error)
|
||||
DeleteReply(entryID int64) error
|
||||
Close() error
|
||||
}
|
@ -6,7 +6,7 @@ import (
|
||||
)
|
||||
|
||||
type Reply struct {
|
||||
ID int64 `json:"id,omitempty"`
|
||||
ID int64 `json:"-"`
|
||||
Created string `json:"created,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
@ -1,20 +0,0 @@
|
||||
package justcaptcha
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// CheckCaptcha performs a request to a justcaptcha service and returns wether
|
||||
// CAPTCHA was solved or not. If there is a problem with connection to the
|
||||
// service it will return an error.
|
||||
func CheckCaptcha(id string, serviceURL string) (bool, error) {
|
||||
path, _ := url.JoinPath(serviceURL, id)
|
||||
|
||||
r, err := http.Get(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return r.StatusCode == 202, nil
|
||||
}
|
@ -1,88 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
)
|
||||
|
||||
type HttpServer struct {
|
||||
server *http.Server
|
||||
router *httprouter.Router
|
||||
}
|
||||
|
||||
func NewHttpServer() *HttpServer {
|
||||
r := httprouter.New()
|
||||
return &HttpServer{
|
||||
server: &http.Server{
|
||||
ReadTimeout: 3 * time.Second,
|
||||
WriteTimeout: 3 * time.Second,
|
||||
Handler: r,
|
||||
},
|
||||
router: r,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *HttpServer) GET(path string, handler http.HandlerFunc) {
|
||||
s.router.Handler(http.MethodGet, path, handler)
|
||||
}
|
||||
|
||||
func (s *HttpServer) POST(path string, handler http.HandlerFunc) {
|
||||
s.router.Handler(http.MethodPost, path, handler)
|
||||
}
|
||||
|
||||
func (s *HttpServer) PATCH(path string, handler http.HandlerFunc) {
|
||||
s.router.Handler(http.MethodPatch, path, handler)
|
||||
}
|
||||
|
||||
func (s *HttpServer) PUT(path string, handler http.HandlerFunc) {
|
||||
s.router.Handler(http.MethodPut, path, handler)
|
||||
}
|
||||
|
||||
func (s *HttpServer) DELETE(path string, handler http.HandlerFunc) {
|
||||
s.router.Handler(http.MethodDelete, path, handler)
|
||||
}
|
||||
|
||||
func (s *HttpServer) SetNotFoundHandler(handler http.HandlerFunc) {
|
||||
s.router.NotFound = handler
|
||||
}
|
||||
|
||||
// GetURLParam wrapper around underlying router for getting URL parameters.
|
||||
func GetURLParam(r *http.Request, param string) string {
|
||||
return httprouter.ParamsFromContext(r.Context()).ByName(param)
|
||||
}
|
||||
|
||||
func (s *HttpServer) Start(network, address string) error {
|
||||
listener, err := net.Listen(network, address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if listener.Addr().Network() == "unix" {
|
||||
os.Chmod(address, 0777)
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err = s.server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *HttpServer) Stop() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := s.server.Shutdown(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
Loading…
Reference in New Issue
Block a user