1
0

Initial commit.

This commit is contained in:
Alexander Andreev 2022-10-19 03:25:43 +04:00
commit c6e719b5c1
Signed by: Arav
GPG Key ID: 0388CC8FAA51063F
26 changed files with 1015 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
bin/*
!bin/.keep
.vscode
*.test.*
test.sqlite*

21
LICENSE Normal file
View 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
View 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
View 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.

0
bin/.keep Normal file
View File

30
build/archlinux/PKGBUILD Normal file
View 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
}

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

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

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

View File

@ -0,0 +1,2 @@
DELETE FROM `entry`
WHERE `entry_id` = ?;

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

View File

@ -0,0 +1,4 @@
INSERT INTO `entry`
(`created`, `name`, `message`, `website`, `hide_website`)
VALUES
(?, ?, ?, ?, ?);

View File

@ -0,0 +1,2 @@
DELETE FROM `reply`
WHERE `entry_id` = ?;

View File

@ -0,0 +1,4 @@
INSERT INTO `reply`
(`created`, `entry_id`, `message`)
VALUES
(?, ?, ?);

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

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

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

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

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

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