Unverified Commit 5997178d authored by Kevin Lyda's avatar Kevin Lyda
Browse files

Move to storage module.

parent 737a2693
Loading
Loading
Loading
Loading

folders/connection.go

deleted100644 → 0
+0 −64
Original line number Diff line number Diff line
// Package folders are all the routines and sql for managing folders.
package folders

import (
	"embed"
	"errors"
	"os"
	"path"

	"github.com/adrg/xdg"
	"github.com/golang-migrate/migrate/v4"
	"github.com/golang-migrate/migrate/v4/source/iofs"
	"github.com/jmoiron/sqlx"

	// Included to connect to sqlite.
	_ "github.com/golang-migrate/migrate/v4/database/sqlite"
	_ "modernc.org/sqlite"
)

//go:embed sql/*.sql
var fs embed.FS

// Store is the store for folders.
type Store struct {
	user string
	db   *sqlx.DB
}

// Open opens the folders database.
func Open(user string) (*Store, error) {
	fdir := path.Join(xdg.DataHome, "BULLETIN")
	err := os.MkdirAll(fdir, 0700)
	if err != nil {
		return nil, errors.New("bulletin directory problem")
	}
	fdb := path.Join(fdir, "bboard.db")

	// Run db migrations if needed.
	sqldir, err := iofs.New(fs, "sql")
	if err != nil {
		return nil, err
	}
	m, err := migrate.NewWithSourceInstance("iofs", sqldir, "sqlite://"+fdb+"?_pragma=foreign_keys(1)")
	if err != nil {
		return nil, err
	}
	err = m.Up()
	if err != nil && err != migrate.ErrNoChange {
		return nil, err
	}
	m.Close()

	store := &Store{user: user}
	store.db, err = sqlx.Connect("sqlite", "file://"+fdb+"?_pragma=foreign_keys(1)")
	if err != nil {
		return nil, errors.New("bulletin database problem")
	}
	return store, nil
}

// Close closes the db backing the store.
func (fstore *Store) Close() {
	fstore.db.Close()
}
+73 −118
Original line number Diff line number Diff line
@@ -2,165 +2,120 @@
package folders

import (
	"context"
	"errors"
	"fmt"
	"strings"

	"github.com/jmoiron/sqlx"
	"git.lyda.ie/kevin/bulletin/storage"
	"git.lyda.ie/kevin/bulletin/this"
)

// FolderVisibility is the folder visibility level.
type FolderVisibility string
// ValidFolder validates the folder name for this user.
func ValidFolder(folder string) (string, error) {
	if strings.Contains(folder, "%") {
		return "", errors.New("Folder name cannot contain a %")
	}
	correct := FindFolder(folder)
	if correct == "" {
		return "", errors.New("Unable to select the folder")
	}
	if !IsFolderAccess(correct, this.User.Login) {
		// TODO: Should be:
		//       WRITE(6,'('' You are not allowed to access folder.'')')
		//       WRITE(6,'('' See '',A,'' if you wish to access folder.'')')
		return "", errors.New("Unable to select the folder")
	}
	return correct, nil
}

// Values for FolderVisibility.
const (
	FolderPublic      FolderVisibility = "public"
	FolderSemiPrivate                  = "semi-private"
	FolderPrivate                      = "private"
	FolderPublic      int64 = 0
	FolderSemiPrivate       = 1
	FolderPrivate           = 2
)

// FolderCreateOptions are a list of folder options.
type FolderCreateOptions struct {
	Always      int
	Brief       int
	Description string
	Notify      int
	Owner       string
	Readnew     int
	Shownew     int
	System      int
	Expire      int
	Visibility  FolderVisibility
}

// CreateFolder creates a new folder.
func (s *Store) CreateFolder(name string, options FolderCreateOptions) error {
	if !IsAlphaNum(name) {
func CreateFolder(owner string, options storage.CreateFolderParams) error {
	if !IsAlphaNum(options.Name) {
		return errors.New("Folder can only have letters and numbers")
	}
	name = strings.ToUpper(name)
	_, err := s.db.Exec(
		`INSERT INTO folders
			(name, always, brief, description, notify, owner, readnew,
			 shownew, system, expire, visibility)
			VALUES
			($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
		name,
		options.Always,
		options.Brief,
		options.Description,
		options.Notify,
		options.Owner,
		options.Readnew,
		options.Shownew,
		options.System,
		options.Expire,
		options.Visibility,
	)
	// TODO: turn _ into rows and make sure it was added.
	// TODO: process this error a bit more to give a better error message.
	options.Name = strings.ToUpper(options.Name)

	ctx := context.TODO()
	tx, err := this.Store.Begin()
	if err != nil {
		return err
	}

// FolderListOptions are a list of folder options.
type FolderListOptions struct {
	defer tx.Rollback()
	qtx := this.Q.WithTx(tx)
	err = qtx.CreateFolder(ctx, options)
	if err != nil {
		return err
	}

// FolderListRow are a list of folder options.
type FolderListRow struct {
	Name        string `db:"name"`
	Count       int    `db:"count"`
	Description string `db:"description"`
	err = qtx.AddFolderOwner(ctx, storage.AddFolderOwnerParams{
		Folder: options.Name,
		Login:  owner,
	})
	if err != nil {
		return err
	}

// ListFolder creates a new folder.
func (s *Store) ListFolder(user string, _ FolderListOptions) ([]FolderListRow, error) {
	// TODO: get message counts.
	var rows *sqlx.Rows
	var err error
	if s.IsUserAdmin(user) {
		rows, err = s.db.Queryx(
			`SELECT name, 0 as count, description FROM folders
			ORDER BY name`,
		)
	} else {
		// TODO: limit access.
		rows, err = s.db.Queryx(
			`SELECT f.name, count(m.id) as count, f.description FROM folders AS f
			LEFT JOIN messages AS m ON f.name = m.folder
			GROUP By f.name
			ORDER BY f.name`)
	}
	flr := []FolderListRow{}
	if err != nil {
	// TODO: process this error a bit more to give a better error message.
		return flr, err
	return tx.Commit()
}
	for rows.Next() {
		row := FolderListRow{}
		err := rows.StructScan(&row)

// ListFolder provides a list of folders that this.User has access to.
func ListFolder() ([]storage.ListFolderRow, error) {
	// TODO: need to check access.
	ctx := context.TODO()
	rows, err := this.Q.ListFolder(ctx)
	if err != nil {
		// TODO: process this error a bit more to give a better error message.
			return flr, err
		return []storage.ListFolderRow{}, err
	}
		flr = append(flr, row)
	}
	return flr, nil
	return rows, nil
}

// FindFolder finds a folder based on the prefix.
func (s *Store) FindFolder(name string) string {
	var folder string
	s.db.Get(&folder, "SELECT name FROM folders where name = $1", name)
func FindFolder(name string) string {
	ctx := context.TODO()
	folder, _ := this.Q.FindFolderExact(ctx, name)
	if folder != "" {
		return folder
	}
	s.db.Get(&folder,
		`SELECT name FROM folders where name LIKE $1
	     ORDER BY name LIMIT 1`, name+"%")
	folder, _ = this.Q.FindFolderPrefix(ctx, name)
	return folder
}

// IsFolderAccess checks if a user can access a folder.
func (s *Store) IsFolderAccess(name, user string) bool {
	found := 0
	s.db.Get(&found,
		`SELECT 1 FROM folders AS f LEFT JOIN co_owners AS c ON f.name = c.folder
		   WHERE f.name = $1 AND
			       (f.visibility = "public"
						  OR (f.owner = $2 OR c.OWNER = $2))`,
		name, user)
func IsFolderAccess(name, login string) bool {
	ctx := context.TODO()
	found, _ := this.Q.IsFolderAccess(ctx, storage.IsFolderAccessParams{
		Name:  name,
		Login: login,
	})
	return found == 1
}

// IsFolderOwner checks if a user is a folder owner.
func (s *Store) IsFolderOwner(name, user string) bool {
	found := 0
	s.db.Get(&found,
		`SELECT 1 FROM folders AS f LEFT JOIN co_owners AS c
	   ON f.name = c.folder
		 WHERE f.name = $1 AND (f.owner = '$2' OR c.OWNER = '$2')`,
		name, user)
func IsFolderOwner(folder, login string) bool {
	ctx := context.TODO()
	found, _ := this.Q.IsFolderOwner(ctx, storage.IsFolderOwnerParams{
		Folder: folder,
		Login:  login,
	})
	return found == 1
}

// DeleteFolder creates a new folder.
func (s *Store) DeleteFolder(name string) error {
// DeleteFolder deletes a folder.
func DeleteFolder(name string) error {
	// TODO: make sure user can delete this table.
	results, err := s.db.Exec("DELETE FROM folders WHERE name=$1", name)
	ctx := context.TODO()
	err := this.Q.DeleteFolder(ctx, name)
	// TODO: process this error a bit more to give a better error message.
	if err != nil {
		return err
	}
	rows, err := results.RowsAffected()
	if err != nil {
		return err
	}
	if rows == 0 {
		return errors.New("No such folder found")
	}
	if rows != 1 {
		return fmt.Errorf("Unexpected number (%d) of folders removed", rows)
	}
	return nil
}
+41 −80
Original line number Diff line number Diff line
package folders

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"time"

	"git.lyda.ie/kevin/bulletin/storage"
	"git.lyda.ie/kevin/bulletin/this"
)

// CreateMessage creates a new folder.
func (s *Store) CreateMessage(author, subject, message, folder string, permanent, shutdown int, expiration *time.Time) error {
func CreateMessage(author, subject, message, folder string, permanent, shutdown int, expiration *time.Time) error {
	ctx := context.TODO()
	if expiration == nil {
		var days int
		err := s.db.Get(&days, "SELECT expire FROM folders WHERE name = $1", folder)
		days, err := this.Q.GetFolderExpire(ctx, folder)
		if err != nil {
			return err
		}
		if days <= 0 {
			// TODO: Get from site config.
			days = 14
		}
		exp := time.Now().AddDate(0, 0, days)
		exp := time.Now().AddDate(0, 0, int(days))
		expiration = &exp
	}
	// TODO: replace _ with rows and check.
	_, err := s.db.Exec(
		`INSERT INTO messages
			(id, folder, author, subject, message, permanent, shutdown, expiration)
			VALUES
			((SELECT COALESCE(MAX(id), 0) + 1 FROM messages WHERE folder = $1), $1, $2, $3, $4, $5, $6, $7)`,
		folder,
		author,
		subject,
		message,
		permanent,
		shutdown,
		expiration, // TODO: handle this being NULL
	)
	err := this.Q.CreateMessage(ctx, storage.CreateMessageParams{
		Folder:     folder,
		Folder_2:   folder,
		Author:     author,
		Subject:    subject,
		Message:    message,
		Permanent:  int64(permanent),
		Shutdown:   int64(shutdown),
		Expiration: *expiration,
	})
	// TODO: process this error a bit more to give a better error message.
	return err
}

// Message contains a message
type Message struct {
	ID       int       `db:"id"`
	Folder   string    `db:"folder"`
	Author   string    `db:"author"`
	Subject  string    `db:"subject"`
	Message  string    `db:"message"`
	Expires  time.Time `db:"expiration"`
	CreateAt time.Time `db:"create_at"`
	UpdateAt time.Time `db:"update_at"`
}

// String renders a message.
func (m *Message) String() string {
	buf := &strings.Builder{}
	// TODO: Show if an edit has happened.
	fmt.Fprintf(buf, "From: \"%s\" %s\n", m.Author, m.CreateAt.Format("02-JAN-2006 15:04:05"))
	fmt.Fprintf(buf, "To: %s\n", m.Folder)
	fmt.Fprintf(buf, "Subj: %s\n\n", m.Subject)
	fmt.Fprintf(buf, "%s\n", m.Message)

	return buf.String()
}

// ReadMessage reads a message for a user.
func (s *Store) ReadMessage(login, folder string, msgid int) (*Message, error) {
	msg := &Message{}
	s.db.Get(msg,
		`SELECT id, folder, author, subject, message, expiration, create_at, update_at
			      FROM messages WHERE folder = $1 AND id = $2`, folder, msgid)
	if msg.ID != msgid || msgid == 0 {
		return nil, errors.New("Specified message was not found")
func ReadMessage(login, folder string, msgid int64) (*storage.Message, error) {
	ctx := context.TODO()
	msg, err := this.Q.ReadMessage(ctx, storage.ReadMessageParams{
		Folder: folder,
		ID:     msgid,
	})
	if err != nil {
		return nil, err
	}
	// TODO: replace _ with rows and check.
	s.db.Exec(
		"INSERT INTO read (login, folder, msgid) VALUES ($1, $2, $3)",
		login, folder, msgid)

	return msg, nil
	if msg.ID != int64(msgid) || msgid == 0 {
		return nil, errors.New("Specified message was not found")
	}
	err = this.Q.SetMessageSeen(ctx, storage.SetMessageSeenParams{
		Login:  login,
		Folder: folder,
		Msgid:  int64(msgid),
	})

// ListMessagesOptions has the options for a ListMessages call.
type ListMessagesOptions struct {
	// TODO: is this needed?  Maybe for message order?
	return &msg, nil
}

// ListMessages lists messages.
func (s *Store) ListMessages(folder string, options *ListMessagesOptions) ([]Message, error) {
	messages := []Message{}
	if options != nil {
		return messages, errors.New("TODO: options aren't implemented")
	}
	rows, err := s.db.Queryx(
		`SELECT id, folder, author, subject, message, expiration, create_at, update_at
			      FROM messages WHERE folder = $1`, folder)
	if err != nil {
		return messages, nil
	}
	for rows.Next() {
		msg := Message{}
		err = rows.StructScan(&msg)
		if err != nil {
			return []Message{}, nil
		}
		messages = append(messages, msg)
	}
	return messages, nil

func ListMessages(folder string) ([]storage.ListMessagesRow, error) {
	ctx := context.TODO()
	// TODO: options aren't implemented - need to set them?
	rows, err := this.Q.ListMessages(ctx, folder)
	return rows, err
}
+0 −21
Original line number Diff line number Diff line
--- Dropped in reverse order to deal with foreign keys.
DROP TABLE mark;
DROP TABLE read;

DROP INDEX messages_idx_expiration;
DROP INDEX messages_idx_shutdown;
DROP TABLE messages;

DROP TRIGGER co_owners_after_update_update_at;
DROP TABLE co_owners;

DROP TRIGGER folders_before_delete_protect;
DROP TRIGGER folders_after_update_update_at;
DROP TRIGGER folders_before_update_validate;
DROP TRIGGER folders_before_insert_validate;
DROP TABLE folders;

DROP TRIGGER users_before_delete_protect;
DROP TRIGGER users_before_update_protect;
DROP TRIGGER users_after_update_update_at;
DROP TABLE users;

folders/sql/1_create_table.up.sql

deleted100644 → 0
+0 −168
Original line number Diff line number Diff line
CREATE TABLE users (
  login       VARCHAR(12)  NOT NULL PRIMARY KEY,
  name        VARCHAR(53)  NOT NULL,
  admin       INT          DEFAULT 0 NOT NULL,
  moderator   INT          DEFAULT 0 NOT NULL,
  alert       INT          NOT NULL DEFAULT 0,  --- 0=no, 1=brief, 2=readnew
  disabled    INT          DEFAULT 0 NOT NULL,
  last_login  TIMESTAMP    DEFAULT CURRENT_TIMESTAMP NOT NULL,
  create_at   TIMESTAMP    DEFAULT CURRENT_TIMESTAMP NOT NULL,
  update_at   TIMESTAMP    DEFAULT CURRENT_TIMESTAMP NOT NULL
) WITHOUT ROWID;

CREATE TRIGGER users_after_update_update_at
  AFTER UPDATE ON users FOR EACH ROW
  WHEN NEW.update_at = OLD.update_at    --- avoid infinite loop
BEGIN
  UPDATE users SET update_at=CURRENT_TIMESTAMP WHERE login=NEW.login;
END;

INSERT INTO users (login, name, admin)
       VALUES ('SYSTEM', 'System User', 1);

CREATE TRIGGER users_before_update_protect
  AFTER UPDATE ON users FOR EACH ROW
  WHEN OLD.login = 'SYSTEM' AND (NEW.login != OLD.login OR NEW.admin != 1)
BEGIN
  SELECT RAISE (ABORT, 'SYSTEM user is protected');
END;

CREATE TRIGGER users_before_delete_protect
  BEFORE DELETE on users FOR EACH ROW
  WHEN OLD.login = 'SYSTEM'
BEGIN
  SELECT RAISE (ABORT, 'SYSTEM user is protected');
END;

CREATE TABLE folders (
  name        VARCHAR(25)  NOT NULL PRIMARY KEY,
  always      INT          DEFAULT 0 NOT NULL,
  brief       INT          DEFAULT 0 NOT NULL,
  description VARCHAR(53)  DEFAULT 0 NOT NULL,
  notify      INT          DEFAULT 0 NOT NULL,
  readnew     INT          DEFAULT 0 NOT NULL,
  shownew     INT          DEFAULT 0 NOT NULL,
  system      INT          DEFAULT 0 NOT NULL,
  expire      INT          DEFAULT 14 NOT NULL,
  visibility  TEXT         DEFAULT 'public' NOT NULL,
  create_at   TIMESTAMP    DEFAULT CURRENT_TIMESTAMP NOT NULL,
  update_at   TIMESTAMP    DEFAULT CURRENT_TIMESTAMP NOT NULL
) WITHOUT ROWID;

CREATE TRIGGER folders_before_insert_validate
  BEFORE INSERT on folders
BEGIN
  SELECT
    CASE
      WHEN NEW.name != UPPER(NEW.name) OR NEW.name GLOB '*[^A-Z0-9_-]*' THEN
        RAISE (ABORT, 'Invalid folder name')
    END;
END;

CREATE TRIGGER folders_before_update_validate
  BEFORE UPDATE on folders
BEGIN
  SELECT
    CASE
      WHEN NEW.name != UPPER(NEW.name) OR NEW.name GLOB '*[^A-Z0-9_-]*' THEN
        RAISE (ABORT, 'Invalid folder name')
      WHEN OLD.name = 'GENERAL' AND OLD.name != NEW.name THEN
        RAISE (ABORT, 'GENERAL folder is protected')
    END;
END;

CREATE TRIGGER folders_after_update_update_at
  AFTER UPDATE ON folders FOR EACH ROW
    WHEN NEW.update_at = OLD.update_at    --- avoid infinite loop
BEGIN
  UPDATE folders SET update_at=CURRENT_TIMESTAMP WHERE name=NEW.name;
END;

CREATE TRIGGER folders_before_delete_protect
  BEFORE DELETE on folders FOR EACH ROW
  WHEN OLD.name = 'GENERAL'
BEGIN
  SELECT RAISE (ABORT, 'GENERAL folder is protected');
END;

INSERT INTO folders (name, description, system, shownew)
       VALUES ('GENERAL', 'Default general bulletin folder.', 1, 1);

CREATE TABLE owners (
  folder      VARCHAR(25)  REFERENCES folders(name) ON DELETE CASCADE ON UPDATE CASCADE,
  owner       VARCHAR(25)  REFERENCES users(login) ON UPDATE CASCADE,
  create_at   TIMESTAMP    DEFAULT CURRENT_TIMESTAMP NOT NULL,
  update_at   TIMESTAMP    DEFAULT CURRENT_TIMESTAMP NOT NULL,
  PRIMARY KEY (folder, owner)
) WITHOUT ROWID;

CREATE TRIGGER owners_after_update_update_at
  AFTER UPDATE ON owners FOR EACH ROW
    WHEN NEW.update_at = OLD.update_at    --- avoid infinite loop
BEGIN
  UPDATE owners SET update_at=CURRENT_TIMESTAMP WHERE folder=NEW.folder AND owner=NEW.owner;
END;

INSERT INTO owners (folder, owner) VALUES ('GENERAL', 'SYSTEM');

CREATE TABLE messages (
  id          INT          NOT NULL,
  folder      VARCHAR(25)  REFERENCES folders(name) ON DELETE CASCADE ON UPDATE CASCADE,
  author      VARCHAR(25)  REFERENCES users(login) ON UPDATE CASCADE,
  subject     VARCHAR(53)  NOT NULL,
  message     TEXT         NOT NULL,
  permanent   INT          DEFAULT 0 NOT NULL,
  shutdown    INT          DEFAULT 0 NOT NULL,
  expiration  TIMESTAMP    NOT NULL,
  create_at   TIMESTAMP    DEFAULT CURRENT_TIMESTAMP NOT NULL,
  update_at   TIMESTAMP    DEFAULT CURRENT_TIMESTAMP NOT NULL,
  PRIMARY KEY (id, folder)
) WITHOUT ROWID;
CREATE INDEX messages_idx_shutdown ON messages(shutdown);
CREATE INDEX messages_idx_expiration ON messages(expiration);

CREATE TABLE seen (
  login       VARCHAR(25) REFERENCES users(login) ON DELETE CASCADE ON UPDATE CASCADE,
  folder      VARCHAR(25) REFERENCES folders(name) ON DELETE CASCADE ON UPDATE CASCADE,
  msgid       INT,
  PRIMARY KEY (folder, login, msgid),
  CONSTRAINT read_fk_id_folder
    FOREIGN KEY (msgid, folder)
    REFERENCES messages(id, folder)
    ON DELETE CASCADE
    ON UPDATE CASCADE
) WITHOUT ROWID;

CREATE TABLE mark (
  login       VARCHAR(25) REFERENCES users(login) ON DELETE CASCADE ON UPDATE CASCADE,
  folder      VARCHAR(25) REFERENCES folders(name) ON DELETE CASCADE ON UPDATE CASCADE,
  msgid       INT,
  PRIMARY KEY (folder, login, msgid),
  CONSTRAINT mark_fk_id_folder
    FOREIGN KEY (msgid, folder)
    REFERENCES messages(id, folder)
    ON DELETE CASCADE
    ON UPDATE CASCADE
) WITHOUT ROWID;

CREATE TABLE access (
  login       VARCHAR(25) REFERENCES users(login) ON DELETE CASCADE ON UPDATE CASCADE,
  folder      VARCHAR(25) REFERENCES folders(name) ON DELETE CASCADE ON UPDATE CASCADE,
  PRIMARY KEY (login, folder)
) WITHOUT ROWID;

--- User folder configs.
CREATE TABLE config (
  login       VARCHAR(25) REFERENCES users(login) ON DELETE CASCADE ON UPDATE CASCADE,
  folder      VARCHAR(25) REFERENCES folders(name) ON DELETE CASCADE ON UPDATE CASCADE,
  always      INT     NOT NULL DEFAULT 0,
  alert       INT     NOT NULL DEFAULT 0,  --- 0=no, 1=brief, 2=readnew
  PRIMARY KEY (login, folder)
) WITHOUT ROWID;

--- System configs.
CREATE TABLE system (
  name            VARCHAR(12)  NOT NULL PRIMARY KEY,
  default_expire  INT          NOT NULL DEFAULT -1,
  expire_limit    INT          NOT NULL DEFAULT -1
);
Loading