diff --git a/Makefile b/Makefile deleted file mode 100755 index 84c3ebe..0000000 --- a/Makefile +++ /dev/null @@ -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 diff --git a/README.md b/README.md index caeb851..e03d17c 100644 --- a/README.md +++ b/README.md @@ -1,86 +1,4 @@ justguestbook ver. 1.0.0 ======================== -## Usage - - justguestbookd -conf /etc/justguestbook.yaml - -## API summary - - GET /?p=&psz= - POST / - data: captcha_id=&name=&message=[&website=&hide_website=1] - DELETE /:entry - - POST /:entry/reply - data: reply= - DELETE /:entry/reply - -## Public API - -### Get a list of guestbook entries - - GET /?p=&psz= - -#### URL query parameters -- `p=` is a page number. By default first page will be returned. -- `psz=` is a page size. By default will be used page size set by a service. - -#### HTTP codes -- `200` if there are entries. -- `500` if for some reason a list cannot be sent. - -### Post a new guestbook entry - - POST / - -#### Fields -- `captcha_id` is an ID of CAPTCHA stored in a justcaptcha service. -- `name` of a poster. Optional. Service's default if not set. -- `message` a poster want to post. Required. -- `website` of a poster. Optional. -- `hide_website` if true hide website. Optional. Hide by default. - -#### HTTP codes -- `201` if created -- `422` if some required field doesn't present. -- `500` if for some reason post cannot be stored. - -A `500` response should return a `message` back. - -## Administration API - -All such commands must have a password supplied in `X-Password` header. - -### Send a reply to entry - - POST /:entry/reply - -#### Fields -- `message` for an entry. Required. - -#### HTTP codes -- `201` if created or modified successfully. -- `403` if unauthorised. -- `404` if there's no such entry. -- `500` if there are internal problems with service. - -### Delete an entry - - DELETE /:entry - -#### HTTP codes -- `204` if was successfully deleted. -- `403` if unauthorised. -- `404` if there's no such entry. -- `500` if there are internal problems with service. - -### Delete a reply - - DELETE /:entry/reply - -#### HTTP codes -- `204` if was successfully deleted. -- `403` if unauthorised. -- `404` if there's no such entry or reply. -- `500` if there are internal problems with service. \ No newline at end of file +A library implementing simple guestbook with replies. \ No newline at end of file diff --git a/bin/.keep b/bin/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/build/archlinux/PKGBUILD b/build/archlinux/PKGBUILD deleted file mode 100644 index 8d46884..0000000 --- a/build/archlinux/PKGBUILD +++ /dev/null @@ -1,30 +0,0 @@ -# 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 deleted file mode 100644 index 2bce76d..0000000 --- a/cmd/justguestbookd/main.go +++ /dev/null @@ -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 ") - 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) - } - -} diff --git a/configs/config.yaml b/configs/config.yaml deleted file mode 100644 index d4d9841..0000000 --- a/configs/config.yaml +++ /dev/null @@ -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/" \ No newline at end of file diff --git a/go.mod b/go.mod index 3a5ddff..35d888a 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,8 @@ -module justguestbook +module git.arav.top/Arav/justguestbook go 1.19 require ( - github.com/julienschmidt/httprouter v1.3.0 - github.com/mattn/go-sqlite3 v1.14.15 + github.com/mattn/go-sqlite3 v1.14.16 github.com/pkg/errors v0.9.1 - gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index a288b3e..de3fb0c 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,4 @@ -github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= -github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= -github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI= -github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= +github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/init/systemd.service b/init/systemd.service deleted file mode 100755 index 524bfc8..0000000 --- a/init/systemd.service +++ /dev/null @@ -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 diff --git a/internal/configuration/configuration.go b/internal/configuration/configuration.go deleted file mode 100644 index ebda9c9..0000000 --- a/internal/configuration/configuration.go +++ /dev/null @@ -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] -} diff --git a/internal/database/sqlite/queries/entryUpdate.sql b/internal/database/sqlite/queries/entryUpdate.sql deleted file mode 100644 index 3b8a33b..0000000 --- a/internal/database/sqlite/queries/entryUpdate.sql +++ /dev/null @@ -1,8 +0,0 @@ -UPDATE OR REPLACE `entry` - SET - `entry_id` = ? - `name` = ?, - `message` = ?, - `website` = ?, - `hide_website` = ?, - WHERE `entry_id` = ?; \ No newline at end of file diff --git a/internal/database/sqlite/queries/replyUpdate.sql b/internal/database/sqlite/queries/replyUpdate.sql deleted file mode 100644 index fdcb1e4..0000000 --- a/internal/database/sqlite/queries/replyUpdate.sql +++ /dev/null @@ -1,6 +0,0 @@ -UPDATE OR REPLACE `reply` - SET - `entry_id` = ?, - `created` = ?, - `message` = ?, - WHERE `entry_id` = ?; \ No newline at end of file diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go deleted file mode 100644 index 5d97731..0000000 --- a/internal/handlers/handlers.go +++ /dev/null @@ -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) - } - } -} diff --git a/internal/database/sqlite/database.go b/pkg/database/sqlite/database.go similarity index 61% rename from internal/database/sqlite/database.go rename to pkg/database/sqlite/database.go index a2737e6..ff21f04 100644 --- a/internal/database/sqlite/database.go +++ b/pkg/database/sqlite/database.go @@ -4,17 +4,18 @@ import ( "database/sql" _ "embed" "fmt" - "justguestbook/internal/guestbook" + "git.arav.top/Arav/justguestbook/pkg/guestbook" _ "github.com/mattn/go-sqlite3" + "github.com/pkg/errors" ) var ( //go:embed queries/schema.sql - queryCreateDatabase string + querySchema string //go:embed queries/entryGetAll.sql - queryGetEntries string + queryGetAll string //go:embed queries/entryCount.sql queryCount string @@ -33,7 +34,7 @@ var ( ) var ( - stmtGetEntries *sql.Stmt + stmtGetAll *sql.Stmt stmtCount *sql.Stmt stmtNewEntry *sql.Stmt stmtUpdateEntry *sql.Stmt @@ -44,49 +45,51 @@ var ( ) func initDBStatements(db *sql.DB) error { - _, err := db.Exec(queryCreateDatabase) + db.Exec("PRAGMA foreign_keys = ON;") + + _, err := db.Exec(querySchema) if err != nil { - return err + return errors.Wrap(err, "failed to init schema") } - stmtGetEntries, err = db.Prepare(queryGetEntries) + stmtGetAll, err = db.Prepare(queryGetAll) if err != nil { - return err + return errors.Wrap(err, "failed to prepare queryGetAll") } stmtCount, err = db.Prepare(queryCount) if err != nil { - return err + return errors.Wrap(err, "failed to prepare queryCount") } stmtNewEntry, err = db.Prepare(queryNewEntry) if err != nil { - return err + return errors.Wrap(err, "failed to prepare queryNewEntry") } stmtUpdateEntry, err = db.Prepare(queryUpdateEntry) if err != nil { - return err + return errors.Wrap(err, "failed to prepare queryUpdateEntry") } stmtDeleteEntry, err = db.Prepare(queryDeleteEntry) if err != nil { - return err + return errors.Wrap(err, "failed to prepare queryDeleteEntry") } stmtNewReply, err = db.Prepare(queryNewReply) if err != nil { - return err + return errors.Wrap(err, "failed to prepare queryNewReply") } stmtUpdateReply, err = db.Prepare(queryUpdateReply) if err != nil { - return err + return errors.Wrap(err, "failed to prepare queryUpdateReply") } stmtDeleteReply, err = db.Prepare(queryDeleteReply) if err != nil { - return err + return errors.Wrap(err, "failed to prepare queryDeleteReply") } return nil @@ -116,7 +119,7 @@ func (d *SQLiteDatabase) Entries(page, pageSize int64) (entries []*guestbook.Ent } defer tx.Rollback() - rows, err := tx.Stmt(stmtGetEntries).Query(pageSize, (page-1)*pageSize) + rows, err := tx.Stmt(stmtGetAll).Query(pageSize, (page-1)*pageSize) if err != nil { return } @@ -128,13 +131,14 @@ func (d *SQLiteDatabase) Entries(page, pageSize int64) (entries []*guestbook.Ent var reply_message sql.NullString if err = rows.Scan( &entry.ID, &entry.Created, &entry.Name, - &entry.Website, &entry.Message, + &entry.Message, &entry.Website, &reply_created, &reply_message); err != nil { return } if reply_message.Valid /* reply_created is also valid if reply is */ { entry.Reply = &guestbook.Reply{ + ID: entry.ID, Created: reply_created.String, Message: reply_message.String} } @@ -147,22 +151,25 @@ func (d *SQLiteDatabase) Entries(page, pageSize int64) (entries []*guestbook.Ent return } -func (d *SQLiteDatabase) Count() (int64, error) { +// Count returns how much entries are in an `entry` table. +func (d *SQLiteDatabase) Count() (count int64, err error) { tx, err := d.db.Begin() if err != nil { return -1, err } defer tx.Rollback() - var count int64 err = tx.Stmt(stmtCount).QueryRow().Scan(&count) if err != nil { return -1, err } + tx.Commit() + return count, nil } +// NewEntry inserts a passed Entry struct and fills its ID field if successful. func (d *SQLiteDatabase) NewEntry(entry *guestbook.Entry) error { tx, err := d.db.Begin() if err != nil { @@ -170,93 +177,99 @@ func (d *SQLiteDatabase) NewEntry(entry *guestbook.Entry) error { } defer tx.Rollback() - _, err = tx.Stmt(stmtNewEntry).Exec(entry.Created, entry.Name, entry.Message, + r, err := tx.Stmt(stmtNewEntry).Exec(entry.Created, entry.Name, entry.Message, entry.Website, entry.HideWebsite) if err != nil { return err } + entry.ID, err = r.LastInsertId() + if err != nil { + return err + } + tx.Commit() return nil } -func (d *SQLiteDatabase) UpdateEntry(entryID int64, entry *guestbook.Entry) (bool, error) { +// UpdateEntry +func (d *SQLiteDatabase) UpdateEntry(entry *guestbook.Entry) (*guestbook.Entry, error) { tx, err := d.db.Begin() if err != nil { - return false, err + return nil, err } defer tx.Rollback() - res, err := tx.Stmt(stmtUpdateEntry).Exec(entry.ID, entry.Name, entry.Message, entry.Website, entry.HideWebsite, entryID) + row := tx.Stmt(stmtUpdateEntry).QueryRow(entry.Name, entry.Message, entry.Website, entry.HideWebsite, entry.ID) + + uEntry := guestbook.Entry{} + err = row.Scan(&uEntry.ID, &uEntry.Created, &uEntry.Name, &uEntry.Message, &uEntry.Website, &uEntry.HideWebsite) if err != nil { - return false, err + return nil, err } - ra, err := res.RowsAffected() - if err != nil { - return false, err - } + tx.Commit() - return ra > 0, nil + return &uEntry, nil } -func (d *SQLiteDatabase) DeleteEntry(entryID int64) error { +func (d *SQLiteDatabase) DeleteEntry(entryID int64) (int64, error) { tx, err := d.db.Begin() if err != nil { - return err + return -1, err } defer tx.Rollback() res, err := tx.Stmt(stmtDeleteEntry).Exec(entryID) if err != nil { - return err + return -1, err } - _, err = res.RowsAffected() + c, err := res.RowsAffected() if err != nil { - return err + return -1, err } - return nil + tx.Commit() + + return c, nil } -func (d *SQLiteDatabase) NewReply(reply *guestbook.Reply) (err error) { +func (d *SQLiteDatabase) NewReply(reply *guestbook.Reply) error { tx, err := d.db.Begin() if err != nil { - return + return err } defer tx.Rollback() _, err = tx.Stmt(stmtNewReply).Exec(reply.ID, reply.Created, reply.Message) if err != nil { - return + return err } tx.Commit() - return + return nil } -// UpdateEntry -func (d *SQLiteDatabase) UpdateReply(entryID int64, reply *guestbook.Reply) (bool, error) { +func (d *SQLiteDatabase) UpdateReply(reply *guestbook.Reply) (*guestbook.Reply, error) { tx, err := d.db.Begin() if err != nil { - return false, err + return nil, err } defer tx.Rollback() - res, err := tx.Stmt(stmtUpdateReply).Exec(reply.ID, reply.Created, reply.Message, entryID) + uReply := guestbook.Reply{} + err = tx.Stmt(stmtUpdateReply).QueryRow( + reply.Created, reply.Message, reply.ID).Scan(&uReply.ID, &uReply.Created, &uReply.Message) if err != nil { - return false, err + return nil, err } - ra, err := res.RowsAffected() - if err != nil { - return false, err - } + tx.Commit() - return ra > 0, nil + return &uReply, nil } func (d *SQLiteDatabase) DeleteReply(entryID int64) error { @@ -276,10 +289,21 @@ func (d *SQLiteDatabase) DeleteReply(entryID int64) error { return err } + tx.Commit() + return nil } func (d *SQLiteDatabase) Close() error { + stmtCount.Close() + stmtDeleteEntry.Close() + stmtDeleteReply.Close() + stmtGetAll.Close() + stmtNewEntry.Close() + stmtNewReply.Close() + stmtUpdateEntry.Close() + stmtUpdateReply.Close() + return d.db.Close() } diff --git a/pkg/database/sqlite/database_test.go b/pkg/database/sqlite/database_test.go new file mode 100644 index 0000000..a3f69a7 --- /dev/null +++ b/pkg/database/sqlite/database_test.go @@ -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) + } + +} diff --git a/internal/database/sqlite/queries/entryCount.sql b/pkg/database/sqlite/queries/entryCount.sql similarity index 100% rename from internal/database/sqlite/queries/entryCount.sql rename to pkg/database/sqlite/queries/entryCount.sql diff --git a/internal/database/sqlite/queries/entryDelete.sql b/pkg/database/sqlite/queries/entryDelete.sql similarity index 100% rename from internal/database/sqlite/queries/entryDelete.sql rename to pkg/database/sqlite/queries/entryDelete.sql diff --git a/internal/database/sqlite/queries/entryGetAll.sql b/pkg/database/sqlite/queries/entryGetAll.sql similarity index 100% rename from internal/database/sqlite/queries/entryGetAll.sql rename to pkg/database/sqlite/queries/entryGetAll.sql diff --git a/internal/database/sqlite/queries/entryNew.sql b/pkg/database/sqlite/queries/entryNew.sql similarity index 100% rename from internal/database/sqlite/queries/entryNew.sql rename to pkg/database/sqlite/queries/entryNew.sql diff --git a/pkg/database/sqlite/queries/entryUpdate.sql b/pkg/database/sqlite/queries/entryUpdate.sql new file mode 100644 index 0000000..d20934d --- /dev/null +++ b/pkg/database/sqlite/queries/entryUpdate.sql @@ -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`; \ No newline at end of file diff --git a/internal/database/sqlite/queries/replyDelete.sql b/pkg/database/sqlite/queries/replyDelete.sql similarity index 100% rename from internal/database/sqlite/queries/replyDelete.sql rename to pkg/database/sqlite/queries/replyDelete.sql diff --git a/internal/database/sqlite/queries/replyNew.sql b/pkg/database/sqlite/queries/replyNew.sql similarity index 100% rename from internal/database/sqlite/queries/replyNew.sql rename to pkg/database/sqlite/queries/replyNew.sql diff --git a/pkg/database/sqlite/queries/replyUpdate.sql b/pkg/database/sqlite/queries/replyUpdate.sql new file mode 100644 index 0000000..5ce5cff --- /dev/null +++ b/pkg/database/sqlite/queries/replyUpdate.sql @@ -0,0 +1,6 @@ + UPDATE OR REPLACE `reply` + SET + `created` = ?, + `message` = ? + WHERE `entry_id` = ? +RETURNING `entry_id`, `created`, `message`; \ No newline at end of file diff --git a/internal/database/sqlite/queries/schema.sql b/pkg/database/sqlite/queries/schema.sql similarity index 100% rename from internal/database/sqlite/queries/schema.sql rename to pkg/database/sqlite/queries/schema.sql diff --git a/internal/guestbook/database.go b/pkg/guestbook/database.go similarity index 73% rename from internal/guestbook/database.go rename to pkg/guestbook/database.go index 323cd0c..2f3ad38 100644 --- a/internal/guestbook/database.go +++ b/pkg/guestbook/database.go @@ -6,10 +6,10 @@ type Guestbook interface { Entries(page, pageSize int64) ([]*Entry, error) Count() (int64, error) NewEntry(entry *Entry) error - UpdateEntry(entryID int64, entry *Entry) (bool, error) + UpdateEntry(entry *Entry) (*Entry, error) DeleteEntry(entryID int64) error NewReply(reply *Reply) error - UpdateReply(entryID int64, reply *Reply) (bool, error) + UpdateReply(reply *Reply) (*Entry, error) DeleteReply(entryID int64) error Close() error } diff --git a/internal/guestbook/entry.go b/pkg/guestbook/entry.go similarity index 100% rename from internal/guestbook/entry.go rename to pkg/guestbook/entry.go diff --git a/internal/guestbook/reply.go b/pkg/guestbook/reply.go similarity index 91% rename from internal/guestbook/reply.go rename to pkg/guestbook/reply.go index eab9633..ebef0cd 100644 --- a/internal/guestbook/reply.go +++ b/pkg/guestbook/reply.go @@ -6,7 +6,7 @@ import ( ) type Reply struct { - ID int64 `json:"id,omitempty"` + ID int64 `json:"-"` Created string `json:"created,omitempty"` Message string `json:"message"` } diff --git a/pkg/justcaptcha/justcaptcha.go b/pkg/justcaptcha/justcaptcha.go deleted file mode 100644 index c3ff987..0000000 --- a/pkg/justcaptcha/justcaptcha.go +++ /dev/null @@ -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 -} diff --git a/pkg/server/http.go b/pkg/server/http.go deleted file mode 100644 index 40e2422..0000000 --- a/pkg/server/http.go +++ /dev/null @@ -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 -}