...
 
Commits (2)
  • Keefer Rourke's avatar
    fixed admin stat command · 09855876
    Keefer Rourke authored
    09855876
  • Keefer Rourke's avatar
    implemented preliminary POST handler on /p/ · e1abdbfd
    Keefer Rourke authored
    Other changes:
     - posts now support multiple attachments
    
    TODO:
     - captcha and API keys need be handled by srv/handlers.go: postHandler()
     - add server configurable limit for number of allowed attachments per
       post
    
    Bug fixes:
     - srv/settings.go: changed ReadConfig() to use a pointer receiver;
       config was previously being read into a copy of the Settings struct
       lol
    e1abdbfd
attachments:
- allow server admin to configure the max number of attachments per post
- do not preserve file names
- strip EXIF data
- strip EXIF data (see issue #1)
tests:
- none of the code on this branch has tests yet
- tests need to be added for each package to prevent accidental regressions
......@@ -75,23 +75,23 @@ var Cmds = []cli.Command{
} else if cx.NArg() > 3 {
fmt.Println("stat: too many arguments")
os.Exit(2)
// } else if cx.Bool("with-replies") && strings.Contains(strings.Join(cx.FlagNames(), " "), "reply") {
// fmt.Println("stat: cannot use --with-replies and --reply flags together")
// os.Exit(2)
} else if cx.Bool("with-replies") && cx.IsSet("reply") {
fmt.Println("stat: cannot use --with-replies and --reply flags together")
os.Exit(2)
}
if n, err := strconv.ParseInt(cx.Args().Get(0), 10, 64); err == nil {
// if strings.Contains(strings.Join(cx.FlagNames(), " "), "reply") {
// if err = statReply(n, cx.Int64("reply"), cx.Bool("with-reports")); err != nil {
// fmt.Println(err)
// os.Exit(1)
// }
// } else {
if err = statPost(n, cx.Bool("with-reports"), cx.Bool("with-replies")); err != nil {
fmt.Println(err)
os.Exit(1)
if cx.IsSet("reply") {
if err = statReply(n, cx.Int64("reply"), cx.Bool("with-reports")); err != nil {
fmt.Println(err)
os.Exit(1)
}
} else {
if err = statPost(n, cx.Bool("with-reports"), cx.Bool("with-replies")); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
// }
os.Exit(0)
} else {
fmt.Println("stat: post num must be an integer")
......
......@@ -22,6 +22,9 @@ import (
var db *sql.DB
// The table names that are used internally in the Sqlite3 database. It may be
// be helpful at times to open a Sqlite shell and inspect these tables to aid
// in debugging and/or server administration.
const (
POST_TABLE string = "posts"
REPLY_TABLE string = "replies"
......@@ -114,8 +117,9 @@ func addPost(tx *sql.Tx, p *Post) error {
defer ins.Close()
tags := strings.Join(p.Tags, ",")
attachments := strings.Join(p.AttachmentUri, ",")
datestamp := timedate.UnixDateStamp(p.DatePosted)
_, err = ins.Exec(p.Id, p.Message, tags, p.TimesShared, datestamp, p.AttachmentUri)
_, err = ins.Exec(p.Id, p.Message, tags, p.TimesShared, datestamp, attachments)
return err
}
......@@ -250,7 +254,6 @@ func getAllPosts(tx *sql.Tx) ([]Post, error) {
}
sort.Sort(PostSlice(posts))
return posts, nil
}
// retreives a reference to a fully qualified Post
......@@ -328,12 +331,15 @@ func getPost(tx *sql.Tx, id int64) (*Post, error) {
}
rows.Close()
// parse tags
var tags []string
if rawTags != "" {
tags = strings.Split(rawTags, ",")
for i, t := range tags {
tags[i] = strings.TrimSpace(t)
// split comma separated tag list
tags := ParseTagString(rawTags)
// split attachment list
var attachments []string
if attachmentUri != "" {
attachments = strings.Split(attachmentUri, ",")
for i, a := range attachments {
attachments[i] = strings.TrimSpace(a)
}
}
......@@ -345,11 +351,10 @@ func getPost(tx *sql.Tx, id int64) (*Post, error) {
DatePosted: date,
Reports: reports,
Replies: replies,
AttachmentUri: attachmentUri,
AttachmentUri: attachments,
// posts from the database have already been finalized
isFinal: true,
}
return post, nil
}
......@@ -428,6 +433,5 @@ func removePost(tx *sql.Tx, id int64) error {
if err := removeRows(tx, id, POST_TABLE, "id"); err != nil {
return err
}
return nil
}
......@@ -12,6 +12,7 @@ import (
"encoding/json"
"errors"
"fmt"
"html"
"io"
"os"
"path/filepath"
......@@ -41,8 +42,8 @@ type Post struct {
DatePosted int64 `json:"date_posted"`
Reports []Report `json:"reports"`
Replies []Reply `json:"replies"`
AttachmentUri string `json:"attachment_uri"` // TODO(krourke) change to array
tempfilePath string
AttachmentUri []string `json:"attachment_uri"`
tempfiles []string
isFinal bool
delcode string
}
......@@ -74,11 +75,13 @@ func (p Post) String() string {
// 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 {
if p.AttachmentUri != "" {
// check attachment exists
uri := strings.TrimPrefix(p.AttachmentUri, "/")
if f, err := os.Stat(uri); os.IsNotExist(err) || f.IsDir() {
return false
if p.AttachmentUri != nil {
// check all attachments exist
for _, v := range p.AttachmentUri {
uri := strings.TrimPrefix(v, "/")
if f, err := os.Stat(uri); os.IsNotExist(err) || f.IsDir() {
return false
}
}
}
// check no illegal values are in exported fields
......@@ -107,7 +110,7 @@ func (p PostSlice) Swap(i, j int) { p[i], p[j] = p[j], p[i] }
// to assign an Id and create the expected directory structure for attachments.
// Returns nil on error. Code is a cleartext passphrase used to authenticate
// deletion; if code is an empty string, then it is not user-deletable.
func NewPost(message, filepath, code string, tags []string) *Post {
func NewPost(message, code string, tags, files []string) *Post {
if message == "" {
return nil
}
......@@ -120,20 +123,20 @@ func NewPost(message, filepath, code string, tags []string) *Post {
}
return &Post{
Message: message,
DatePosted: date,
Tags: tags,
tempfilePath: filepath,
isFinal: false,
delcode: code,
Message: message,
DatePosted: date,
Tags: tags,
tempfiles: files,
isFinal: false,
delcode: code,
}
}
// Finalize() finalizes a new Post by assigning an Id, and processing the
// specified attachment, so that it may be inserted into the database.
// If a Post has already been finalized, then this function does nothing.
// If a Post has already been finalized, then this function does nothing. Use
// IsValid() to validate post integrity.
// TODO(krourke) do not preserve file names
// TODO(krourke) allow multiple attachments
func (p *Post) Finalize() (password string, err error) {
if p.isFinal == true {
return p.delcode, nil
......@@ -146,35 +149,37 @@ func (p *Post) Finalize() (password string, err error) {
}
p.Id += 1
// check attachment file exists if present then move to public dir
// check attachment files exist if present then move to public dir
dir := fmt.Sprintf("%s/%d", globals.POSTDIR, p.Id)
if p.tempfilePath != "" {
if fstat, err := os.Stat(p.tempfilePath); os.IsNotExist(err) || !fstat.Mode().IsRegular() {
return "", ErrAttachmentNotFound
if p.tempfiles != nil {
for _, tmpf := range p.tempfiles {
if fstat, err := os.Stat(tmpf); os.IsNotExist(err) || !fstat.Mode().IsRegular() {
return "", ErrAttachmentNotFound
}
src, err := os.Open(tmpf)
if err != nil {
return "", err
}
// create destination file
err = os.MkdirAll(filepath.FromSlash(dir), os.ModeDir)
if err != nil {
return "", err
}
attachment, err := os.Create(filepath.FromSlash(dir + "/" + filepath.Base(src.Name())))
if err != nil {
return "", err
}
// copy to dest
if _, err := io.Copy(attachment, src); err != nil {
return "", err
}
src.Close()
// add proper attachment path to list of URIs
p.AttachmentUri = append(p.AttachmentUri, "/"+filepath.ToSlash(attachment.Name()))
}
src, err := os.Open(p.tempfilePath)
if err != nil {
return "", err
}
defer src.Close()
// create destination file
err = os.MkdirAll(filepath.FromSlash(dir), os.ModeDir)
if err != nil {
return "", err
}
attachment, err := os.Create(filepath.FromSlash(dir + "/" + filepath.Base(src.Name())))
if err != nil {
return "", err
}
// copy to dest
if _, err := io.Copy(attachment, src); err != nil {
return "", err
}
p.AttachmentUri = "/" + filepath.ToSlash(attachment.Name())
}
p.isFinal = true
return p.delcode, nil
}
......@@ -192,7 +197,6 @@ func ReadPosts() ([]Post, error) {
}
posts, err := getAllPosts(tx)
tx.Commit()
return posts, err
}
......@@ -206,10 +210,21 @@ func GetPostNum() (int64, error) {
}
postnum, err := getPostNum(tx) // returns 0 on error
tx.Commit()
return postnum, err
}
// ParseTagString() accepts a comma delimited string and returns a slice of
// white-space trimmed, html-escaped tags.
func ParseTagString(tagstr string) []string {
tags := strings.Split(tagstr, ",")
for i, t := range tags {
t = strings.TrimSpace(t)
t = html.EscapeString(t)
tags[i] = t
}
return tags
}
// Lookup() retreives a Post from the database.
// The returned Post is nil if no Post exists with the specified id.
func Lookup(id int64) (*Post, error) {
......@@ -223,39 +238,44 @@ func Lookup(id int64) (*Post, error) {
}
// GetPostsRange() returns a PostSlice of existing indexed posts. The parameters
// l and h specify the lowest and highest post *indices* to slice the posts.
// If either l or h are negative, then the search is unbounded on the lower or
// higher bounds respectively. If l and h are equal, then only one post
// is returned. If l is higher than the number of posts available, a nil slice
// is returned. If h is higher than the number of posts available, the higher
// bound is made to be unbounded. If l > h >= 0, then the nil slice is returned
// because this range is nonsensical.
// l and h specify the lowest and highest post *indices* with which to slice
// posts. The lower bound l is inclusive, and the upper bound h is exclusive.
// This is consistent with golang slice subindicing.
// See https://blog.golang.org/go-slices-usage-and-internals
//
// It is important to note that l and h are *not* post IDs. They are bounds on a
// range of values to be returned. Ex. if there are 500 posts in the database,
// and you want to get the second set of 20 posts, then query with l=20, h=41.
// If either l or h are negative, then the search is unbounded on the lower or
// higher bounds respectively. If l is higher than the number of posts
// available, a nil slice is returned. If h is higher than the number of posts
// available, the higher bound is ignored. If l > h >= 0, then the nil slice is
// returned because this range is nonsensical. If l == h, then a nil slice is
// returned. To retrieve a single post, specify h = l+1.
//
// If the intention is to return a single post, and you know the post ID, then
// the Lookup function should be used instead.
// It is important to note that l and h are *not* post IDs. They are indices
// used to slice the server's internal list of posts.
// Ex. if there are 500 posts in the database and you want to get the first set
// of 20 posts, then specify l=0&h=20. The next set of 20 posts is l=20&h=40...
// and so on.
//
// TODO(krourke) fix range to be inclusive (l, h)
// Note: If the intention is to return a single post, and you know the post ID,
// then the Lookup() function should be used instead.
func GetPostsRange(l, h int) []Post {
posts, err := ReadPosts()
if err != nil {
return nil
}
// parse request
if l > len(posts) {
// parse request bounds
if l >= len(posts) {
return nil
}
if h >= len(posts) { // too high of a bound is treated as no bound
h = -1
}
if 0 <= h && h < l { // bad range is 0 <= h <= l
//fmt.Println("0 <= h < 1")
// get slice
if 0 <= h && h < l { // bad range is 0 <= h < l
//fmt.Println("0 <= h < l")
return nil
} else if l <= 0 && h >= 0 { // no lower bound
} else if l < 0 && h >= 0 { // no lower bound
//fmt.Println("no lower")
return posts[:h]
} else if l >= 0 && h <= 0 { // no upper bound
......@@ -263,7 +283,7 @@ func GetPostsRange(l, h int) []Post {
return posts[l:]
} else if h > l && l >= 0 || (l >= 0 && h == l) { // fetch between l and h inclusive
//fmt.Println("l - h")
return posts[l : h+1]
return posts[l:h]
}
// else return all posts if both parameters are negative
return posts
......
......@@ -97,7 +97,6 @@ func GetReplyNum(parentID int64) (int64, error) {
}
replynum, err := getReplyNum(tx, parentID) // returns 0 on error
tx.Commit()
return replynum, err
}
......
......@@ -8,6 +8,7 @@ package srv
import (
"fmt"
"log"
"gitlab.com/tokumei/tokumei/posts"
)
......@@ -21,7 +22,7 @@ var (
// QueuePost() queues a newly created Post to be added to the server database.
func QueuePost(p *posts.Post) {
fmt.Println("here")
fmt.Println("in queue")
if p != nil {
postChan <- p
}
......@@ -61,13 +62,18 @@ func listenForReplies() {
// run as a go-routine
func listenForPosts() {
for {
fmt.Println("in listen")
p := <-postChan
if p != nil {
fmt.Println("got post")
delcode, err := p.Finalize()
if err != nil {
continue
}
posts.AddPost(p, delcode)
err = posts.AddPost(p, delcode)
if err != nil {
log.Println(err)
}
}
}
}
......@@ -12,6 +12,7 @@ import (
"log"
"net/http"
"net/url"
"os"
"strconv"
"strings"
......@@ -138,34 +139,99 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
errorHandler(w, r, http.StatusInternalServerError)
return
}
var attachment *mimetype.FileType
if p.AttachmentUri != "" {
attachment, err = mimetype.GetFileType("public" + p.AttachmentUri)
if err != nil {
log.Printf("attachment for post %d is unavailable.\n", p.Id)
var attachments []*mimetype.FileType
if p.AttachmentUri != nil {
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)
}
}
}
data := struct {
Conf Settings
Post *posts.Post
Attachment *mimetype.FileType
Conf Settings
Post *posts.Post
Attachments []*mimetype.FileType
}{
Conf,
p,
attachment,
attachments,
}
if err := tmpl.Execute(w, data); err != nil {
log.Fatalf("template execution: %s", err)
}
}
// TODO(kfarwell)
// Add recaptcha support.
case "POST":
// if err := r.ParseMultipartForm(0); err != nil {
// errorHandler(w, r, http.StatusBadRequest)
// return
// }
// _, fh, err := r.FormFile("attachment")
// f, err := fh.Open()
fmt.Println("IN POST POST")
// save all multipart form data to disk
if err := r.ParseMultipartForm(0); err != nil {
log.Printf("could not parse MultipartForm: %s\n", err.Error())
errorHandler(w, r, http.StatusBadRequest)
return
}
// parse key-value pairs
message := r.FormValue("message")
if message == "" {
log.Println("post rejected due to empty message body")
errorHandler(w, r, http.StatusBadRequest)
return
} else if len(message) > Conf.PostConf.CharLimit {
log.Printf("post rejected because message longer than limit %d\n", Conf.PostConf.CharLimit)
errorHandler(w, r, http.StatusUnprocessableEntity)
return
}
tagstr := r.FormValue("tags")
password := r.FormValue("password")
// check if the post can be made with the provided fields
if Conf.Features.ProvideApiKeys && Conf.PostConf.RequireCaptcha {
// TODO handle API keys with captcha
} else if Conf.PostConf.RequireCaptcha {
// TODO handle captcha without API keys
}
// parse attachments and get a list of their locations on disk
// TODO finish this
var attachments []string
fhdrs := r.MultipartForm.File["attachment"]
for _, fh := range fhdrs {
f, err := fh.Open()
if err != nil {
log.Printf("could not open post attachment file: %s\n", err.Error())
errorHandler(w, r, http.StatusInternalServerError)
return
}
// check file is allowed mimetype and reject unverified files
if ftyp, err := mimetype.GetFileType(f.(*os.File).Name()); err != nil {
log.Println(err)
errorHandler(w, r, http.StatusUnsupportedMediaType)
return
} else if !ftyp.VerifiedSignature {
log.Printf("rejected file %s because file type could not be verified\n", f.(*os.File).Name())
errorHandler(w, r, http.StatusUnsupportedMediaType)
return
} else if ftyp.Size > Conf.PostConf.MaxFileSize {
log.Printf("rejected file %s because file is too larger than %dB\n", Conf.PostConf.MaxFileSize)
errorHandler(w, r, http.StatusRequestEntityTooLarge)
return
}
// get file name and append to attachment slice
attachments = append(attachments, f.(*os.File).Name())
f.Close()
}
// make a new Post and queue it
p := posts.NewPost(message, password, posts.ParseTagString(tagstr), attachments)
if p == nil {
errorHandler(w, r, http.StatusInternalServerError)
return
} else {
QueuePost(p)
}
default:
errorHandler(w, r, http.StatusUnauthorized)
}
......
......@@ -4,15 +4,74 @@
*
* Tokumei is a simple, self-hosted microblogging platform. */
// Package srv contains functions pertaining to Tokumei server operations.
// Package srv contains functions pertaining to internal Tokumei server
// operations.
//
// Hacking
//
// Functions documented in this package are *not* part of the client API.
// Exported functions in this package are provided for hacking directly on the
// server and no other purpose. There is no stable server API.
//
// Server Configuration
//
// Refer to the Settings struct to become familiar with underlying server
// configuration. Configs are serialized and deserialized to JSON. Tokumei
// servers expect a properly formatted cfg/config.json file.
//
// See http://tokumei.co/hosting for more configuration details.
//
// Client API
//
// If you are looking for client documentation, Tokumei has a simple POST/GET
// API for client applications. Below is short summary, however the full
// documentation is located at http://tokumei.co/api.
//
// GET Requests
//
// Send a GET request to http://example.com/p/1.json to get the first post.
// wget http://example.com/p/1.json # retrieve post ID 1
//
// Send a GET request to http://example.com/posts to get all posts. Specify a
// range of posts to retrieve with query parameters 'l' and 'h'. This is useful
// for pagination.
// wget http://example.com/posts?l=0&h=20 # retrieve first 20 posts
//
// POST Requests
//
// Send a POST request with a multipart form to http://example.com/p/ to make a
// post. Expected fields:
// message - the post text (required)
// tags - comma separated list of tags (optional)
// password - deletion password (optional)
// attachment - array of files (optional)
// api_key - may be required depending on server configuration
// Example:
// curl -F 'message=hello world!' \
// -F 'tags=hello, bonjour, gutentag' \
// -F '[email protected]' \
// http://example.com/p/
//
// Send a POST request to https://example.com/p/n to reply to a post, where 'n'
// must be a valid numeric post ID. Expected fields:
// comment - the reply text (required)
// password - deletion password (optional)
// api_key - may be required depending on server configuration
// Example:
// curl -d 'comment=you should at the void and the void shouts back' \
// -d 'password=supersecretpassword' \
// http://example.com/p/1
//
// A note on API keys: Tokumei servers may be configured with varying levels of
// spam protection including CAPTCHA and API keys. If a server has API keys
// enabled, then the api_key field is required for all clients that are not the
// official web interface.
package srv
import (
/* Standard library packages */
"fmt"
"log"
"net/http"
/* Tokumei */ // other settings are stored in the Conf object belonging to this package
)
// Routes is a map of string to base request paths. Packages that wish to extend
......
......@@ -56,14 +56,14 @@ type PrivateConf struct {
// completed before a post can be submitted; submitting posts using an API key
// will allow a poster to skip this requirement.
type PostConf struct {
CharLimit uint `json:"char_limit"` // number of chars in post excluding tags
MaxFileSize uint `json:"max_filesize"` // file size limit for 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
EnableReportSpam bool `json:"enable_spam_reporting"` // enable or disable spam reports by users
EnableReportIllegal bool `json:"enable_illegal_content_reporting"` // enable or disable illegal content reports
RequireCaptcha bool `json:"require_captcha"` // post captcha requirement
CharLimit int `json:"char_limit"` // number of chars in post excluding tags
MaxFileSize uint64 `json:"max_filesize"` // file size limit for 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
EnableReportSpam bool `json:"enable_spam_reporting"` // enable or disable spam reports by users
EnableReportIllegal bool `json:"enable_illegal_content_reporting"` // enable or disable illegal content reports
RequireCaptcha bool `json:"require_captcha"` // post captcha requirement
}
// TrendingConf is struct that contains settings that tailor the trending
......@@ -117,7 +117,7 @@ var Conf Settings
// ReadConfig reads the JSON-formatted configuration file located the specified
// file path.
func (s Settings) ReadConfig(file string) error {
func (s *Settings) ReadConfig(file string) error {
if file == "" {
return errors.New("Path to config file cannot be empty!")
}
......@@ -127,5 +127,5 @@ func (s Settings) ReadConfig(file string) error {
return err
}
return json.Unmarshal(buf, &s)
return json.Unmarshal(buf, s)
}