Commit c83d2306 authored by Keefer Rourke's avatar Keefer Rourke

allow multiple attachments per post; refactor srv/*handlers.go

parent e1abdbfd
posting:
- implement API key generation / validation
- add CAPTCHA support (alongside API keys)
attachments:
- allow server admin to configure the max number of attachments per post
- do not preserve file names
- strip EXIF data (see issue #1)
......
......@@ -44,7 +44,10 @@ func sendPost() error {
}
}
p := posts.NewPost(msg, file, "", strings.Split(tagstr, ","))
files := make([]string, 1)
files[0] = file
p := posts.NewPost(msg, "", strings.Split(tagstr, ","), files)
if p == nil {
return errors.New("admin sendpost: post is malformed")
}
......
......@@ -50,52 +50,67 @@ var (
Flags: []cli.Flag{
cli.StringFlag{
Name: "port, p",
Value: "1337",
Value: "3003",
Usage: "set `PORT` for the server at run-time",
Destination: &globals.PORT,
Destination: &srv.Port,
},
cli.StringFlag{
Name: "config, c",
Value: "cfg/config.json",
Usage: "load configuration at run-time from `FILE`",
Destination: &globals.CFGFILE,
Name: "config, c",
Value: "cfg/config.json",
Usage: "load configuration at run-time from `FILE`",
},
cli.BoolFlag{
Name: "diagnose, d",
Usage: "Dry run server",
Usage: "print server configuration before start",
},
cli.BoolFlag{
Name: "verbose, v",
Usage: "set verbose output",
Destination: &globals.Verbose,
Name: "verbose, v",
Usage: "set verbose output",
},
},
Action: func(c *cli.Context) {
if c.Bool("diagnose") { // print settings as loaded from the config file
fmt.Println("Using config file at: " + globals.CFGFILE)
Action: func(cx *cli.Context) {
// set config file
if cx.IsSet("config") {
c := cx.String("config")
if f, err := os.Stat(c); !os.IsNotExist(err) && f.Mode().IsRegular() {
srv.CFGFILE = c
} else {
fmt.Printf("Could not open specified config file: %s\n", c)
os.Exit(1)
}
}
// print settings as loaded from the config file
if cx.Bool("diagnose") {
fmt.Println("Using server config file at: " + srv.CFGFILE)
fmt.Println("Settings as read from file:")
fmt.Print(srv.Conf.String())
fmt.Println()
}
// set verbosity of server logging
if cx.Bool("verbose") {
srv.Verbose = true
}
srv.StartServer(globals.PORT)
srv.StartServer(srv.Port)
},
}
)
func init() {
/* application settings */
// read the server configuration file into memory
err := srv.Conf.ReadConfig(globals.CFGFILE)
if err != nil {
log.Fatalf("config error: %s", err)
if err := srv.Conf.ReadConfig(srv.CFGFILE); err != nil {
log.Fatalf("config error: %s\n", err)
}
if err := srv.Conf.ValidateConfig(); err != nil {
log.Fatalf("config error: %s\n", err)
}
globals.PORT = string(srv.Conf.Port)
srv.Port = string(srv.Conf.Port)
// logging configuration
if os.Getenv("TOKUMEI_ENV") == "DEV" {
log.SetPrefix("tokumei: ")
log.SetFlags(log.Lshortfile) // print file line num with log entry
log.SetOutput(os.Stdout)
globals.Verbose = true
srv.Verbose = true
}
// bootstrap the application's post database
......@@ -104,7 +119,6 @@ func init() {
}
}
// run application
func main() {
// customize cli
cli.VersionPrinter = func(c *cli.Context) {
......@@ -112,7 +126,7 @@ func main() {
c.App.Name, c.App.Version, c.App.Description)
}
// set up the application
// set up the application with man-page description
app := cli.NewApp()
app.Authors = []cli.Author{
cli.Author{
......
......@@ -11,19 +11,11 @@ import "path/filepath"
const (
VERSION string = "2.0"
PUBLIC string = "public"
CFG string = "cfg"
)
// CFGFILE is the path to the JSON formatted config file for this Tokumei server.
// See the tokumei/srv package to better understand server configuration.
var CFGFILE string = filepath.FromSlash(CFG + "/config.json")
var (
/* constant-ish */
TMPLDIR string = filepath.FromSlash(PUBLIC + "/html") // location of all templates
POSTDIR string = filepath.FromSlash(PUBLIC + "/files") // location of all post media
POSTDB string = "posts.db" // sqlite3 database
/* real variables :) */
PORT string = "1337" // can be overridden by settings in CFGFILE
Verbose bool = false
)
......@@ -12,10 +12,12 @@ package posts
import (
"database/sql"
"errors"
"log"
"sort"
"strings"
_ "github.com/mattn/go-sqlite3" // sql driver
"gitlab.com/tokumei/tokumei/timedate"
"golang.org/x/crypto/bcrypt"
)
......@@ -37,6 +39,7 @@ const (
func initDB(path string) error {
var err error
if db, err = sql.Open("sqlite3", "file:"+path+"?foreign_keys=on"); err != nil {
log.Println(err)
return err
}
......@@ -94,6 +97,7 @@ func initDB(path string) error {
for _, s := range stmts {
_, err = db.Exec(s)
if err != nil {
log.Println(err)
return err
}
}
......@@ -112,7 +116,8 @@ func addPost(tx *sql.Tx, p *Post) error {
ins, err := tx.Prepare("insert or ignore into " + POST_TABLE + " (id, message, tags, times_shared, date, attachment_uri) values (?, ?, ?, ?, ?, ?)")
if err != nil {
return nil
log.Println(err)
return err
}
defer ins.Close()
......@@ -133,10 +138,12 @@ func addReport(tx *sql.Tx, id int64, r *Report) error {
ins, err := tx.Prepare("insert or ignore into " + P_REPORT_TABLE + " (id, type, reason) values (?, ?, ?)")
if err != nil {
return nil
log.Println(err)
return err
}
defer ins.Close()
if _, err := ins.Exec(id, r.Type, r.Reason); err != nil {
log.Println(err)
return err
}
return nil
......@@ -155,7 +162,8 @@ func addDelCode(tx *sql.Tx, d *DeleteCode) error {
ins, err = tx.Prepare("insert or ignore into " + R_DELCODE_TABLE + " (id, hash, salt) values (?, ?, ?)")
}
if err != nil {
return nil
log.Println(err)
return err
}
defer ins.Close()
......@@ -172,7 +180,8 @@ func addReply(tx *sql.Tx, postId int64, r *Reply) error {
}
ins, err := tx.Prepare("insert or ignore into " + REPLY_TABLE + " (parent, reply_num, message) values (?, ?, ?)")
if err != nil {
return nil
log.Println(err)
return err
}
defer ins.Close()
......@@ -190,11 +199,13 @@ func addReplyReport(tx *sql.Tx, r *Reply, v *Report) error {
ins, err := tx.Prepare("insert or ignore into " + R_REPORT_TABLE + " (id, type, reason) values (?, ?, ?")
if err != nil {
log.Println(err)
return nil
}
defer ins.Close()
if _, err := ins.Exec(r.Id, v.Type, v.Reason); err != nil {
log.Println(err)
return err
}
return nil
......@@ -207,6 +218,7 @@ func getPostNum(tx *sql.Tx) (int64, error) {
if err := row.Scan(&postnum); err == sql.ErrNoRows {
return 0, ErrPostNotFound
} else if err != nil {
log.Println(err)
return -1, err
}
return postnum, nil
......@@ -225,6 +237,7 @@ func getReplyNum(tx *sql.Tx, parent int64) (int64, error) {
if err := row.Scan(&replynum); err == sql.ErrNoRows {
return 0, nil
} else if err != nil {
log.Println(err)
return -1, err
}
return replynum, nil
......@@ -236,6 +249,7 @@ func getAllPosts(tx *sql.Tx) ([]Post, error) {
if err == sql.ErrNoRows {
return nil, nil
} else if err != nil {
log.Println(err)
return nil, err
}
......@@ -247,6 +261,7 @@ func getAllPosts(tx *sql.Tx) ([]Post, error) {
return nil, err
}
if p, err := getPost(tx, id); err != nil {
log.Println("retrieved invalid post from database; discarding")
return nil, err
} else if p.IsValid() { // only append valid db entries
posts = append(posts, *p)
......@@ -267,6 +282,7 @@ func getPost(tx *sql.Tx, id int64) (*Post, error) {
if err := row.Scan(&postId, &message, &rawTags, &timesShared, &date, &attachmentUri); err == sql.ErrNoRows {
return nil, ErrPostNotFound
} else if err != nil {
log.Println(err)
return nil, err
}
......@@ -276,11 +292,13 @@ func getPost(tx *sql.Tx, id int64) (*Post, error) {
if err == sql.ErrNoRows {
reports = nil
} else if err != nil {
log.Println(err)
return nil, err
}
for rows.Next() {
var typ, reason string
if err := rows.Scan(&typ, &reason); err != nil {
log.Println(err)
return nil, err
}
reports = append(reports, Report{
......@@ -296,6 +314,7 @@ func getPost(tx *sql.Tx, id int64) (*Post, error) {
if err == sql.ErrNoRows {
replies = nil
} else if err != nil {
log.Println(err)
return nil, err
}
for rows.Next() {
......@@ -309,11 +328,13 @@ func getPost(tx *sql.Tx, id int64) (*Post, error) {
if err == sql.ErrNoRows {
replyReports = nil
} else if err != nil {
log.Println(err)
return nil, err
}
for reportRows.Next() {
var typ, reason string
if err := reportRows.Scan(&typ, &reason); err != nil {
log.Println(err)
return nil, err
}
replyReports = append(replyReports, Report{
......@@ -376,6 +397,7 @@ func deletePost(tx *sql.Tx, id int64, delcode string) error {
// post
return removePost(tx, id)
} else if err != nil {
log.Println(err)
return err
}
......@@ -405,6 +427,7 @@ func removePost(tx *sql.Tx, id int64) error {
if p == nil {
return ErrPostNotFound
} else if err != nil {
log.Println(err)
return err
}
......@@ -414,23 +437,29 @@ func removePost(tx *sql.Tx, id int64) error {
var r int64
rows.Scan(&r)
if err := removeRows(tx, r, R_DELCODE_TABLE, "id"); err != nil {
log.Println(err)
return err
}
if err := removeRows(tx, r, R_REPORT_TABLE, "id"); err != nil {
log.Println(err)
return err
}
}
if err := removeRows(tx, id, REPLY_TABLE, "parent"); err != nil {
log.Println(err)
return err
}
if err := removeRows(tx, id, P_DELCODE_TABLE, "id"); err != nil {
log.Println(err)
return err
}
if err := removeRows(tx, id, P_REPORT_TABLE, "id"); err != nil {
log.Println(err)
return err
}
if err := removeRows(tx, id, POST_TABLE, "id"); err != nil {
log.Println(err)
return err
}
return nil
......
......@@ -14,12 +14,14 @@ import (
"fmt"
"html"
"io"
"log"
"os"
"path/filepath"
"strings"
"gitlab.com/tokumei/tokumei/globals"
"gitlab.com/tokumei/tokumei/timedate"
"tokumei.co/tokumei/mimetype"
)
var (
......@@ -74,7 +76,7 @@ func (p Post) String() string {
// IsValid() validates the integrity of a Post on some basic parameters. It may
// be useful to validate a post after it is retrieved from the database in the
// event that the database has been tampered with and invalid data is present.
func (p Post) IsValid() bool {
func (p *Post) IsValid() bool {
if p.AttachmentUri != nil {
// check all attachments exist
for _, v := range p.AttachmentUri {
......@@ -89,15 +91,32 @@ func (p Post) IsValid() bool {
}
// GetNumReports() returns the number of times a Post has been reported.
func (p Post) GetNumReports() int64 {
func (p *Post) GetNumReports() int64 {
return int64(len(p.Reports))
}
// GetNumReplies() returns the number of replies a Post has received.
func (p Post) GetNumReplies() int64 {
func (p *Post) GetNumReplies() int64 {
return int64(len(p.Replies))
}
// GetAttachments() returns a slice of mimetype.FileType descriptors for each
// attachment in the Post. Returns nil if post has no attachments.
func (p *Post) GetAttachments() []mimetype.FileType {
var attachments []mimetype.FileType
if p.AttachmentUri != nil { // AttachmentUri is a slice of attachment URIs
for _, uri := range p.AttachmentUri {
file, err := mimetype.GetFileType("public" + uri)
if err != nil {
log.Printf("attachment for post %d is unavailable.\n", p.Id)
} else {
attachments = append(attachments, *file)
}
}
}
return attachments
}
// PostSlice is a slice of Post which imposes ordering by Id.
type PostSlice []Post
......
......@@ -3,24 +3,29 @@
<div class="card">
<div class="card-content">
<p>{{.Post.Message}}</p>
{{if .Attachment}} {{$size := humanize .Attachment.Size}} {{if eq .Attachment.HtmlEmbed "" | not}} {{$entity := .Attachment.HtmlEmbed}} {{if eq $entity "img"}}
{{if .Attachments}}
{{range $i, $v := .Attachments}}
{{$size := humanize $v.Size}}
{{if eq $v.HtmlEmbed "" | not}} {{$entity := $v.HtmlEmbed}} {{if eq $entity "img"}}
<!-- if attachment is an image, display inline with link -->
<a href="{{.Post.AttachmentUri}}">
<img src="{{.Post.AttachmentUri}}" alt="Attachment ({{$size}} {{upper .Attachment.Ext}})" class="attachment"/>
<a href="{{index .Post.AttachmentUri $i}}">
<img src="{{index .Post.AttachmentUri $i}}" alt="Attachment ({{$size}} {{upper $v.Ext}})" class="attachment"/>
</a>
<!-- otherwise display whatever the hell it actually is -->
{{else if eq $entity "audio"}}
<audio src="{{.Post.AttachmentUri}}" class="attachment" controls></audio>
<audio src="{{index .Post.AttachmentUri $i}}" class="attachment" controls></audio>
{{else if eq $entity "video"}}
<video src="{{.Post.AttachmentUri}}" class="attachment" controls></video>
<video src="{{index .Post.AttachmentUri $i}}" class="attachment" controls></video>
{{else}}
<p><i>No preview is available for this file.</i></p>
{{end}} {{end}}
{{end}} <!-- embeddable -->
{{end}} <!-- preview type -->
<br/>
<br/>
<p><a href="{{.Post.AttachmentUri}}" class="btn-large pink wave-effect waves-light"><i class="mdi mdi-download left"></i>Download ({{$size}} {{upper .Attachment.Ext}})</a></p>
<p><a href="{{index .Post.AttachmentUri $i}}" class="btn-large pink wave-effect waves-light"><i class="mdi mdi-download left"></i>Download ({{$size}} {{upper $v.Ext}})</a></p>
{{end}} <!-- range -->
</div>
{{end}}
{{end}} <!-- preview type -->
<div class="card-action">
<!-- buttons -->
<span class="post-buttons">
......
/* Copyright (c) 2017 Tokumei authors.
* This software package is licensed for use under the ISC license.
* See LICENSE for details.
*
* Tokumei is a simple, self-hosted microblogging platform. */
// This file contains handlers for API endpoints only; GET API functions return
// only JSON responses, and POST API functions accept only multipart/form-data.
// Multipurpose/dynamic and logic heavy routings belong in dynamic_handlers.go
package srv
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"gitlab.com/tokumei/tokumei/posts"
)
/* get api-only endpoints */
// queries of the following forms are permitted, where
// * n, m are integers and h, l are the literal characters h and l
// /posts : returns all posts
// /posts?h=n : returns the first n posts
// /posts?l=m : returns all posts excluding the first m posts
// /posts?h=n&l=m : returns the first n posts excluding the first m posts
//
// other query parameters are ignored
func getPostsHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
if r.URL.Path != Routes["allposts"] {
errorHandler(w, r, http.StatusNotFound)
return
}
q, err := url.ParseQuery(r.URL.RawQuery)
if err != nil {
errorHandler(w, r, http.StatusBadRequest)
return
}
h, herr := UrlIntQuery(q, "h")
l, lerr := UrlIntQuery(q, "l")
if (herr != nil && herr != ErrKeyNotFound) || (lerr != nil && lerr != ErrKeyNotFound) {
errorHandler(w, r, http.StatusBadRequest)
return
}
posts := posts.GetPostsRange(l, h)
res, err := json.MarshalIndent(posts, "", " ")
if err != nil {
errorHandler(w, r, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, string(res))
default:
errorHandler(w, r, http.StatusUnauthorized)
}
}
// getPostNumHandler will return the postnum to the client
func getPostNumHandler(w http.ResponseWriter, r *http.Request) {
n, err := posts.GetPostNum()
if err != nil {
errorHandler(w, r, http.StatusInternalServerError)
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, "%d", n)
}
/* post api-only endpoints */
// reportHandler will post a report from the request submitted to /report
func reportHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "POST":
// handle form data (post id, reply id, type, reason)
// negative reply id can indicate that the report is for the post
default:
errorHandler(w, r, http.StatusBadRequest)
}
}
......@@ -15,14 +15,20 @@ import (
// TODO(krourke)
var (
deleteChan = make(chan *posts.DeleteCode)
postChan = make(chan *posts.Post)
reportChan = make(chan *posts.Report)
replyChan = make(chan *posts.Reply)
postChan = make(chan *posts.Post)
)
func QueueDeleteCode(d *posts.DeleteCode) {
if d != nil {
deleteChan <- d
}
}
// QueuePost() queues a newly created Post to be added to the server database.
func QueuePost(p *posts.Post) {
fmt.Println("in queue")
if p != nil {
postChan <- p
}
......@@ -46,26 +52,18 @@ func QueueReport(r *posts.Report) {
}
// run as a go-routine
func listenForReports() {
for {
<-reportChan
}
}
// run as a go-routine
func listenForReplies() {
func listenForDeleteCodes() {
for {
<-replyChan
<-deleteChan
}
}
// run as a go-routine
func listenForPosts() {
for {
fmt.Println("in listen")
p := <-postChan
if p != nil {
fmt.Println("got post")
fmt.Print("got post:", p)
delcode, err := p.Finalize()
if err != nil {
continue
......@@ -77,3 +75,17 @@ func listenForPosts() {
}
}
}
// run as a go-routine
func listenForReports() {
for {
<-reportChan
}
}
// run as a go-routine
func listenForReplies() {
for {
<-replyChan
}
}
This diff is collapsed.
......@@ -131,6 +131,7 @@ func init() {
// start "daemons"
go listenForPosts()
go listenForDeleteCodes()
go listenForReplies()
go listenForReports()
}
......
......@@ -7,11 +7,37 @@
package srv
import (
/* Standard library packages */
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"path/filepath"
)
// CFG is the name of the server configuration directory
const CFG string = "cfg"
// Constant constraints for Settings
const (
MIN_ATTACHMENTS int = 0
MAX_ATTACHMENTS int = 4
)
// CFGFILE is the path to the JSON formatted config file for this Tokumei server.
// See the tokumei/srv package to better understand server configuration.
var CFGFILE string = filepath.FromSlash(CFG + "/config.json")
// Default server operation settings
var (
Port string = "3003"
Verbose bool = false
)
// Errors
var (
ErrInvalidFilePath = errors.New("srv settings: path to configuration file is invalid")
ErrNil = errors.New("srv settings: nil Settings is invalid")
ErrBadAttachmentNum = errors.New("srv settings: Settings.MaxAttachmentNum not in allowable range")
)
// Settings is a struct which contains all configuration settings for this
......@@ -22,7 +48,7 @@ type Settings struct {
Subtitle string `json:"site_subtitle"` // site subtitle shown on landing page
Description string `json:"meta_description"` // meta description used in search results
Lang string `json:"lang"` // site-wide locale
Host string `json:"host"` // ex. blog.gitlab.com/tokumei
Host string `json:"host"` // ex. blog.example.com
Port uint `json:"port"` // default: 1337
IsPrivate bool `json:"private_installation"` // if true, require accounts to make posts
Features Features `json:"features"` // enabled/disable features
......@@ -57,7 +83,8 @@ type PrivateConf struct {
// will allow a poster to skip this requirement.
type PostConf struct {
CharLimit int `json:"char_limit"` // number of chars in post excluding tags
MaxFileSize uint64 `json:"max_filesize"` // file size limit for attachments
MaxAttachmentNum int `json:"max_attachment_num"` // maximum number of attachments allowed per post; 0 through 4 allowed
MaxFileSize uint64 `json:"max_filesize"` // file size limit for individual attachments
ForceTagging bool `json:"force_tagging"` // require at least one tag for post
EnableReplies bool `json:"enable_replies"` // enable or disable replies on posts
EnableWebDelete bool `json:"enable_web_delete"` // enable or disable post deletion by users
......@@ -115,11 +142,11 @@ func (s Settings) String() string {
// Conf is the runtime Settings for the server.
var Conf Settings
// ReadConfig reads the JSON-formatted configuration file located the specified
// ReadConfig() reads the JSON-formatted configuration file located the specified
// file path.
func (s *Settings) ReadConfig(file string) error {
if file == "" {
return errors.New("Path to config file cannot be empty!")
return ErrInvalidFilePath
}
buf, err := ioutil.ReadFile(file)
......@@ -129,3 +156,15 @@ func (s *Settings) ReadConfig(file string) error {
return json.Unmarshal(buf, s)
}
// ValidateConfig() validates the data held in a Settings struct.
func (s *Settings) ValidateConfig() error {
if s == nil {
return ErrNil
}
if s.PostConf.MaxAttachmentNum < MIN_ATTACHMENTS || s.PostConf.MaxAttachmentNum > MAX_ATTACHMENTS {
return ErrBadAttachmentNum
}