1
0

This project was made into a library.

This commit is contained in:
Alexander Andreev 2023-01-12 03:27:45 +04:00
parent be5d0782b6
commit 4bb06cb2a1
Signed by: Arav
GPG Key ID: 0388CC8FAA51063F
29 changed files with 245 additions and 767 deletions

View File

@ -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

View File

@ -1,86 +1,4 @@
justguestbook ver. 1.0.0 justguestbook ver. 1.0.0
======================== ========================
## Usage A library implementing simple guestbook with replies.
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.

View File

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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
View File

@ -1,10 +1,8 @@
module justguestbook module git.arav.top/Arav/justguestbook
go 1.19 go 1.19
require ( require (
github.com/julienschmidt/httprouter v1.3.0 github.com/mattn/go-sqlite3 v1.14.16
github.com/mattn/go-sqlite3 v1.14.15
github.com/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1
gopkg.in/yaml.v3 v3.0.1
) )

10
go.sum
View File

@ -1,10 +1,4 @@
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
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/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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=

View File

@ -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

View File

@ -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]
}

View File

@ -1,8 +0,0 @@
UPDATE OR REPLACE `entry`
SET
`entry_id` = ?
`name` = ?,
`message` = ?,
`website` = ?,
`hide_website` = ?,
WHERE `entry_id` = ?;

View File

@ -1,6 +0,0 @@
UPDATE OR REPLACE `reply`
SET
`entry_id` = ?,
`created` = ?,
`message` = ?,
WHERE `entry_id` = ?;

View File

@ -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)
}
}
}

View File

@ -4,17 +4,18 @@ import (
"database/sql" "database/sql"
_ "embed" _ "embed"
"fmt" "fmt"
"justguestbook/internal/guestbook"
"git.arav.top/Arav/justguestbook/pkg/guestbook"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
"github.com/pkg/errors"
) )
var ( var (
//go:embed queries/schema.sql //go:embed queries/schema.sql
queryCreateDatabase string querySchema string
//go:embed queries/entryGetAll.sql //go:embed queries/entryGetAll.sql
queryGetEntries string queryGetAll string
//go:embed queries/entryCount.sql //go:embed queries/entryCount.sql
queryCount string queryCount string
@ -33,7 +34,7 @@ var (
) )
var ( var (
stmtGetEntries *sql.Stmt stmtGetAll *sql.Stmt
stmtCount *sql.Stmt stmtCount *sql.Stmt
stmtNewEntry *sql.Stmt stmtNewEntry *sql.Stmt
stmtUpdateEntry *sql.Stmt stmtUpdateEntry *sql.Stmt
@ -44,49 +45,51 @@ var (
) )
func initDBStatements(db *sql.DB) error { func initDBStatements(db *sql.DB) error {
_, err := db.Exec(queryCreateDatabase) db.Exec("PRAGMA foreign_keys = ON;")
_, err := db.Exec(querySchema)
if err != nil { 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 { if err != nil {
return err return errors.Wrap(err, "failed to prepare queryGetAll")
} }
stmtCount, err = db.Prepare(queryCount) stmtCount, err = db.Prepare(queryCount)
if err != nil { if err != nil {
return err return errors.Wrap(err, "failed to prepare queryCount")
} }
stmtNewEntry, err = db.Prepare(queryNewEntry) stmtNewEntry, err = db.Prepare(queryNewEntry)
if err != nil { if err != nil {
return err return errors.Wrap(err, "failed to prepare queryNewEntry")
} }
stmtUpdateEntry, err = db.Prepare(queryUpdateEntry) stmtUpdateEntry, err = db.Prepare(queryUpdateEntry)
if err != nil { if err != nil {
return err return errors.Wrap(err, "failed to prepare queryUpdateEntry")
} }
stmtDeleteEntry, err = db.Prepare(queryDeleteEntry) stmtDeleteEntry, err = db.Prepare(queryDeleteEntry)
if err != nil { if err != nil {
return err return errors.Wrap(err, "failed to prepare queryDeleteEntry")
} }
stmtNewReply, err = db.Prepare(queryNewReply) stmtNewReply, err = db.Prepare(queryNewReply)
if err != nil { if err != nil {
return err return errors.Wrap(err, "failed to prepare queryNewReply")
} }
stmtUpdateReply, err = db.Prepare(queryUpdateReply) stmtUpdateReply, err = db.Prepare(queryUpdateReply)
if err != nil { if err != nil {
return err return errors.Wrap(err, "failed to prepare queryUpdateReply")
} }
stmtDeleteReply, err = db.Prepare(queryDeleteReply) stmtDeleteReply, err = db.Prepare(queryDeleteReply)
if err != nil { if err != nil {
return err return errors.Wrap(err, "failed to prepare queryDeleteReply")
} }
return nil return nil
@ -116,7 +119,7 @@ func (d *SQLiteDatabase) Entries(page, pageSize int64) (entries []*guestbook.Ent
} }
defer tx.Rollback() 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 { if err != nil {
return return
} }
@ -128,13 +131,14 @@ func (d *SQLiteDatabase) Entries(page, pageSize int64) (entries []*guestbook.Ent
var reply_message sql.NullString var reply_message sql.NullString
if err = rows.Scan( if err = rows.Scan(
&entry.ID, &entry.Created, &entry.Name, &entry.ID, &entry.Created, &entry.Name,
&entry.Website, &entry.Message, &entry.Message, &entry.Website,
&reply_created, &reply_message); err != nil { &reply_created, &reply_message); err != nil {
return return
} }
if reply_message.Valid /* reply_created is also valid if reply is */ { if reply_message.Valid /* reply_created is also valid if reply is */ {
entry.Reply = &guestbook.Reply{ entry.Reply = &guestbook.Reply{
ID: entry.ID,
Created: reply_created.String, Created: reply_created.String,
Message: reply_message.String} Message: reply_message.String}
} }
@ -147,22 +151,25 @@ func (d *SQLiteDatabase) Entries(page, pageSize int64) (entries []*guestbook.Ent
return 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() tx, err := d.db.Begin()
if err != nil { if err != nil {
return -1, err return -1, err
} }
defer tx.Rollback() defer tx.Rollback()
var count int64
err = tx.Stmt(stmtCount).QueryRow().Scan(&count) err = tx.Stmt(stmtCount).QueryRow().Scan(&count)
if err != nil { if err != nil {
return -1, err return -1, err
} }
tx.Commit()
return count, nil return count, nil
} }
// NewEntry inserts a passed Entry struct and fills its ID field if successful.
func (d *SQLiteDatabase) NewEntry(entry *guestbook.Entry) error { func (d *SQLiteDatabase) NewEntry(entry *guestbook.Entry) error {
tx, err := d.db.Begin() tx, err := d.db.Begin()
if err != nil { if err != nil {
@ -170,93 +177,99 @@ func (d *SQLiteDatabase) NewEntry(entry *guestbook.Entry) error {
} }
defer tx.Rollback() 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) entry.Website, entry.HideWebsite)
if err != nil { if err != nil {
return err return err
} }
entry.ID, err = r.LastInsertId()
if err != nil {
return err
}
tx.Commit() tx.Commit()
return nil 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() tx, err := d.db.Begin()
if err != nil { if err != nil {
return false, err return nil, err
} }
defer tx.Rollback() 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 { if err != nil {
return false, err return nil, err
} }
ra, err := res.RowsAffected() tx.Commit()
if err != nil {
return false, err
}
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() tx, err := d.db.Begin()
if err != nil { if err != nil {
return err return -1, err
} }
defer tx.Rollback() defer tx.Rollback()
res, err := tx.Stmt(stmtDeleteEntry).Exec(entryID) res, err := tx.Stmt(stmtDeleteEntry).Exec(entryID)
if err != nil { if err != nil {
return err return -1, err
} }
_, err = res.RowsAffected() c, err := res.RowsAffected()
if err != nil { 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() tx, err := d.db.Begin()
if err != nil { if err != nil {
return return err
} }
defer tx.Rollback() defer tx.Rollback()
_, err = tx.Stmt(stmtNewReply).Exec(reply.ID, reply.Created, reply.Message) _, err = tx.Stmt(stmtNewReply).Exec(reply.ID, reply.Created, reply.Message)
if err != nil { if err != nil {
return return err
} }
tx.Commit() tx.Commit()
return return nil
} }
// UpdateEntry func (d *SQLiteDatabase) UpdateReply(reply *guestbook.Reply) (*guestbook.Reply, error) {
func (d *SQLiteDatabase) UpdateReply(entryID int64, reply *guestbook.Reply) (bool, error) {
tx, err := d.db.Begin() tx, err := d.db.Begin()
if err != nil { if err != nil {
return false, err return nil, err
} }
defer tx.Rollback() 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 { if err != nil {
return false, err return nil, err
} }
ra, err := res.RowsAffected() tx.Commit()
if err != nil {
return false, err
}
return ra > 0, nil return &uReply, nil
} }
func (d *SQLiteDatabase) DeleteReply(entryID int64) error { func (d *SQLiteDatabase) DeleteReply(entryID int64) error {
@ -276,10 +289,21 @@ func (d *SQLiteDatabase) DeleteReply(entryID int64) error {
return err return err
} }
tx.Commit()
return nil return nil
} }
func (d *SQLiteDatabase) Close() error { 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() return d.db.Close()
} }

View 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)
}
}

View 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`;

View File

@ -0,0 +1,6 @@
UPDATE OR REPLACE `reply`
SET
`created` = ?,
`message` = ?
WHERE `entry_id` = ?
RETURNING `entry_id`, `created`, `message`;

View File

@ -6,10 +6,10 @@ type Guestbook interface {
Entries(page, pageSize int64) ([]*Entry, error) Entries(page, pageSize int64) ([]*Entry, error)
Count() (int64, error) Count() (int64, error)
NewEntry(entry *Entry) error NewEntry(entry *Entry) error
UpdateEntry(entryID int64, entry *Entry) (bool, error) UpdateEntry(entry *Entry) (*Entry, error)
DeleteEntry(entryID int64) error DeleteEntry(entryID int64) error
NewReply(reply *Reply) error NewReply(reply *Reply) error
UpdateReply(entryID int64, reply *Reply) (bool, error) UpdateReply(reply *Reply) (*Entry, error)
DeleteReply(entryID int64) error DeleteReply(entryID int64) error
Close() error Close() error
} }

View File

@ -6,7 +6,7 @@ import (
) )
type Reply struct { type Reply struct {
ID int64 `json:"id,omitempty"` ID int64 `json:"-"`
Created string `json:"created,omitempty"` Created string `json:"created,omitempty"`
Message string `json:"message"` Message string `json:"message"`
} }

View File

@ -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
}

View File

@ -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
}