Commit 5b9d9611 authored by Keefer Rourke's avatar Keefer Rourke

split posts pkg into multiple files

other notable changes:
 - add salt to delcodes for extra safety
 - finished Reply.Finalize()
 - rename database tables
parent 4691858a
......@@ -20,6 +20,8 @@ import (
)
// Cmds is a set of verbose, semi-interactive administrative commands.
//
// TODO(krourke) fix 'admin stat' command.
var Cmds = []cli.Command{
// remove a post
cli.Command{
......
......@@ -5,7 +5,7 @@
* Tokumei is a simple, self-hosted microblogging platform. */
// Command tokumei runs a simple, anonymous, self-hosted microblogging service.
// Get started with Tokumei by heading over to https://gitlab.com/tokumei/hosting and
// Get started with Tokumei by heading over to https://tokumei.co/hosting and
// following our simple guide. While this package is go-gettable, unless you are
// hacking on Tokumei, it is recommended that you use our installation scripts.
//
......
......@@ -12,7 +12,6 @@ const (
VERSION string = "2.0"
PUBLIC string = "public"
CFG string = "cfg"
SRV string = "srv"
)
// CFGFILE is the path to the JSON formatted config file for this Tokumei server.
......
......@@ -4,6 +4,9 @@
*
* Tokumei is a simple, self-hosted microblogging platform. */
// db.go contains the unexported backend interactions with the database
// exported functionality should wrap these functions
package posts
import (
......@@ -13,8 +16,8 @@ import (
"strings"
_ "github.com/mattn/go-sqlite3" // sql driver
"golang.org/x/crypto/bcrypt"
"gitlab.com/tokumei/tokumei/timedate"
"golang.org/x/crypto/bcrypt"
)
var db *sql.DB
......@@ -23,9 +26,9 @@ const (
POST_TABLE string = "posts"
REPLY_TABLE string = "replies"
P_REPORT_TABLE string = "post_reports"
R_REPORT_TABLE string = "repl_reports"
R_REPORT_TABLE string = "reply_reports"
P_DELCODE_TABLE string = "post_del_codes"
R_DELCODE_TABLE string = "repl_del_codes"
R_DELCODE_TABLE string = "reply_del_codes"
)
func initDB(path string) error {
......@@ -72,14 +75,15 @@ func initDB(path string) error {
// create delcodes table for posts
`create table if not exists ` + P_DELCODE_TABLE + `(
id integer primary key not null,
code text not null,
hash text not null,
salt text not null,
foreign key (id) references ` + POST_TABLE + `(id)
);`,
// create delcodes table for replies
// TODO(krourke) add salt
`create table if not exists ` + R_DELCODE_TABLE + `(
id integer primary key not null,
code text not null,
hash text not null,
salt text not null,
foreign key (id) references ` + REPLY_TABLE + `(id)
);`,
}
......@@ -142,16 +146,16 @@ func addDelCode(tx *sql.Tx, d *DeleteCode) error {
var ins *sql.Stmt
var err error
if d.Parent < 0 { // if for top-level post
ins, err = tx.Prepare("insert or ignore into " + P_DELCODE_TABLE + " (id, code) values (?, ?)")
ins, err = tx.Prepare("insert or ignore into " + P_DELCODE_TABLE + " (id, hash, salt) values (?, ?, ?)")
} else { // else if for a post reply
ins, err = tx.Prepare("insert or ignore into " + R_DELCODE_TABLE + " (id, code) values (?, ?)")
ins, err = tx.Prepare("insert or ignore into " + R_DELCODE_TABLE + " (id, hash, salt) values (?, ?, ?)")
}
if err != nil {
return nil
}
defer ins.Close()
_, err = ins.Exec(d.Id, d.Hash)
_, err = ins.Exec(d.Id, d.Hash, d.Salt)
return err
}
......@@ -173,7 +177,7 @@ func addReply(tx *sql.Tx, postId int64, r *Reply) error {
}
// adds a report to a specified reply
func addReplReport(tx *sql.Tx, r *Reply, v *Report) error {
func addReplyReport(tx *sql.Tx, r *Reply, v *Report) error {
if r == nil {
return errors.New("posts/db: cannot add reports to nil reply")
} else if r.Id < 0 || r.Message == "" {
......@@ -196,12 +200,32 @@ func addReplReport(tx *sql.Tx, r *Reply, v *Report) error {
func getPostNum(tx *sql.Tx) (int64, error) {
row := tx.QueryRow("select max(id) from " + POST_TABLE)
var postnum int64
if err := row.Scan(&postnum); err != nil {
return 0, err
if err := row.Scan(&postnum); err == sql.ErrNoRows {
return 0, ErrPostNotFound
} else if err != nil {
return -1, err
}
return postnum, nil
}
// retrieves the highest active Reply.Id for a given Post
func getReplyNum(tx *sql.Tx, parent int64) (int64, error) {
p, err := Lookup(parent)
if p == nil {
return -1, ErrPostNotFound
} else if err != nil {
return -1, err
}
row := tx.QueryRow("select max(reply_num) from " + REPLY_TABLE)
var replynum int64
if err := row.Scan(&replynum); err == sql.ErrNoRows {
return 0, nil
} else if err != nil {
return -1, err
}
return replynum, nil
}
// retrieves all Posts in the database, sorted by id
func getAllPosts(tx *sql.Tx) ([]Post, error) {
rows, err := tx.Query("select id from " + POST_TABLE)
......@@ -238,7 +262,7 @@ func getPost(tx *sql.Tx, id int64) (*Post, error) {
// get post with id
row := tx.QueryRow("select * from "+POST_TABLE+" where id = ?", id)
if err := row.Scan(&postId, &message, &rawTags, &timesShared, &date, &attachmentUri); err == sql.ErrNoRows {
return nil, nil
return nil, ErrPostNotFound
} else if err != nil {
return nil, err
}
......@@ -336,21 +360,22 @@ func getPost(tx *sql.Tx, id int64) (*Post, error) {
//
// delcode must the cleartext of the deletion passphrase if any.
func deletePost(tx *sql.Tx, id int64, delcode string) error {
var hash string
err := tx.QueryRow("select code from " + P_DELCODE_TABLE + " where id = ?").Scan(&hash)
var hash, salt string
err := tx.QueryRow("select hash, salt from "+P_DELCODE_TABLE+" where id = ?").Scan(&hash, &salt)
if err == sql.ErrNoRows {
// if delcode supplied but post not protected
if delcode != "" {
return ErrUnauthorized
}
// else if post is unprotected and no code specified, simply remove the post
// else if post is unprotected and no code specified, simply remove the
// post
return removePost(tx, id)
} else if err != nil {
return err
}
// check supplied code against the stored hash
err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(delcode))
err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(delcode+salt))
if err != nil {
return ErrUnauthorized
}
......
/* 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. */
package posts
import (
"crypto/rand"
"encoding/base64"
"golang.org/x/crypto/bcrypt"
)
// A DeleteCode is a simple association of a hashed passphrase to a Post or
// Reply.
type DeleteCode struct {
Id int64
Parent int64 // negative if post is not a reply
Hash string
Salt string
}
// NewDeleteCode() create a new fully qualified DeleteCode. If the DeleteCode
// is for a Post, then the parent parameter should be negative.
func NewDeleteCode(id, parent int64, code string) *DeleteCode {
// return nil if ID is invalid; IDs start at 1.
// if code is an empty string then no DeleteCode should be made
if id <= 0 || code == "" {
return nil
}
// generate a random 32-byte salt
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return nil
}
salt := base64.URLEncoding.EncodeToString(buf)
code += salt
// hash the salted password with bcrypt (which itself uses an algorithmic
// salt)
hash, err := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost)
if err != nil {
return nil
}
return &DeleteCode{
Id: id,
Parent: parent,
Hash: string(hash),
Salt: salt,
}
}
......@@ -17,7 +17,6 @@ import (
"path/filepath"
"strings"
"golang.org/x/crypto/bcrypt"
"gitlab.com/tokumei/tokumei/globals"
"gitlab.com/tokumei/tokumei/timedate"
)
......@@ -29,7 +28,6 @@ var (
ErrBadRange = errors.New("posts: range query is malformed")
)
/* logging */
// A Post is a parent to a slice of Reply and Report and is identified by Id,
// must have a Message string, may have option tags, and attachments.
// The only associated metadata is the number of times shared, and the (UTC)
......@@ -49,19 +47,6 @@ type Post struct {
delcode string
}
// A Reply is a simple struct containing an Id, a Message and the (UTC) date it
// was posted. Reports on a Reply are handled much in the same way that they are
// handled for a Post.
type Reply struct {
Id int64 `json:"id"`
Message string `json:"message"`
DatePosted int64 `json:"date_posted"`
Reports []Report `json:"reports"`
parentId int64
isFinal bool
delcode string
}
// A Report is a simple way to contain data about objectionable user content.
// Some reasonable Types might "illegal content" or "spam". It is typically
// expected that a Reason also be provided so that Reports are not submitted on
......@@ -71,15 +56,6 @@ type Report struct {
Reason string `json:"reason"`
}
// A DeleteCode is a simple association of a hashed passphrase to a Post or
// Reply.
//TODO(krourke) add salts
type DeleteCode struct {
Id int64 `json:"id"`
Parent int64 `json:"parent"` // negative if post is not a reply
Hash string `json:"hash"`
}
func prettyJson(v interface{}) (string, error) {
res, err := json.MarshalIndent(v, "", " ")
if err != nil {
......@@ -127,32 +103,6 @@ func (p PostSlice) Len() int { return len(p) }
func (p PostSlice) Less(i, j int) bool { return p[i].Id < p[j].Id }
func (p PostSlice) Swap(i, j int) { p[i], p[j] = p[j], p[i] }
// Reply implements fmt.Stringer; it is printed as formatted JSON.
func (r Reply) String() string {
s, _ := prettyJson(r)
return s
}
// It may be useful to validate a reply after it has been retreived from the
// database in the event that the database has been tampered with and invalid
// data is present.
func (r Reply) IsValid() bool {
return r.Id >= 0 && r.Message != "" && r.isFinal
}
// GetNumReports() returns the number of times a Reply has been reported.
func (r Reply) GetNumReports() int64 {
return int64(len(r.Reports))
}
// ReplySlice is a slice of Reply which imposes ordering by Id
type ReplySlice []Reply
// ReplySlice implements sort.Interface
func (r ReplySlice) Len() int { return len(r) }
func (r ReplySlice) Less(i, j int) bool { return r[i].Id < r[j].Id }
func (r ReplySlice) Swap(i, j int) { r[i], r[j] = r[j], r[i] }
// NewPost() creates a new Post without a valid Id. Finalize() must be called
// to assign an Id and create the expected directory structure for attachments.
// Returns nil on error. Code is a cleartext passphrase used to authenticate
......@@ -182,16 +132,22 @@ func NewPost(message, filepath, code string, tags []string) *Post {
// 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.
func (p *Post) Finalize() (string, error) {
// 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
}
// assign ID
p.Id = GetPostNum() + 1
dir := fmt.Sprintf("%s/%d", globals.POSTDIR, p.Id)
p.Id, err = GetPostNum()
if err != nil && err != ErrPostNotFound {
return "", err
}
p.Id += 1
// check attachment file exists if present then move to public location
// check attachment file exists 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
......@@ -222,44 +178,6 @@ func (p *Post) Finalize() (string, error) {
return p.delcode, nil
}
// NewReply() creates a new Reply without a valid Id. Finalize() must be called
// to assign a reply number (Reply.Id).
func NewReply(parent int64, comment string) *Reply {
if parent <= 0 {
return nil
}
date := timedate.UnixDateStamp(-1) // get current date at UTC 00:00
return &Reply{
Message: comment,
DatePosted: date,
parentId: parent,
isFinal: false,
}
}
// NewDeleteCode() create a new fully qualified DeleteCode. If the DeleteCode
// is for a Post, then the parent parameter should be negative.
func NewDeleteCode(id, parent int64, code string) *DeleteCode {
// return nil if ID is invalid; IDs start at 1.
if id <= 0 {
return nil
}
// hash the password
hash, err := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost)
if err != nil {
return nil
}
return &DeleteCode{
Id: id,
Parent: parent,
Hash: string(hash),
}
}
// InitDB() initializes the database with the correct schema for post and reply
// storage.
func InitDB(path string) error {
......@@ -279,17 +197,17 @@ func ReadPosts() ([]Post, error) {
}
// GetPostNum() retrieves the highest active Post ID from the database.
// The next Post's ID should always be this number plus one. On error, 0 is
// returned, since Post IDs always start at 1.
func GetPostNum() int64 {
// The next Post's ID should always be this number plus one. Return value is
// negative if an error occurred. If there are no posts, then 0 is returned.
func GetPostNum() (int64, error) {
tx, err := db.Begin()
if err != nil {
return 0
return -1, err
}
postnum, _ := getPostNum(tx) // returns 0 on error
postnum, err := getPostNum(tx) // returns 0 on error
tx.Commit()
return postnum
return postnum, err
}
// Lookup() retreives a Post from the database.
......@@ -319,6 +237,8 @@ func Lookup(id int64) (*Post, error) {
//
// If the intention is to return a single post, and you know the post ID, then
// the Lookup function should be used instead.
//
// TODO(krourke) fix range to be inclusive (l, h)
func GetPostsRange(l, h int) []Post {
posts, err := ReadPosts()
if err != nil {
......@@ -375,35 +295,6 @@ func AddPost(p *Post, code string) error {
return tx.Commit()
}
// AddReply() adds a reply to a post by the postId. As with AddPost(), code is
// an optional cleartext deletion code; pass an empty string to omit the delete
// code and prevent reply-deletion by users.
func AddReply(postId int64, r *Reply, code string) error {
tx, err := db.Begin()
if err != nil {
return err
}
if err := addReply(tx, postId, r); err != nil {
return err
}
if code == "" { // if no delcode to add
return tx.Commit()
}
// else hash and save delcode
hash, err := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost)
if err != nil {
return err
}
if err = addDelCode(tx, &DeleteCode{
Id: r.Id,
Parent: postId,
Hash: string(hash),
}); err != nil {
return err
}
return tx.Commit()
}
// ForceDelete() deletes a Post specified by id and all associated replies,
// reports, etc. This function does not respect user-specified deletion codes.
func ForceDelete(id int64) error {
......
/* 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. */
package posts
import (
"gitlab.com/tokumei/tokumei/timedate"
)
// A Reply is a simple struct containing an Id, a Message and the (UTC) date it
// was posted. Reports on a Reply are handled much in the same way that they are
// handled for a Post.
type Reply struct {
Id int64 `json:"id"`
Message string `json:"message"`
DatePosted int64 `json:"date_posted"`
Reports []Report `json:"reports"`
parentId int64
isFinal bool
delcode string
}
// Reply implements fmt.Stringer; it is printed as formatted JSON.
func (r Reply) String() string {
s, _ := prettyJson(r)
return s
}
// It may be useful to validate a reply after it has been retreived from the
// database in the event that the database has been tampered with and invalid
// data is present.
func (r Reply) IsValid() bool {
return r.Id >= 0 && r.Message != "" && r.isFinal
}
// GetNumReports() returns the number of times a Reply has been reported.
func (r Reply) GetNumReports() int64 {
return int64(len(r.Reports))
}
// ReplySlice is a slice of Reply which imposes ordering by Id
type ReplySlice []Reply
// ReplySlice implements sort.Interface
func (r ReplySlice) Len() int { return len(r) }
func (r ReplySlice) Less(i, j int) bool { return r[i].Id < r[j].Id }
func (r ReplySlice) Swap(i, j int) { r[i], r[j] = r[j], r[i] }
// NewReply() creates a new Reply without a valid Id. Finalize() must be called
// to assign a reply number (Reply.Id).
func NewReply(parent int64, code, comment string) *Reply {
if parent <= 0 {
return nil
}
date := timedate.UnixDateStamp(-1) // get current date at UTC 00:00
return &Reply{
Message: comment,
DatePosted: date,
parentId: parent,
isFinal: false,
delcode: code,
}
}
// Finalize() finalizes a new Post by assigning an Id to the Reply so that it
// may be inserted into the database. If a Reply has already been finalized,
// then this function does nothing.
func (r *Reply) Finalize() (password string, err error) {
if r.isFinal == true {
return r.delcode, nil
}
// assign reply ID
r.Id, err = GetReplyNum(r.parentId)
if err != nil && err != ErrPostNotFound {
return "", err
}
r.Id += 1
r.isFinal = true
return r.delcode, nil
}
// GetReplyNum() retrieves the highest active Reply ID for a particular post
// from the database. The next Reply ID should always be this number plus one.
// Return value is negative if an error occurred. If there are no replies, then
// 0 is returned.
func GetReplyNum(parentID int64) (int64, error) {
tx, err := db.Begin()
if err != nil {
return -1, err
}
replynum, err := getReplyNum(tx, parentID) // returns 0 on error
tx.Commit()
return replynum, err
}
// AddReply() adds a reply to a post by the postId. As with AddPost(), code is
// an optional cleartext deletion code; pass an empty string to omit the delete
// code and prevent reply-deletion by users.
func AddReply(postId int64, r *Reply, code string) error {
tx, err := db.Begin()
if err != nil {
return err
}
if err := addReply(tx, postId, r); err != nil {
return err
}
if code == "" { // if no delcode to add
return tx.Commit()
}
// add the code to the database
if err = addDelCode(tx, NewDeleteCode(r.Id, r.parentId, code)); err != nil {
return err
}
return tx.Commit()
}
......@@ -7,7 +7,6 @@
package srv
import (
/* Standard library packages */
"encoding/json"
"fmt"
"log"
......
File deleted
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment