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