Initial commit.
This commit is contained in:
commit
c6e719b5c1
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
bin/*
|
||||
!bin/.keep
|
||||
.vscode
|
||||
*.test.*
|
||||
test.sqlite*
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
The MIT License
|
||||
|
||||
Copyright (c) 2022 Alexander "Arav" Andreev <me@arav.top>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
29
Makefile
Executable file
29
Makefile
Executable file
@ -0,0 +1,29 @@
|
||||
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
|
87
README.md
Normal file
87
README.md
Normal file
@ -0,0 +1,87 @@
|
||||
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.
|
||||
- `404` if there's no 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
|
||||
- `204` 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.
|
||||
- `500` if there are internal problems with service.
|
30
build/archlinux/PKGBUILD
Normal file
30
build/archlinux/PKGBUILD
Normal file
@ -0,0 +1,30 @@
|
||||
# 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
|
||||
}
|
91
cmd/justguestbookd/main.go
Normal file
91
cmd/justguestbookd/main.go
Normal file
@ -0,0 +1,91 @@
|
||||
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(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
hand, err := handlers.New(config.Owner.Name, config.Owner.Password, config.AnonymousPosterName, config.PageSize, db, config.CaptchaAddr)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
srv := server.NewHttpServer()
|
||||
|
||||
srv.GET("/", hand.Entries)
|
||||
srv.POST("/", hand.New)
|
||||
srv.DELETE("/:entry", hand.Delete)
|
||||
srv.POST("/:entry/reply", hand.Reply)
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
15
configs/config.yaml
Normal file
15
configs/config.yaml
Normal file
@ -0,0 +1,15 @@
|
||||
# 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
|
||||
captcha_addr: "/var/run/justcaptcha/c.sock"
|
10
go.mod
Normal file
10
go.mod
Normal file
@ -0,0 +1,10 @@
|
||||
module justguestbook
|
||||
|
||||
go 1.19
|
||||
|
||||
require (
|
||||
github.com/julienschmidt/httprouter v1.3.0
|
||||
github.com/mattn/go-sqlite3 v1.14.15
|
||||
github.com/pkg/errors v0.9.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
10
go.sum
Normal file
10
go.sum
Normal file
@ -0,0 +1,10 @@
|
||||
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/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=
|
39
init/systemd.service
Executable file
39
init/systemd.service
Executable file
@ -0,0 +1,39 @@
|
||||
[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
|
45
internal/configuration/configuration.go
Normal file
45
internal/configuration/configuration.go
Normal file
@ -0,0 +1,45 @@
|
||||
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]
|
||||
}
|
175
internal/database/sqlite/database.go
Normal file
175
internal/database/sqlite/database.go
Normal file
@ -0,0 +1,175 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"justguestbook/internal/guestbook"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed queries/schema.sql
|
||||
queryCreateDatabase string
|
||||
|
||||
//go:embed queries/entryGetAll.sql
|
||||
queryGetEntries string
|
||||
|
||||
//go:embed queries/entryNew.sql
|
||||
queryNewEntry string
|
||||
//go:embed queries/entryDelete.sql
|
||||
queryDeleteEntry string
|
||||
//go:embed queries/replyNew.sql
|
||||
queryNewReply string
|
||||
//go:embed queries/replyDelete.sql
|
||||
queryDeleteReply string
|
||||
)
|
||||
|
||||
var (
|
||||
stmtGetEntries *sql.Stmt
|
||||
stmtNewEntry *sql.Stmt
|
||||
stmtDeleteEntry *sql.Stmt
|
||||
stmtNewReply *sql.Stmt
|
||||
stmtDeleteReply *sql.Stmt
|
||||
)
|
||||
|
||||
func initDBStatements(db *sql.DB) error {
|
||||
_, err := db.Exec(queryCreateDatabase)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stmtGetEntries, err = db.Prepare(queryGetEntries)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stmtNewEntry, err = db.Prepare(queryNewEntry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stmtDeleteEntry, err = db.Prepare(queryDeleteEntry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stmtNewReply, err = db.Prepare(queryNewReply)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stmtDeleteReply, err = db.Prepare(queryDeleteReply)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type SQLiteDatabase struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func New(filePath string) (*SQLiteDatabase, error) {
|
||||
db, err := sql.Open("sqlite3", dsn(filePath))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := initDBStatements(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &SQLiteDatabase{db: db}, nil
|
||||
}
|
||||
|
||||
func (d *SQLiteDatabase) Entries(page, pageSize int64) (entries []*guestbook.Entry, err error) {
|
||||
tx, err := d.db.Begin()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
rows, err := tx.Stmt(stmtGetEntries).Query(pageSize, (page-1)*pageSize)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var entry guestbook.Entry
|
||||
var reply_created sql.NullString
|
||||
var reply_message sql.NullString
|
||||
if err = rows.Scan(
|
||||
&entry.ID, &entry.Created, &entry.Name,
|
||||
&entry.Website, &entry.Message,
|
||||
&reply_created, &reply_message); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if reply_message.Valid /* reply_created is also valid if reply is */ {
|
||||
entry.Reply = &guestbook.Reply{
|
||||
Created: reply_created.String,
|
||||
Message: reply_message.String}
|
||||
}
|
||||
|
||||
entries = append(entries, &entry)
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (d *SQLiteDatabase) NewEntry(entry *guestbook.Entry) error {
|
||||
tx, err := d.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.Stmt(stmtNewEntry).Exec(entry.Created, entry.Name, entry.Message,
|
||||
entry.Website, entry.HideWebsite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *SQLiteDatabase) DeleteEntry(entryID int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *SQLiteDatabase) NewReply(reply *guestbook.Reply) (err error) {
|
||||
tx, err := d.db.Begin()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.Stmt(stmtNewReply).Exec(reply.Created, reply.ID, reply.Message)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (d *SQLiteDatabase) DeleteReply(entryID int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *SQLiteDatabase) Close() error {
|
||||
return d.db.Close()
|
||||
}
|
||||
|
||||
func dsn(filePath string) string {
|
||||
return fmt.Sprintf("file:%s?_journal=WAL&_mutex=full", filePath)
|
||||
}
|
2
internal/database/sqlite/queries/entryDelete.sql
Normal file
2
internal/database/sqlite/queries/entryDelete.sql
Normal file
@ -0,0 +1,2 @@
|
||||
DELETE FROM `entry`
|
||||
WHERE `entry_id` = ?;
|
18
internal/database/sqlite/queries/entryGetAll.sql
Normal file
18
internal/database/sqlite/queries/entryGetAll.sql
Normal file
@ -0,0 +1,18 @@
|
||||
SELECT
|
||||
`entry`.`entry_id`,
|
||||
`entry`.`created`,
|
||||
`entry`.`name`,
|
||||
(CASE
|
||||
WHEN `entry`.`hide_website` IS FALSE
|
||||
THEN `entry`.`website`
|
||||
ELSE ''
|
||||
END) AS `website`,
|
||||
`entry`.`message`,
|
||||
`reply`.`created` AS `reply_created`,
|
||||
`reply`.`message` AS `reply_message`
|
||||
FROM `entry`
|
||||
LEFT JOIN `reply`
|
||||
ON `entry`.`entry_id` = `reply`.`entry_id`
|
||||
ORDER BY `entry`.`entry_id` DESC
|
||||
LIMIT ?
|
||||
OFFSET ?;
|
4
internal/database/sqlite/queries/entryNew.sql
Normal file
4
internal/database/sqlite/queries/entryNew.sql
Normal file
@ -0,0 +1,4 @@
|
||||
INSERT INTO `entry`
|
||||
(`created`, `name`, `message`, `website`, `hide_website`)
|
||||
VALUES
|
||||
(?, ?, ?, ?, ?);
|
2
internal/database/sqlite/queries/replyDelete.sql
Normal file
2
internal/database/sqlite/queries/replyDelete.sql
Normal file
@ -0,0 +1,2 @@
|
||||
DELETE FROM `reply`
|
||||
WHERE `entry_id` = ?;
|
4
internal/database/sqlite/queries/replyNew.sql
Normal file
4
internal/database/sqlite/queries/replyNew.sql
Normal file
@ -0,0 +1,4 @@
|
||||
INSERT INTO `reply`
|
||||
(`created`, `entry_id`, `message`)
|
||||
VALUES
|
||||
(?, ?, ?);
|
20
internal/database/sqlite/queries/schema.sql
Normal file
20
internal/database/sqlite/queries/schema.sql
Normal file
@ -0,0 +1,20 @@
|
||||
-- SQLite3
|
||||
CREATE TABLE IF NOT EXISTS `entry` (
|
||||
`entry_id` INTEGER PRIMARY KEY NOT NULL,
|
||||
`created` TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`name` TEXT NOT NULL,
|
||||
`message` TEXT NOT NULL,
|
||||
`website` TEXT NOT NULL,
|
||||
`hide_website` INTEGER NOT NULL DEFAULT TRUE);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `entry_created_idx`
|
||||
ON `entry` (`created`);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `reply` (
|
||||
`entry_id` INTEGER PRIMARY KEY NOT NULL,
|
||||
`created` TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`message` TEXT NOT NULL,
|
||||
FOREIGN KEY (`entry_id`)
|
||||
REFERENCES `entry` (`entry_id`)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE );
|
12
internal/guestbook/database.go
Normal file
12
internal/guestbook/database.go
Normal file
@ -0,0 +1,12 @@
|
||||
package guestbook
|
||||
|
||||
const DateFormat = "2006-01-02 15:04:05"
|
||||
|
||||
type Guestbook interface {
|
||||
Entries(page, pageSize int64) ([]*Entry, error)
|
||||
NewEntry(entry *Entry) error
|
||||
DeleteEntry(entryID int64) error
|
||||
NewReply(reply *Reply) error
|
||||
DeleteReply(entryID int64) error
|
||||
Close() error
|
||||
}
|
29
internal/guestbook/entry.go
Normal file
29
internal/guestbook/entry.go
Normal file
@ -0,0 +1,29 @@
|
||||
package guestbook
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
ID int64 `json:"entry_id"`
|
||||
Created string `json:"created"`
|
||||
Name string `json:"name"`
|
||||
Website string `json:"website,omitempty"`
|
||||
Message string `json:"message"`
|
||||
HideWebsite bool `json:"hide_website,omitempty"`
|
||||
Reply *Reply `json:"reply,omitempty"`
|
||||
}
|
||||
|
||||
func NewEntry(name, website, message string, hideWebsite bool) (*Entry, error) {
|
||||
if name == "" || message == "" {
|
||||
return nil, errors.New("name and message field are required")
|
||||
}
|
||||
|
||||
return &Entry{
|
||||
Created: time.Now().UTC().Format(DateFormat),
|
||||
Name: name,
|
||||
Website: website,
|
||||
HideWebsite: hideWebsite,
|
||||
Message: message}, nil
|
||||
}
|
23
internal/guestbook/reply.go
Normal file
23
internal/guestbook/reply.go
Normal file
@ -0,0 +1,23 @@
|
||||
package guestbook
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Reply struct {
|
||||
ID int64 `json:"-"`
|
||||
Created string `json:"created,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func NewReply(entryID int64, message string) (*Reply, error) {
|
||||
if message == "" {
|
||||
return nil, errors.New("empty reply field")
|
||||
}
|
||||
|
||||
return &Reply{
|
||||
ID: entryID,
|
||||
Created: time.Now().UTC().Format(DateFormat),
|
||||
Message: message}, nil
|
||||
}
|
208
internal/handlers/handlers.go
Normal file
208
internal/handlers/handlers.go
Normal file
@ -0,0 +1,208 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"justguestbook/internal/guestbook"
|
||||
"justguestbook/pkg/server"
|
||||
"justguestbook/pkg/server/justcaptcha"
|
||||
"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, error) {
|
||||
return &GuestbookHandlers{
|
||||
owner: owner,
|
||||
password: password,
|
||||
anonymousName: anonymousName,
|
||||
defaultPageSize: defaultPageSize,
|
||||
db: guestbook,
|
||||
captchaAddr: captchaAddr}, nil
|
||||
}
|
||||
|
||||
func (h *GuestbookHandlers) Entries(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
var page_num int64 = 1
|
||||
var page_size int64 = h.defaultPageSize
|
||||
|
||||
if r.URL.Query().Get("p") != "" {
|
||||
page_num, err = strconv.ParseInt(r.URL.Query().Get("p"), 10, 32)
|
||||
if err != nil {
|
||||
page_num = 1
|
||||
}
|
||||
}
|
||||
|
||||
if r.URL.Query().Get("ps") != "" {
|
||||
page_size, err = strconv.ParseInt(r.URL.Query().Get("ps"), 10, 32)
|
||||
if err != nil {
|
||||
page_size = h.defaultPageSize
|
||||
}
|
||||
}
|
||||
|
||||
entries, err := h.db.Entries(page_num, page_size)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprint(w, err)
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
guestbookEntries := struct {
|
||||
Owner string `json:"owner"`
|
||||
Entries []*guestbook.Entry `json:"entries"`
|
||||
}{
|
||||
Owner: h.owner,
|
||||
Entries: entries}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(&guestbookEntries)
|
||||
}
|
||||
|
||||
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 {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprint(w, 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("website"), r.FormValue("message"), len(r.FormValue("hide_website")) != 0)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
fmt.Fprint(w, err)
|
||||
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 {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprint(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
solved, err := justcaptcha.CheckCaptcha(cid.CaptchaID, h.captchaAddr)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprint(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !solved {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(entry); err != nil {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
fmt.Fprint(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err = h.db.NewEntry(entry)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprint(w, entry.Message)
|
||||
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 {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
fmt.Fprint(w, err)
|
||||
}
|
||||
|
||||
if r.Header.Get("Content-Type") == "application/x-www-form-urlencoded" {
|
||||
r.ParseForm()
|
||||
|
||||
reply, err = guestbook.NewReply(id, r.FormValue("reply"))
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
fmt.Fprint(w, err)
|
||||
}
|
||||
} else if r.Header.Get("Content-Type") == "application/json" {
|
||||
if err := json.NewDecoder(r.Body).Decode(reply); err != nil {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
fmt.Fprint(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.db.NewReply(reply); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprint(w, err)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
fmt.Fprint(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasSuffix(r.URL.Path, "reply") {
|
||||
if err := h.db.DeleteReply(entryID); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprint(w, err)
|
||||
}
|
||||
} else {
|
||||
if err := h.db.DeleteEntry(entryID); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprint(w, err)
|
||||
}
|
||||
}
|
||||
}
|
84
pkg/server/http.go
Normal file
84
pkg/server/http.go
Normal file
@ -0,0 +1,84 @@
|
||||
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) 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
|
||||
}
|
33
pkg/server/justcaptcha/justcaptcha.go
Normal file
33
pkg/server/justcaptcha/justcaptcha.go
Normal file
@ -0,0 +1,33 @@
|
||||
package justcaptcha
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func CheckCaptcha(id string, url string) (bool, error) {
|
||||
var c http.Client
|
||||
var r *http.Response
|
||||
var err error
|
||||
|
||||
if strings.Contains(url, ":") {
|
||||
c = http.Client{}
|
||||
} else {
|
||||
c = http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(_ context.Context, _, addr string) (net.Conn, error) {
|
||||
return net.Dial("unix", addr)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
r, err = c.Get(url)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return r.StatusCode == 200, nil
|
||||
}
|
19
test/curl_new.sh
Executable file
19
test/curl_new.sh
Executable file
@ -0,0 +1,19 @@
|
||||
#!/usr/bin/sh
|
||||
|
||||
addr="http://127.0.0.1:18888"
|
||||
pass="1234"
|
||||
|
||||
case $1 in
|
||||
get)
|
||||
curl $addr/
|
||||
break
|
||||
;;
|
||||
new)
|
||||
curl -d "name=$2&message=$3" $addr/
|
||||
break
|
||||
;;
|
||||
reply)
|
||||
curl -H "X-Password: $pass" -d "reply=$3" $addr/$2/reply
|
||||
break
|
||||
;;
|
||||
esac
|
Loading…
Reference in New Issue
Block a user