diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..8ac08fb9493f703554cc05613d006cc3d1f44432 --- /dev/null +++ b/Makefile @@ -0,0 +1,7 @@ +.PHONY: all +all: + go generate ./... + + +# vim:ft=make +# diff --git a/ask/ask.go b/ask/ask.go index 30ddba76b976096fe8291441630a8ef187439076..1626a1ce62bc4ca6efef5e9a61ebc1522d45412d 100644 --- a/ask/ask.go +++ b/ask/ask.go @@ -3,3 +3,16 @@ Package ask provides routines to ask questions of users. It handles getting a line of text, getting a choice from a liat and other things. */ package ask + +import "github.com/chzyer/readline" + +// GetLine gets a line. +func GetLine(prompt string) (string, error) { + rl, err := readline.New(prompt) + if err != nil { + return "", err + } + defer rl.Close() + line, err := rl.Readline() + return line, err +} diff --git a/storage/connection.go b/storage/connection.go index 0b7a8c7572d27100fb555c1337ca08daa1849bd6..fcf7c97d9683dab16b58b603a8c7c922e5fa611b 100644 --- a/storage/connection.go +++ b/storage/connection.go @@ -1,14 +1,8 @@ package storage 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. @@ -16,49 +10,11 @@ import ( _ "modernc.org/sqlite" ) -//go:embed migrations/*.sql -var migrationsFS embed.FS - -// Store is the store for bulletin. -type Store struct { - user string - db *sqlx.DB -} - // Open opens the bulletin 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, "bulletin.db") - - // Run db migrations if needed. - migrations, err := iofs.New(migrationsFS, "migrations") - if err != nil { - return nil, err - } - m, err := migrate.NewWithSourceInstance("iofs", migrations, - "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)") +func Open(dbfile string) (*sqlx.DB, error) { + db, err := sqlx.Connect("sqlite", "file://"+dbfile+"?_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() + return db, nil } diff --git a/storage/display.go b/storage/display.go new file mode 100644 index 0000000000000000000000000000000000000000..11d62f7788c77f4c1065dc07dd1b56642ac3ad7d --- /dev/null +++ b/storage/display.go @@ -0,0 +1,35 @@ +package storage + +import ( + "fmt" + "strings" + "time" +) + +// Full renders a message. +func (m *Message) Full() string { + buf := &strings.Builder{} + changed := "*" + if m.CreateAt.Compare(m.UpdateAt) == 0 { + changed = "" + } + fmt.Fprintf(buf, "From: \"%s\" %s%s\n", m.Author, + m.CreateAt.Format("02-JAN-2006 15:04:05"), changed) + 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() +} + +// Short renders a message. +func (m *Message) Short(expire bool) string { + var t time.Time + if expire { + t = m.Expiration + } else { + t = m.CreateAt + } + ts := t.Format("2006-05-04 15:02:01") + return fmt.Sprintf("%4d %-43s %-12s %-10s\n", m.ID, m.Subject, m.Author, ts) +} diff --git a/storage/folders.sql.go b/storage/folders.sql.go index c2df48ff6ca3d20dd35fab7fa112f3d4a3cd70f5..00286ee3c6cf40c34ebbe73c89966ca562978dad 100644 --- a/storage/folders.sql.go +++ b/storage/folders.sql.go @@ -7,7 +7,6 @@ package storage import ( "context" - "database/sql" ) const createFolder = `-- name: CreateFolder :exec @@ -97,7 +96,7 @@ SELECT 1 FROM folders AS f LEFT JOIN owners AS c ON f.name = c.folder type IsFolderAccessParams struct { Name string - Owner sql.NullString + Owner string } func (q *Queries) IsFolderAccess(ctx context.Context, arg IsFolderAccessParams) (int64, error) { @@ -114,7 +113,7 @@ SELECT 1 FROM folders AS f LEFT JOIN owners AS c ON f.name = c.folder type IsFolderOwnerParams struct { Name string - Owner sql.NullString + Owner string } func (q *Queries) IsFolderOwner(ctx context.Context, arg IsFolderOwnerParams) (int64, error) { diff --git a/storage/messages.sql.go b/storage/messages.sql.go index 62b82a477fd87173237a71c8cba1b754166eabb7..0b8d4d90a771e3e3770771cfd7d08b19d277daa9 100644 --- a/storage/messages.sql.go +++ b/storage/messages.sql.go @@ -7,7 +7,6 @@ package storage import ( "context" - "database/sql" "time" ) @@ -20,9 +19,9 @@ INSERT INTO messages ( ` type CreateMessageParams struct { - Folder sql.NullString - Folder_2 sql.NullString - Author sql.NullString + Folder string + Folder_2 string + Author string Subject string Message string Permanent int64 @@ -44,6 +43,17 @@ func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) er return err } +const getFirstMessageID = `-- name: GetFirstMessageID :one +SELECT id FROM messages WHERE folder = ? and id = MIN(id) GROUP BY folder +` + +func (q *Queries) GetFirstMessageID(ctx context.Context, folder string) (int64, error) { + row := q.db.QueryRowContext(ctx, getFirstMessageID, folder) + var id int64 + err := row.Scan(&id) + return id, err +} + const listMessages = `-- name: ListMessages :many SELECT id, folder, author, subject, message, expiration, create_at, update_at FROM messages @@ -52,8 +62,8 @@ WHERE folder = ? type ListMessagesRow struct { ID int64 - Folder sql.NullString - Author sql.NullString + Folder string + Author string Subject string Message string Expiration time.Time @@ -61,7 +71,7 @@ type ListMessagesRow struct { UpdateAt time.Time } -func (q *Queries) ListMessages(ctx context.Context, folder sql.NullString) ([]ListMessagesRow, error) { +func (q *Queries) ListMessages(ctx context.Context, folder string) ([]ListMessagesRow, error) { rows, err := q.db.QueryContext(ctx, listMessages, folder) if err != nil { return nil, err @@ -98,9 +108,9 @@ INSERT INTO mark (login, folder, msgid) VALUES (?, ?, ?) ` type MarkMessageParams struct { - Login sql.NullString - Folder sql.NullString - Msgid sql.NullInt64 + Login string + Folder string + Msgid int64 } func (q *Queries) MarkMessage(ctx context.Context, arg MarkMessageParams) error { @@ -113,9 +123,9 @@ INSERT INTO seen (login, folder, msgid) VALUES (?, ?, ?) ` type SetMessageSeenParams struct { - Login sql.NullString - Folder sql.NullString - Msgid sql.NullInt64 + Login string + Folder string + Msgid int64 } func (q *Queries) SetMessageSeen(ctx context.Context, arg SetMessageSeenParams) error { diff --git a/storage/migrate.go b/storage/migrate.go new file mode 100644 index 0000000000000000000000000000000000000000..ab8f218ba5dd0b774610829d50eb55a5faa7d8dd --- /dev/null +++ b/storage/migrate.go @@ -0,0 +1,35 @@ +package storage + +import ( + "embed" + + "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/source/iofs" + + // Included to connect to sqlite. + _ "github.com/golang-migrate/migrate/v4/database/sqlite" + _ "modernc.org/sqlite" +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +// Migrate creates and updates the database. +func Migrate(dbfile string) error { + // Run db migrations if needed. + migrations, err := iofs.New(migrationsFS, "migrations") + if err != nil { + return err + } + m, err := migrate.NewWithSourceInstance("iofs", migrations, + "sqlite://"+dbfile+"?_pragma=foreign_keys(1)") + if err != nil { + return err + } + defer m.Close() + err = m.Up() + if err != nil && err != migrate.ErrNoChange { + return err + } + return nil +} diff --git a/storage/migrations/1_create_table.up.sql b/storage/migrations/1_create_table.up.sql index 0aafda908e8368ad6b23e759b88c2e0078d81a78..1329304d9a8c0f7b6103554cc0df3a3daec856bc 100644 --- a/storage/migrations/1_create_table.up.sql +++ b/storage/migrations/1_create_table.up.sql @@ -1,5 +1,5 @@ CREATE TABLE users ( - login VARCHAR(12) NOT NULL PRIMARY KEY, + login VARCHAR(12) PRIMARY KEY NOT NULL, name VARCHAR(53) NOT NULL, admin INT DEFAULT 0 NOT NULL, moderator INT DEFAULT 0 NOT NULL, @@ -25,14 +25,14 @@ BEGIN END; CREATE TRIGGER users_before_delete_protect - BEFORE DELETE on users FOR EACH ROW + 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, + name VARCHAR(25) PRIMARY KEY NOT NULL, always INT DEFAULT 0 NOT NULL, brief INT DEFAULT 0 NOT NULL, description VARCHAR(53) DEFAULT 0 NOT NULL, @@ -84,8 +84,10 @@ BEGIN END; CREATE TABLE owners ( - folder VARCHAR(25) REFERENCES folders(name) ON DELETE CASCADE ON UPDATE CASCADE, - owner VARCHAR(25) REFERENCES users(login) ON UPDATE CASCADE, + folder VARCHAR(25) REFERENCES folders(name) + ON DELETE CASCADE ON UPDATE CASCADE NOT NULL, + owner VARCHAR(25) REFERENCES users(login) + ON UPDATE CASCADE NOT NULL, create_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, update_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, PRIMARY KEY (folder, owner) @@ -100,8 +102,12 @@ END; 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, + folder VARCHAR(25) REFERENCES folders(name) + ON DELETE CASCADE ON UPDATE CASCADE + NOT NULL, + author VARCHAR(25) REFERENCES users(login) + ON UPDATE CASCADE + NOT NULL, subject VARCHAR(53) NOT NULL, message TEXT NOT NULL, permanent INT DEFAULT 0 NOT NULL, @@ -115,9 +121,11 @@ 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, + login VARCHAR(25) REFERENCES users(login) + ON DELETE CASCADE ON UPDATE CASCADE NOT NULL, + folder VARCHAR(25) REFERENCES folders(name) + ON DELETE CASCADE ON UPDATE CASCADE NOT NULL, + msgid INT NOT NULL, PRIMARY KEY (folder, login, msgid), CONSTRAINT read_fk_id_folder FOREIGN KEY (msgid, folder) @@ -127,9 +135,11 @@ CREATE TABLE seen ( ) 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, + login VARCHAR(25) REFERENCES users(login) + ON DELETE CASCADE ON UPDATE CASCADE NOT NULL, + folder VARCHAR(25) REFERENCES folders(name) + ON DELETE CASCADE ON UPDATE CASCADE NOT NULL, + msgid INT NOT NULL, PRIMARY KEY (folder, login, msgid), CONSTRAINT mark_fk_id_folder FOREIGN KEY (msgid, folder) @@ -138,24 +148,28 @@ CREATE TABLE mark ( 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, +CREATE TABLE folder_access ( + login VARCHAR(25) REFERENCES users(login) + ON DELETE CASCADE ON UPDATE CASCADE NOT NULL, + folder VARCHAR(25) REFERENCES folders(name) + ON DELETE CASCADE ON UPDATE CASCADE NOT NULL, 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 + login VARCHAR(25) REFERENCES users(login) + ON DELETE CASCADE ON UPDATE CASCADE NOT NULL, + folder VARCHAR(25) REFERENCES folders(name) + ON DELETE CASCADE ON UPDATE CASCADE NOT NULL, + always INT DEFAULT 0 NOT NULL, + alert INT DEFAULT 0 NOT NULL, --- 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 + name VARCHAR(12) PRIMARY KEY NOT NULL, + default_expire INT DEFAULT -1 NOT NULL, + expire_limit INT DEFAULT -1 NOT NULL ); diff --git a/storage/models.go b/storage/models.go index 13279efc60e582c3144ff5daf49f14eaa21a9da1..cfcd89887e7c93496a365b355250c3d3d9b29c4f 100644 --- a/storage/models.go +++ b/storage/models.go @@ -5,18 +5,12 @@ package storage import ( - "database/sql" "time" ) -type Access struct { - Login sql.NullString - Folder sql.NullString -} - type Config struct { - Login sql.NullString - Folder sql.NullString + Login string + Folder string Always int64 Alert int64 } @@ -36,16 +30,21 @@ type Folder struct { UpdateAt time.Time } +type FolderAccess struct { + Login string + Folder string +} + type Mark struct { - Login sql.NullString - Folder sql.NullString - Msgid sql.NullInt64 + Login string + Folder string + Msgid int64 } type Message struct { ID int64 - Folder sql.NullString - Author sql.NullString + Folder string + Author string Subject string Message string Permanent int64 @@ -56,16 +55,16 @@ type Message struct { } type Owner struct { - Folder sql.NullString - Owner sql.NullString + Folder string + Owner string CreateAt time.Time UpdateAt time.Time } type Seen struct { - Login sql.NullString - Folder sql.NullString - Msgid sql.NullInt64 + Login string + Folder string + Msgid int64 } type System struct { diff --git a/storage/queries/messages.sql b/storage/queries/messages.sql index 2f87c6835da581b1693f6d4551ee30cb026058ec..07dcf67c16db4b2629cb01c74f8c2e57ec86098a 100644 --- a/storage/queries/messages.sql +++ b/storage/queries/messages.sql @@ -15,3 +15,6 @@ INSERT INTO mark (login, folder, msgid) VALUES (?, ?, ?); SELECT id, folder, author, subject, message, expiration, create_at, update_at FROM messages WHERE folder = ?; + +-- name: GetFirstMessageID :one +SELECT id FROM messages WHERE folder = ? and id = MIN(id) GROUP BY folder; diff --git a/storage/queries/users.sql b/storage/queries/users.sql index 50ca0c67c5fcfd2eae53ba2e16bf2a3b913a0912..1ee85d5ec08441a91b3cb2719d3029f4e796da76 100644 --- a/storage/queries/users.sql +++ b/storage/queries/users.sql @@ -1,8 +1,9 @@ -- name: GetUser :one -SELECT login, name, admin, disabled FROM users WHERE login = ?; +SELECT * FROM users WHERE login = ?; --- name: AddUser :exec -INSERT INTO users (login, name, admin) VALUES (?, ?, ?); +-- name: AddUser :one +INSERT INTO users (login, name, admin) VALUES (?, ?, ?) +RETURNING *; -- name: IsUserAdmin :one SELECT admin FROM users WHERE login = ?; diff --git a/storage/users.sql.go b/storage/users.sql.go index d51be82cab851d1637f03dfbad4f893a2f875a4d..ae1cad7f0b6854991acbb15bff8a2d00538b05f8 100644 --- a/storage/users.sql.go +++ b/storage/users.sql.go @@ -9,8 +9,9 @@ import ( "context" ) -const addUser = `-- name: AddUser :exec +const addUser = `-- name: AddUser :one INSERT INTO users (login, name, admin) VALUES (?, ?, ?) +RETURNING login, name, admin, moderator, alert, disabled, last_login, create_at, update_at ` type AddUserParams struct { @@ -19,30 +20,40 @@ type AddUserParams struct { Admin int64 } -func (q *Queries) AddUser(ctx context.Context, arg AddUserParams) error { - _, err := q.db.ExecContext(ctx, addUser, arg.Login, arg.Name, arg.Admin) - return err +func (q *Queries) AddUser(ctx context.Context, arg AddUserParams) (User, error) { + row := q.db.QueryRowContext(ctx, addUser, arg.Login, arg.Name, arg.Admin) + var i User + err := row.Scan( + &i.Login, + &i.Name, + &i.Admin, + &i.Moderator, + &i.Alert, + &i.Disabled, + &i.LastLogin, + &i.CreateAt, + &i.UpdateAt, + ) + return i, err } const getUser = `-- name: GetUser :one -SELECT login, name, admin, disabled FROM users WHERE login = ? +SELECT login, name, admin, moderator, alert, disabled, last_login, create_at, update_at FROM users WHERE login = ? ` -type GetUserRow struct { - Login string - Name string - Admin int64 - Disabled int64 -} - -func (q *Queries) GetUser(ctx context.Context, login string) (GetUserRow, error) { +func (q *Queries) GetUser(ctx context.Context, login string) (User, error) { row := q.db.QueryRowContext(ctx, getUser, login) - var i GetUserRow + var i User err := row.Scan( &i.Login, &i.Name, &i.Admin, + &i.Moderator, + &i.Alert, &i.Disabled, + &i.LastLogin, + &i.CreateAt, + &i.UpdateAt, ) return i, err } diff --git a/this/this.go b/this/this.go index 40cfe112d96e24dcbb31b0a90d13bc756b4121c9..a5ea9d4bbb372c0df21f5299656aef88f384180b 100644 --- a/this/this.go +++ b/this/this.go @@ -8,3 +8,88 @@ folder, the current message id and other things. TODO: Eventually `this` will need to handle broadcast messages. */ package this + +import ( + "context" + "errors" + "fmt" + "os" + "path" + + "git.lyda.ie/kevin/bulletin/ask" + "git.lyda.ie/kevin/bulletin/storage" + "git.lyda.ie/kevin/bulletin/users" + "github.com/adrg/xdg" + "github.com/jmoiron/sqlx" +) + +// User is the user for this session. +var User storage.User + +// DBFile is the path for the storage. +var DBFile string + +// Store is the store for this session. +var Store *sqlx.DB + +// Q is the storage.Queries for this session. +var Q *storage.Queries + +// Folder is the current folder. +var Folder string + +// MsgID is the current message id. +var MsgID int64 + +// StartSession starts a session. +func StartSession(login, name string) error { + // Validate the login name. + err := users.ValidLogin(login) + if err != nil { + return err + } + + // Run migrations. + bulldir := path.Join(xdg.DataHome, "BULLETIN") + err = os.MkdirAll(bulldir, 0700) + if err != nil { + return errors.New("bulletin directory problem") + } + DBFile := path.Join(bulldir, "bulletin.db") + storage.Migrate(DBFile) + + Store, err = storage.Open(DBFile) + if err != nil { + return err + } + Q = storage.New(Store.DB) + + ctx := context.TODO() + User, err = Q.GetUser(ctx, login) + + if User.Login != login { + if name == "" { + fmt.Printf("Welcome new user %s\n", login) + name, err = ask.GetLine("please enter your name: ") + if err != nil { + return err + } + } + + User, err = Q.AddUser(ctx, storage.AddUserParams{ + Login: login, + Name: name, + }) + if err != nil { + return err + } + fmt.Println("User successfully created. Enjoy!") + } + if User.Disabled == 1 { + return errors.New("User is disabled") + } + Folder = "GENERAL" + MsgID, err = Q.GetFirstMessageID(ctx, Folder) + + return nil +} diff --git a/users/users.go b/users/users.go index 4b2c99ff5b91276030db439dccb773ce49ee2cef..ecbf8ef13c05340070ec0e8ad23ce9ab49c27c84 100644 --- a/users/users.go +++ b/users/users.go @@ -6,26 +6,11 @@ import ( "fmt" "strings" - "git.lyda.ie/kevin/bulletin/folders" - "github.com/chzyer/readline" _ "modernc.org/sqlite" // Loads sqlite driver. ) -// UserData is the type for holding user data. Things like preferences, -// unread message counts, signatures, etc. -type UserData struct { - Login string - FullName string - Folders *folders.Store - CurrentFolder string - CurrentMessage int -} - -// User is the user for this process. It is loaded by the `Verify` function. -var User *UserData - -// ValidName makes sure that an account name is a valid name. -func ValidName(login string) error { +// ValidLogin makes sure that an account name is a valid name. +func ValidLogin(login string) error { if login == "" { return errors.New("empty account is invalid") } @@ -34,65 +19,3 @@ func ValidName(login string) error { } return nil } - -// Open verifies that an account exists. -func Open(login, name string) error { - err := ValidName(login) - if err != nil { - return err - } - User = &UserData{ - Login: login, - } - - User.Folders, err = folders.Open(login) - if err != nil { - return err - } - user, _ := User.Folders.GetUser(login) - - if user.Login != login { - user.Login = login - user.Admin = 0 - - user.Name = name - if name == "" { - fmt.Printf("Welcome new user %s\n", login) - user.Name, err = GetLine("please enter your name: ") - if err != nil { - return err - } - } - - err = User.Folders.AddUser(*user) - if err != nil { - return err - } - fmt.Println("User successfully created. Enjoy!") - } - if user.Disabled == 1 { - return errors.New("User is disabled") - } - User.FullName = user.Name - User.CurrentFolder = "GENERAL" - // TODO: get the most recent unread message. - // User.CurrentMessage = - - return nil -} - -// Close closes the resources open for the account. -func (u *UserData) Close() { - u.Folders.Close() -} - -// GetLine gets a line. -func GetLine(prompt string) (string, error) { - rl, err := readline.New(prompt) - if err != nil { - return "", err - } - defer rl.Close() - line, err := rl.Readline() - return line, err -}