From c6e719b5c149cae3c942928ebb39c04b17aaa7f8 Mon Sep 17 00:00:00 2001 From: "Alexander \"Arav\" Andreev" Date: Wed, 19 Oct 2022 03:25:43 +0400 Subject: [PATCH] Initial commit. --- .gitignore | 5 + LICENSE | 21 ++ Makefile | 29 +++ README.md | 87 ++++++++ bin/.keep | 0 build/archlinux/PKGBUILD | 30 +++ cmd/justguestbookd/main.go | 91 ++++++++ configs/config.yaml | 15 ++ go.mod | 10 + go.sum | 10 + init/systemd.service | 39 ++++ internal/configuration/configuration.go | 45 ++++ internal/database/sqlite/database.go | 175 +++++++++++++++ .../database/sqlite/queries/entryDelete.sql | 2 + .../database/sqlite/queries/entryGetAll.sql | 18 ++ internal/database/sqlite/queries/entryNew.sql | 4 + .../database/sqlite/queries/replyDelete.sql | 2 + internal/database/sqlite/queries/replyNew.sql | 4 + internal/database/sqlite/queries/schema.sql | 20 ++ internal/guestbook/database.go | 12 + internal/guestbook/entry.go | 29 +++ internal/guestbook/reply.go | 23 ++ internal/handlers/handlers.go | 208 ++++++++++++++++++ pkg/server/http.go | 84 +++++++ pkg/server/justcaptcha/justcaptcha.go | 33 +++ test/curl_new.sh | 19 ++ 26 files changed, 1015 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100755 Makefile create mode 100644 README.md create mode 100644 bin/.keep create mode 100644 build/archlinux/PKGBUILD create mode 100644 cmd/justguestbookd/main.go create mode 100644 configs/config.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100755 init/systemd.service create mode 100644 internal/configuration/configuration.go create mode 100644 internal/database/sqlite/database.go create mode 100644 internal/database/sqlite/queries/entryDelete.sql create mode 100644 internal/database/sqlite/queries/entryGetAll.sql create mode 100644 internal/database/sqlite/queries/entryNew.sql create mode 100644 internal/database/sqlite/queries/replyDelete.sql create mode 100644 internal/database/sqlite/queries/replyNew.sql create mode 100644 internal/database/sqlite/queries/schema.sql create mode 100644 internal/guestbook/database.go create mode 100644 internal/guestbook/entry.go create mode 100644 internal/guestbook/reply.go create mode 100644 internal/handlers/handlers.go create mode 100644 pkg/server/http.go create mode 100644 pkg/server/justcaptcha/justcaptcha.go create mode 100755 test/curl_new.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8aa2598 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +bin/* +!bin/.keep +.vscode +*.test.* +test.sqlite* \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..44428c2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2022 Alexander "Arav" Andreev + +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. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100755 index 0000000..84c3ebe --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..d902546 --- /dev/null +++ b/README.md @@ -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. \ No newline at end of file diff --git a/bin/.keep b/bin/.keep new file mode 100644 index 0000000..e69de29 diff --git a/build/archlinux/PKGBUILD b/build/archlinux/PKGBUILD new file mode 100644 index 0000000..8d46884 --- /dev/null +++ b/build/archlinux/PKGBUILD @@ -0,0 +1,30 @@ +# Maintainer: Alexander "Arav" Andreev +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 +} \ No newline at end of file diff --git a/cmd/justguestbookd/main.go b/cmd/justguestbookd/main.go new file mode 100644 index 0000000..88bb17e --- /dev/null +++ b/cmd/justguestbookd/main.go @@ -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 ") + 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) + } + +} diff --git a/configs/config.yaml b/configs/config.yaml new file mode 100644 index 0000000..25a6019 --- /dev/null +++ b/configs/config.yaml @@ -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" \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3a5ddff --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a288b3e --- /dev/null +++ b/go.sum @@ -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= diff --git a/init/systemd.service b/init/systemd.service new file mode 100755 index 0000000..524bfc8 --- /dev/null +++ b/init/systemd.service @@ -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 diff --git a/internal/configuration/configuration.go b/internal/configuration/configuration.go new file mode 100644 index 0000000..ebda9c9 --- /dev/null +++ b/internal/configuration/configuration.go @@ -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] +} diff --git a/internal/database/sqlite/database.go b/internal/database/sqlite/database.go new file mode 100644 index 0000000..74e8672 --- /dev/null +++ b/internal/database/sqlite/database.go @@ -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) +} diff --git a/internal/database/sqlite/queries/entryDelete.sql b/internal/database/sqlite/queries/entryDelete.sql new file mode 100644 index 0000000..75ce8cd --- /dev/null +++ b/internal/database/sqlite/queries/entryDelete.sql @@ -0,0 +1,2 @@ +DELETE FROM `entry` + WHERE `entry_id` = ?; \ No newline at end of file diff --git a/internal/database/sqlite/queries/entryGetAll.sql b/internal/database/sqlite/queries/entryGetAll.sql new file mode 100644 index 0000000..60b0f61 --- /dev/null +++ b/internal/database/sqlite/queries/entryGetAll.sql @@ -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 ?; \ No newline at end of file diff --git a/internal/database/sqlite/queries/entryNew.sql b/internal/database/sqlite/queries/entryNew.sql new file mode 100644 index 0000000..d2e3f0d --- /dev/null +++ b/internal/database/sqlite/queries/entryNew.sql @@ -0,0 +1,4 @@ +INSERT INTO `entry` + (`created`, `name`, `message`, `website`, `hide_website`) +VALUES + (?, ?, ?, ?, ?); \ No newline at end of file diff --git a/internal/database/sqlite/queries/replyDelete.sql b/internal/database/sqlite/queries/replyDelete.sql new file mode 100644 index 0000000..105a925 --- /dev/null +++ b/internal/database/sqlite/queries/replyDelete.sql @@ -0,0 +1,2 @@ +DELETE FROM `reply` + WHERE `entry_id` = ?; \ No newline at end of file diff --git a/internal/database/sqlite/queries/replyNew.sql b/internal/database/sqlite/queries/replyNew.sql new file mode 100644 index 0000000..04079ed --- /dev/null +++ b/internal/database/sqlite/queries/replyNew.sql @@ -0,0 +1,4 @@ +INSERT INTO `reply` + (`created`, `entry_id`, `message`) +VALUES + (?, ?, ?); \ No newline at end of file diff --git a/internal/database/sqlite/queries/schema.sql b/internal/database/sqlite/queries/schema.sql new file mode 100644 index 0000000..0676ad1 --- /dev/null +++ b/internal/database/sqlite/queries/schema.sql @@ -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 ); \ No newline at end of file diff --git a/internal/guestbook/database.go b/internal/guestbook/database.go new file mode 100644 index 0000000..82d81d3 --- /dev/null +++ b/internal/guestbook/database.go @@ -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 +} diff --git a/internal/guestbook/entry.go b/internal/guestbook/entry.go new file mode 100644 index 0000000..59d13b1 --- /dev/null +++ b/internal/guestbook/entry.go @@ -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 +} diff --git a/internal/guestbook/reply.go b/internal/guestbook/reply.go new file mode 100644 index 0000000..ebef0cd --- /dev/null +++ b/internal/guestbook/reply.go @@ -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 +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go new file mode 100644 index 0000000..b992edc --- /dev/null +++ b/internal/handlers/handlers.go @@ -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) + } + } +} diff --git a/pkg/server/http.go b/pkg/server/http.go new file mode 100644 index 0000000..2153352 --- /dev/null +++ b/pkg/server/http.go @@ -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 +} diff --git a/pkg/server/justcaptcha/justcaptcha.go b/pkg/server/justcaptcha/justcaptcha.go new file mode 100644 index 0000000..db4f7fb --- /dev/null +++ b/pkg/server/justcaptcha/justcaptcha.go @@ -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 +} diff --git a/test/curl_new.sh b/test/curl_new.sh new file mode 100755 index 0000000..9e412a2 --- /dev/null +++ b/test/curl_new.sh @@ -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