commit 27032ed489135fece667a55ccc51d29b6afb1415 Author: Gisle Aune Date: Sun Oct 1 17:02:27 2017 +0200 Initial Commit diff --git a/aitestory.json b/aitestory.json new file mode 100644 index 0000000..24cb70b --- /dev/null +++ b/aitestory.json @@ -0,0 +1,29 @@ +{ + "view": { + "title": "Aite RP" + }, + + "db": { + "username": "aitestory", + "password": "", + "database": "aitestory" + }, + + "server": { + "host": "0.0.0.0", + "port": 8000, + "ui": "./ui/" + }, + + "wiki": { + "url": "https://wiki.aiterp.net", + "username": "AiteBot@AiteStory", + "password": "" + }, + + "test": { + "enabled": true, + "username": "AiteBot@AiteStory", + "password": "" + } +} diff --git a/controllers/listcontroller.go b/controllers/listcontroller.go new file mode 100644 index 0000000..3e21940 --- /dev/null +++ b/controllers/listcontroller.go @@ -0,0 +1,25 @@ +package controllers + +import ( + "net/http" + + "git.aiterp.net/gisle/wrouter" + "git.aiterp.net/gisle/wrouter/auth" +) + +// ListController serves the front page's list, with and without filters +var ListController = wrouter.Router{} + +func listIndex(path string, w http.ResponseWriter, req *http.Request, user *auth.User) bool { + if req.Method != "GET" { + return false + } + + // tags := strings.Split(path, ",") + + return true +} + +func init() { + ListController.Function("/", listIndex) +} diff --git a/controllers/wikiauth.go b/controllers/wikiauth.go new file mode 100644 index 0000000..1fc0341 --- /dev/null +++ b/controllers/wikiauth.go @@ -0,0 +1,96 @@ +package controllers + +import ( + "errors" + "fmt" + "log" + "strings" + + "git.aiterp.net/AiteRP/aitestory/server" + "git.aiterp.net/gisle/wrouter/auth" + "github.com/sadbox/mediawiki" +) + +// WikiAthenticator talks with the wiki, allowing users +// to log in +type WikiAthenticator struct{} + +func (wikiAuth *WikiAthenticator) ID() string { + return "wiki" +} + +func (wikiAuth *WikiAthenticator) Name() string { + return "Wiki" +} + +func (wikiAuth *WikiAthenticator) Find(username string) *auth.User { + db := server.Main.DB + + rows, err := db.Query("SELECT id,role FROM `user` WHERE id=?", username) + if err != nil { + log.Println("WikiAthenticator.Find:", err) + return nil + } + + defer rows.Close() + + if !rows.Next() { + return nil + } + + user := auth.NewUser(wikiAuth, "", "member", nil) + role := "member" + rows.Scan(&user.ID, &role) + user.Data["role"] = role + + return user +} + +// Login login +func (wikiAuth *WikiAthenticator) Login(username, password string) (*auth.User, error) { + db := server.Main.DB + + // Connect to the wiki + client, err := mediawiki.New(server.Main.Config.Wiki.URL, server.UserAgent) + if err != nil { + log.Fatal(err) + } + + // Log into the wiki with the credementials + err = client.Login(username, password) + if err != nil { + return nil, fmt.Errorf("Login failed %v", err) + } + + // Look up the user + rows, err := db.Query("SELECT id,role FROM `user` WHERE id=?", client.BasicAuthUser) + if err != nil { + return nil, fmt.Errorf("Login failed %v", err) + return nil, nil + } + + // If none was found, just create a new record with the role of member + if !rows.Next() { + _, err = db.Exec("INSERT INTO `user` (id, role) VALUES (?, 'member')", client.BasicAuthUser) + if err != nil { + return nil, fmt.Errorf("Login failed %v", err) + } + + return auth.NewUser(wikiAuth, client.BasicAuthUser, "member", nil), nil + } + + // If the user was found, read it in + userid, role := "", "" + err = rows.Scan(&userid, &role) + if err != nil { + return nil, fmt.Errorf("Login failed %v", err) + } + userid = strings.Split(userid, "@")[0] + + // Make the user + return auth.NewUser(wikiAuth, userid, "member", nil), nil +} + +func (wikiAuth *WikiAthenticator) Register(username, password string, data map[string]string) (*auth.User, error) { + return nil, errors.New("Registration not allowed") +} diff --git a/formparser/parsers.go b/formparser/parsers.go new file mode 100644 index 0000000..9a79f0c --- /dev/null +++ b/formparser/parsers.go @@ -0,0 +1,61 @@ +package formparser + +import ( + "errors" + "fmt" + "time" +) + +// String parses a string, returning an error if it's outside the range of min,max. It still +// sets the value so that it can be used for the view model. It would be bad if the user lost +// a really long page because of the length limit +func String(value string, target *string, min, max int) error { + *target = value + + if len(value) < min || len(value) > max { + return fmt.Errorf("not between %d and %d", min, max) + } + + return nil +} + +// Select sets the targer if the given form value is inside the list. It will skip if optional +// is set and the value is empty +func Select(value string, target *string, allowedValues []string, optional bool) error { + if value == "" { + if !optional { + return errors.New("not a valid option") + } + + return nil + } + + for _, allowedValue := range allowedValues { + if value == allowedValue { + *target = value + return nil + } + } + + return errors.New("not a valid option") +} + +// Date parses a date, returning an error if it's missing (and not optional) and if it +// cannot be parsed according to RFC3339 +func Date(value string, target *time.Time, optional bool) error { + if value == "" { + if optional { + return nil + } + + return errors.New("missing") + } + + date, err := time.Parse(time.RFC3339, value) + if err != nil { + return errors.New("an invalid date") + } + + *target = date + return nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..d3103f9 --- /dev/null +++ b/main.go @@ -0,0 +1,5 @@ +package main + +func main() { + +} \ No newline at end of file diff --git a/model/page.go b/model/page.go new file mode 100644 index 0000000..48b8473 --- /dev/null +++ b/model/page.go @@ -0,0 +1,146 @@ +package model + +import ( + "fmt" + "net/url" + "time" + + "git.aiterp.net/AiteRP/aitestory/formparser" + "git.aiterp.net/gisle/wrouter/generate" + "github.com/microcosm-cc/bluemonday" + "github.com/russross/blackfriday" +) + +// PageCategories are used by the view model and page to enforce +// a limited selection of categories. I may move it to a configuration +var PageCategories = []string{ + "OoC", + "Story", + "Background", + "Document", + "News", + "Item", + "Info", +} + +// PageTypes describes how the source is rendered. For now it's only markdown, +// but who knows what the future holds. +var PageTypes = []string{ + "Markdown", +} + +// PageMinDate is the earliest date possible. Stories from Matriarch Eriana's childhood +// are thus not going to happen. +var PageMinDate, _ = time.Parse(time.RFC3339, "1753-01-01T00:00:00Z") + +// Page is the model describing the individual articles posted +// by users. +type Page struct { + ID string `json:"id"` + Name string `json:"name"` + Author string `json:"author"` + Category string `json:"category"` + FictionalDate time.Time `json:"fictionalDate"` + PublishDate time.Time `json:"publishDate"` + EditDate time.Time `json:"editDate"` + Dated bool `json:"dated"` + Published bool `json:"published"` + Unlisted bool `json:"unlisted"` + Specific bool `json:"specific"` + Indexed bool `json:"indexed"` + BackgroundURL string `json:"backgroundUrl"` + Type string `json:"type"` + Source string `json:"source"` + + cachedOutput string +} + +// Defaults fills in the default details for a page, suited for populating a form +func (page *Page) Defaults() { + page.Category = PageCategories[0] + + page.Dated = true + page.Published = true + page.Unlisted = false + page.Specific = false + page.Indexed = true + + page.BackgroundURL = "" + page.Type = PageTypes[0] + page.Source = "" +} + +// Insert adds the page to the database +func (page *Page) Insert() error { + page.generateID() + + return nil +} + +// Content parses the content of the page +func (page *Page) Content() (string, error) { + if page.cachedOutput != "" { + return page.cachedOutput, nil + } + + if page.Type == "Markdown" { + // TODO: Convert [[Ehanis Tioran]] to [Ehanis Tioran](https://wiki.aiterp.net/index.php?title=Ehanis%20Tioran) + + unsafe := blackfriday.MarkdownCommon([]byte(page.Source)) + page.cachedOutput = string(bluemonday.UGCPolicy().SanitizeBytes(unsafe)) + + return page.cachedOutput, nil + } + + return "", fmt.Errorf("Page type '%s' is not supported", page.Type) +} + +// ParseForm validates the values in a form and sets the page's values whenever possible regardless +// so that it can be pushed to the viewmodel to allow the user to correct their mistakes without fear +// of losing their hard work +func (page *Page) ParseForm(form url.Values) []error { + errors := make([]error, 0, 4) + page.cachedOutput = "" + + err := formparser.Select(form.Get("category"), &page.Category, PageCategories, page.Category != "") + if err != nil { + errors = append(errors, fmt.Errorf("Category: %s", err)) + } + + err = formparser.Date(form.Get("fictionalDate"), &page.FictionalDate, !page.FictionalDate.IsZero()) + if err != nil { + errors = append(errors, fmt.Errorf("Fictonal Date: %s", err)) + } + + page.Dated = form.Get("dated") != "" + page.Published = form.Get("published") != "" + page.Unlisted = form.Get("unlisted") != "" + page.Specific = form.Get("specific") != "" + page.Indexed = form.Get("indexed") != "" + + err = formparser.String(form.Get("backgroundUrl"), &page.BackgroundURL, 0, 255) + if err != nil { + errors = append(errors, fmt.Errorf("Background URL: %s", err)) + } + + err = formparser.Select(form.Get("type"), &page.Type, PageTypes, page.Type != "") + if err != nil { + errors = append(errors, fmt.Errorf("Category: %s", err)) + } + + err = formparser.String(form.Get("source"), &page.Source, 0, 102400) + if err != nil { + errors = append(errors, fmt.Errorf("Content is too long, max: 100 KB (~17,000 words)")) + } + + if len(errors) > 0 { + errors = nil + } + + return errors +} + +// Standardize page ID generation +func (page *Page) generateID() { + page.ID = generate.FriendlyID(16) +} diff --git a/model/page_test.go b/model/page_test.go new file mode 100644 index 0000000..c3fbcf9 --- /dev/null +++ b/model/page_test.go @@ -0,0 +1,29 @@ +package model + +import "testing" +import "time" + +func TestPage(t *testing.T) { + t.Run("BasicConstants", func(t *testing.T) { + if PageMinDate.Format(time.RFC3339) != "1753-01-01T00:00:00Z" { + t.Error("Invalid date:", PageMinDate.Format(time.RFC3339)) + t.Fail() + } + + page := Page{} + page.generateID() + if len(page.ID) != 16 { + t.Errorf("len(page.ID): %d != 16", len(page.ID)) + t.Fail() + } + + id1 := page.ID + page.generateID() + id2 := page.ID + + t.Logf("Page IDs: %s, %s (should not be the same)", id1, id2) + if id1 == id2 { + t.Fail() + } + }) +} diff --git a/model/tag.go b/model/tag.go new file mode 100644 index 0000000..dba4b05 --- /dev/null +++ b/model/tag.go @@ -0,0 +1,81 @@ +package model + +import ( + "errors" + "fmt" + + "git.aiterp.net/AiteRP/aitestory/server" + "git.aiterp.net/gisle/wrouter/generate" +) + +// TagTypes are the allowed values for Tag.Type +var TagTypes = []string{ + "Location", + "Character", + "Event", + "Organization", + "Source", +} + +// Tag describes a tag +type Tag struct { + ID string + Type string + Name string +} + +// Insert adds the tag to the database, giving it a new unique ID +func (tag *Tag) Insert() error { + db := server.Main.DB + + // Validate tag type + validType := false + for _, tagType := range TagTypes { + if tagType == tag.Type { + validType = true + break + } + } + if !validType { + return fmt.Errorf("\"%s\" is not a valid tag type", tag.Type) + } + + // Validate tag name + if len(tag.Name) == 0 { + return errors.New("Tag name is empty") + } + + // Generate an ID if none exists + if tag.ID == "" { + tag.ID = generate.ID() + } + + // Do the thing + _, err := db.Exec("INSERT INTO `tag` (id,type,name,disabled) VALUES (?,?,?,false)", tag.ID, tag.Type, tag.Name) + if err != nil { + return err + } + + return nil +} + +// ListTags finds all the tags, without filter. If it hits +// the tag cache, it will copy it making it safe to modify +func ListTags() ([]Tag, error) { + db := server.Main.DB + + // Read from the database + rows, err := db.Query("SELECT id,type,name FROM `tag` WHERE disabled=false") + if err != nil { + return nil, err + } + defer rows.Close() + results := make([]Tag, 0, 64) + for rows.Next() { + tag := Tag{} + rows.Scan(&tag.ID, &tag.Type, &tag.Name) + results = append(results, tag) + } + + return results, nil +} diff --git a/model/tag_test.go b/model/tag_test.go new file mode 100644 index 0000000..8af7966 --- /dev/null +++ b/model/tag_test.go @@ -0,0 +1,89 @@ +package model + +import ( + "testing" + + "git.aiterp.net/AiteRP/aitestory/server" +) + +func TestTag(t *testing.T) { + if server.Main.Config.DB.Password == "" { + t.Skip("No database password") + return + } + + t.Run("Insert", func(t *testing.T) { + tag := Tag{} + tag.Name = "Te'Eryvi" + tag.Type = "Organization" + + err := tag.Insert() + if err != nil { + t.Log("Failed to insert:", err) + t.Fail() + } + }) + + t.Run("Insert_BadType", func(t *testing.T) { + tag := Tag{} + tag.Name = "Ehanis Tioran" + tag.Type = "Karakter" + + err := tag.Insert() + if err == nil { + t.Log("Oops, it was inserted.") + t.Fail() + return + } + + if err.Error() != `"Karakter" is not a valid tag type` { + t.Log("Wrong error:", err) + t.Fail() + } + }) + + t.Run("Insert_BadName", func(t *testing.T) { + tag := Tag{} + tag.Name = "" + tag.Type = "Character" + + err := tag.Insert() + if err == nil { + t.Log("Oops, it was inserted.") + t.Fail() + return + } + + if err.Error() != `Tag name is empty` { + t.Log("Wrong error:", err) + t.Fail() + } + }) + + t.Run("List", func(t *testing.T) { + tags, err := ListTags() + if err != nil { + t.Log("Failed to get tags:", err) + t.Fail() + } + if len(tags) == 0 { + t.Log("No tags found") + t.Fail() + } + + t.Logf("%d tags found", len(tags)) + + found := false + for _, tag := range tags { + if tag.Name == "Te'Eryvi" { + t.Logf("Tag found: %+v", tag) + found = true + break + } + } + if !found { + t.Log("The tag inserted in last test wasn't found") + t.Fail() + } + }) +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..c136dfe --- /dev/null +++ b/readme.md @@ -0,0 +1,13 @@ +# AiteStory + +See this [wiki.aiterp.net article](https://wiki.aiterp.net/index.php/Meta:Project_AiteStory#Future) for whatever passes for a requirement specification around here. + +## Installing + +I've only made this to be able to run it on Linux, since that's how it is where this project will be deployed. Ubuntu on Windows will work, too. Running on Windows might work, but you're on your own. + +0. Make sure golang is installed and the build tools are +1. `go get git.aiterp.net/AiteRP/aitestory` +2. `git clone git.aiterp.net/AiteRP/aitestory-ui` into another directory +3. Check config.json +4. Start it \ No newline at end of file diff --git a/server/config.go b/server/config.go new file mode 100644 index 0000000..030957b --- /dev/null +++ b/server/config.go @@ -0,0 +1,63 @@ +package server + +import ( + "encoding/json" + "errors" + "os" + "strings" +) + +// Config is the struct created by the server's config.json +type Config struct { + View struct { + Title string `json:"title"` + } `json:"view"` + + DB struct { + Username string `json:"username"` + Password string `json:"password"` + Database string `json:"database"` + } `json:"db"` + + Server struct { + Host string `json:"host"` + Port int `json:"port"` + UI string `json:"ui"` + } `json:"server"` + + Wiki struct { + URL string `json:"url"` + Username string `json:"username"` + Password string `json:"password"` + } `json:"Wiki"` + + Test struct { + Enabled bool `json:"enabled"` + Username string `json:"username"` + Password string `json:"password"` + } `json:"Test"` +} + +// Load loads the config file from the paths, starting with the first path. +// If it fails to load any of them, it will return false. +func (config *Config) Load(paths ...string) error { + for _, path := range paths { + // Open a file, or continue if it doesn't exist + file, err := os.Open(path) + if err != nil { + continue + } + + // JSON parsing errors should not cause it to skip to the next file. + // That's given me enough grief in the past because JSON is a fickle + // format for human-editable config files + err = json.NewDecoder(file).Decode(&config) + if err != nil { + return err + } + + return nil + } + + return errors.New("No configuration files found in either: " + strings.Join(paths, ",")) +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..2082303 --- /dev/null +++ b/server/server.go @@ -0,0 +1,49 @@ +package server + +import ( + "database/sql" + "fmt" + "log" + "net/http" + "os" + "path" + + "git.aiterp.net/gisle/wrouter" + + // The SQL driver is used in Server.Init() + _ "github.com/go-sql-driver/mysql" +) + +// UserAgent is what the server will appear as when connecting to +// an external service +const UserAgent = "AiteStory/0.1.0 (story.aiterp.net, https://git.aiterp.net/AiteRP/aitestory)" + +type server struct { + DB *sql.DB + Listener *http.Server + Router wrouter.Router + Config Config +} + +// Main is the main instance +var Main = server{} + +func init() { + wd, _ := os.Getwd() + err := Main.Config.Load( + "/etc/aiterp/aitestory.json", + path.Join(os.Getenv("HOME"), ".config/aiterp/aitestory.json"), + path.Join(wd, "aitestory.json"), + path.Join(wd, "../aitestory.json"), + ) + if err != nil { + log.Fatalln("server.init:", err) + } + + dbConfig := Main.Config.DB + db, err := sql.Open("mysql", fmt.Sprintf("%s:%s@/%s?charset=utf8", dbConfig.Username, dbConfig.Password, dbConfig.Database)) + if err != nil || db == nil { + log.Fatalln("server.init:", err) + } + Main.DB = db +} diff --git a/tables.sql b/tables.sql new file mode 100644 index 0000000..0d1518e --- /dev/null +++ b/tables.sql @@ -0,0 +1,61 @@ +# DROP TABLE page; DROP TABLE; USER + +CREATE TABLE user ( + `id` CHAR(32) PRIMARY KEY, + `role` CHAR(16), + + INDEX(role) +); + +CREATE TABLE page ( + `id` CHAR(16) NOT NULL PRIMARY KEY, + `name` VARCHAR(255) NOT NULL, + `author` VARCHAR(64), + `category` VARCHAR(16) NOT NULL, + + `fictional_date` DATETIME NOT NULL, + `publish_date` DATETIME NOT NULL, + `edit_date` DATETIME NOT NULL, + + `dated` BOOLEAN NOT NULL, + `published` BOOLEAN NOT NULL, + `unlisted` BOOLEAN NOT NULL, + `specific` BOOLEAN NOT NULL, + `indexed` BOOLEAN NOT NULL, + + `type` VARCHAR(16) NOT NULL, + `source` MEDIUMTEXT NOT NULL, + `cache` MEDIUMTEXT NOT NULL, + + `background_url` VARCHAR(255), + + FOREIGN KEY (`author`) REFERENCES user(`id`) ON DELETE SET NULL +); + +CREATE TABLE tag ( + `id` CHAR(24) NOT NULL PRIMARY KEY, + `type` CHAR(16) NOT NULL, + `disabled` BOOLEAN NOT NULL, + `name` VARCHAR(64) NOT NULL, + + UNIQUE(name), + INDEX(disabled) +); + +CREATE TABLE page_tag ( + `page_id` CHAR(16) NOT NULL, + `tag_id` CHAR(16) NOT NULL, + + PRIMARY KEY (`page_id`, `tag_id`), + FOREIGN KEY (`page_id`) REFERENCES page(`id`) ON DELETE CASCADE, + FOREIGN KEY (`tag_id`) REFERENCES tag(`id`) ON DELETE CASCADE +); + +CREATE TABLE page_unread ( + `page_id` CHAR(16) NOT NULL, + `user_id` CHAR(32) NOT NULL, + + PRIMARY KEY (`page_id`, `user_id`), + FOREIGN KEY (`page_id`) REFERENCES page(`id`) ON DELETE CASCADE, + FOREIGN KEY (`user_id`) REFERENCES user(`id`) ON DELETE CASCADE +); \ No newline at end of file diff --git a/view/templates/fragments/layout.tmpl b/view/templates/fragments/layout.tmpl new file mode 100644 index 0000000..2a2d867 --- /dev/null +++ b/view/templates/fragments/layout.tmpl @@ -0,0 +1,60 @@ +{{define "layout"}} + + + + + + { .ViewTitle } - { .SiteTitle } + + + + + + + + + + + + + + + + {{ template "head" }} + + + + + +
+
+ {{ template "content" }} +
+ + +
+ + +{{end}} \ No newline at end of file diff --git a/viewmodel/base.go b/viewmodel/base.go new file mode 100644 index 0000000..7412a65 --- /dev/null +++ b/viewmodel/base.go @@ -0,0 +1,27 @@ +package viewmodel + +import ( + "fmt" + + "git.aiterp.net/AiteRP/aitestory/server" + "git.aiterp.net/gisle/wrouter/auth" +) + +// Base is the basic information used to render the page +type Base struct { + UserName string + UserRole string + UserLoggedIn bool + ViewTitle string +} + +// InitBase initializes the base of the viewmodel +func (base *Base) InitBase(user *auth.User, viewTitle string) { + if user != nil { + base.UserName = user.ID + base.UserRole = user.Data["role"] + base.UserLoggedIn = false + } + + base.ViewTitle = fmt.Sprintf("%s - %s", viewTitle, server.Main.Config.View.Title) +}