Browse Source

Initial Commit

master
Gisle Aune 7 years ago
commit
27032ed489
  1. 29
      aitestory.json
  2. 25
      controllers/listcontroller.go
  3. 96
      controllers/wikiauth.go
  4. 61
      formparser/parsers.go
  5. 5
      main.go
  6. 146
      model/page.go
  7. 29
      model/page_test.go
  8. 81
      model/tag.go
  9. 89
      model/tag_test.go
  10. 13
      readme.md
  11. 63
      server/config.go
  12. 49
      server/server.go
  13. 61
      tables.sql
  14. 60
      view/templates/fragments/layout.tmpl
  15. 27
      viewmodel/base.go

29
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": ""
}
}

25
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)
}

96
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")
}

61
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
}

5
main.go

@ -0,0 +1,5 @@
package main
func main() {
}

146
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)
}

29
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()
}
})
}

81
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
}

89
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()
}
})
}

13
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

63
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, ","))
}

49
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
}

61
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
);

60
view/templates/fragments/layout.tmpl

@ -0,0 +1,60 @@
{{define "layout"}}
<!DOCTYPE html>
<!-- For preview and template creation only -->
<html>
<head>
<title>{ .ViewTitle } - { .SiteTitle }</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=2.0, user-scalable=no" />
<meta name="theme-color" content="#222222">
<meta name="robots" content="noindex">
<meta name="googlebot" content="noindex">
<link rel="stylesheet" href="/ui/css/base.css" />
<link rel="stylesheet" href="/ui/css/magic.css" />
<link rel="stylesheet" href="/ui/css/theme.css" />
<link rel="stylesheet" media="screen" href="/ui/fonts/SeanSans.css" type="text/css"/>
<script type="text/javascript" src="/ui/js/background.js"></script>
{{ template "head" }}
</head>
<body>
<img id="main-background" src="/ui/img/bg.png" />
<div id="content-wrapper">
<main>
{{ template "content" }}
</main>
<nav class="main-menu">
<h1>{ .ViewTitle }</h1>
<ul>
<li><a><div class="mg-icon">O</div><div class="mg-label">OoC</div></a></li>
<li><a><div class="mg-icon">S</div><div class="mg-label">Stories</div></a></li>
<li><a><div class="mg-icon">N</div><div class="mg-label">News</div></a></li>
<li><a><div class="mg-icon">D</div><div class="mg-label">Documents</div></a></li>
<li><a><div class="mg-icon">I</div><div class="mg-label">Items</div></a></li>
<li><a><div class="mg-icon">i</div><div class="mg-label">Info</div></a></li>
<li><a><div class="mg-icon">B</div><div class="mg-label">Background</div></a></li>
</ul>
<ul>
<li><a><div class="mg-icon">+</div><div class="mg-label">Post</div></a></li>
</ul>
<ul>
<li><a><div class="mg-icon">A</div><div class="mg-label">Login</div></a></li>
</ul>
</nav>
</div>
</body>
</html>
{{end}}

27
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)
}
Loading…
Cancel
Save