diff --git a/app.go b/app.go index 9e50d97..349ec1d 100644 --- a/app.go +++ b/app.go @@ -1,184 +1,205 @@ package writefreely import ( "database/sql" "flag" "fmt" _ "github.com/go-sql-driver/mysql" "net/http" "os" "os/signal" + "regexp" "syscall" "github.com/gorilla/mux" "github.com/gorilla/sessions" "github.com/writeas/web-core/log" "github.com/writeas/writefreely/config" "github.com/writeas/writefreely/page" ) const ( staticDir = "static/" serverSoftware = "Write Freely" softwareURL = "https://writefreely.org" softwareVer = "0.1" ) var ( debugging bool ) type app struct { router *mux.Router db *datastore cfg *config.Config keys *keychain sessionStore *sessions.CookieStore } +// handleViewHome shows page at root path. Will be the Pad if logged in and the +// catch-all landing page otherwise. +func handleViewHome(app *app, w http.ResponseWriter, r *http.Request) error { + if app.cfg.App.SingleUser { + // Render blog index + return handleViewCollection(app, w, r) + } + + // Multi-user instance + u := getUserSession(app, r) + if u != nil { + // User is logged in, so show the Pad + return handleViewPad(app, w, r) + } + + // Show landing page + return renderPage(w, "landing.tmpl", pageForReq(app, r)) +} + func pageForReq(app *app, r *http.Request) page.StaticPage { p := page.StaticPage{ AppCfg: app.cfg.App, Path: r.URL.Path, Version: "v" + softwareVer, } // Add user information, if given var u *User accessToken := r.FormValue("t") if accessToken != "" { userID := app.db.GetUserID(accessToken) if userID != -1 { var err error u, err = app.db.GetUserByID(userID) if err == nil { p.Username = u.Username } } } else { u = getUserSession(app, r) if u != nil { p.Username = u.Username } } return p } var shttp = http.NewServeMux() +var fileRegex = regexp.MustCompile("/([^/]*\\.[^/]*)$") func Serve() { debugPtr := flag.Bool("debug", false, "Enables debug logging.") createConfig := flag.Bool("create-config", false, "Creates a basic configuration and exits") doConfig := flag.Bool("config", false, "Run the configuration process") flag.Parse() debugging = *debugPtr if *createConfig { log.Info("Creating configuration...") c := config.New() log.Info("Saving configuration...") err := config.Save(c) if err != nil { log.Error("Unable to save configuration: %v", err) os.Exit(1) } os.Exit(0) } else if *doConfig { err := config.Configure() if err != nil { log.Error("Unable to configure: %v", err) os.Exit(1) } os.Exit(0) } log.Info("Initializing...") log.Info("Loading configuration...") cfg, err := config.Load() if err != nil { log.Error("Unable to load configuration: %v", err) os.Exit(1) } app := &app{ cfg: cfg, } app.cfg.Server.Dev = *debugPtr initTemplates() // Load keys log.Info("Loading encryption keys...") err = initKeys(app) if err != nil { log.Error("\n%s\n", err) } // Initialize modules app.sessionStore = initSession(app) // Check database configuration if app.cfg.Database.User == "" || app.cfg.Database.Password == "" { log.Error("Database user or password not set.") os.Exit(1) } if app.cfg.Database.Host == "" { app.cfg.Database.Host = "localhost" } if app.cfg.Database.Database == "" { app.cfg.Database.Database = "writeas" } log.Info("Connecting to database...") db, err := sql.Open("mysql", fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true", app.cfg.Database.User, app.cfg.Database.Password, app.cfg.Database.Host, app.cfg.Database.Port, app.cfg.Database.Database)) if err != nil { log.Error("\n%s\n", err) os.Exit(1) } app.db = &datastore{db} defer shutdown(app) app.db.SetMaxOpenConns(50) r := mux.NewRouter() handler := NewHandler(app) handler.SetErrorPages(&ErrorPages{ NotFound: pages["404-general.tmpl"], Gone: pages["410.tmpl"], InternalServerError: pages["500.tmpl"], Blank: pages["blank.tmpl"], }) // Handle app routes initRoutes(handler, r, app.cfg, app.db) // Handle static files fs := http.FileServer(http.Dir(staticDir)) shttp.Handle("/", fs) r.PathPrefix("/").Handler(fs) // Handle shutdown c := make(chan os.Signal, 2) signal.Notify(c, os.Interrupt, syscall.SIGTERM) go func() { <-c log.Info("Shutting down...") shutdown(app) log.Info("Done.") os.Exit(0) }() // Start web application server http.Handle("/", r) log.Info("Serving on http://localhost:%d\n", app.cfg.Server.Port) log.Info("---") http.ListenAndServe(fmt.Sprintf(":%d", app.cfg.Server.Port), nil) } func shutdown(app *app) { log.Info("Closing database connection...") app.db.Close() } diff --git a/pad.go b/pad.go new file mode 100644 index 0000000..6a04030 --- /dev/null +++ b/pad.go @@ -0,0 +1,144 @@ +package writefreely + +import ( + "github.com/gorilla/mux" + "github.com/writeas/impart" + "github.com/writeas/web-core/log" + "github.com/writeas/writefreely/page" + "net/http" + "strings" +) + +func handleViewPad(app *app, w http.ResponseWriter, r *http.Request) error { + vars := mux.Vars(r) + action := vars["action"] + slug := vars["slug"] + collAlias := vars["collection"] + appData := &struct { + page.StaticPage + Post *RawPost + User *User + Blogs *[]Collection + + Editing bool // True if we're modifying an existing post + EditCollection *Collection // Collection of the post we're editing, if any + }{ + StaticPage: pageForReq(app, r), + Post: &RawPost{Font: "norm"}, + User: getUserSession(app, r), + } + var err error + if appData.User != nil { + appData.Blogs, err = app.db.GetPublishableCollections(appData.User) + if err != nil { + log.Error("Unable to get user's blogs for Pad: %v", err) + } + } + + padTmpl := "pad" + + if action == "" && slug == "" { + // Not editing any post; simply render the Pad + if err = templates[padTmpl].ExecuteTemplate(w, "pad", appData); err != nil { + log.Error("Unable to execute template: %v", err) + } + + return nil + } + + // Retrieve post information for editing + appData.Editing = true + // Make sure this isn't cached, so user doesn't accidentally lose data + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Header().Set("Expires", "Thu, 04 Oct 1990 20:00:00 GMT") + if slug != "" { + appData.Post = getRawCollectionPost(app, slug, collAlias) + if appData.Post.OwnerID != appData.User.ID { + // TODO: add ErrForbiddenEditPost message to flashes + return impart.HTTPError{http.StatusFound, r.URL.Path[:strings.LastIndex(r.URL.Path, "/edit")]} + } + appData.EditCollection, err = app.db.GetCollectionForPad(collAlias) + if err != nil { + return err + } + } else { + // Editing a floating article + appData.Post = getRawPost(app, action) + appData.Post.Id = action + } + + if appData.Post.Gone { + return ErrPostUnpublished + } else if appData.Post.Found && appData.Post.Content != "" { + // Got the post + } else if appData.Post.Found { + return ErrPostFetchError + } else { + return ErrPostNotFound + } + + if err = templates[padTmpl].ExecuteTemplate(w, "pad", appData); err != nil { + log.Error("Unable to execute template: %v", err) + } + return nil +} + +func handleViewMeta(app *app, w http.ResponseWriter, r *http.Request) error { + vars := mux.Vars(r) + action := vars["action"] + slug := vars["slug"] + collAlias := vars["collection"] + appData := &struct { + page.StaticPage + Post *RawPost + User *User + EditCollection *Collection // Collection of the post we're editing, if any + Flashes []string + NeedsToken bool + }{ + StaticPage: pageForReq(app, r), + Post: &RawPost{Font: "norm"}, + User: getUserSession(app, r), + } + var err error + + if action == "" && slug == "" { + return ErrPostNotFound + } + + // Make sure this isn't cached, so user doesn't accidentally lose data + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Header().Set("Expires", "Thu, 28 Jul 1989 12:00:00 GMT") + if slug != "" { + appData.Post = getRawCollectionPost(app, slug, collAlias) + if appData.Post.OwnerID != appData.User.ID { + // TODO: add ErrForbiddenEditPost message to flashes + return impart.HTTPError{http.StatusFound, r.URL.Path[:strings.LastIndex(r.URL.Path, "/meta")]} + } + appData.EditCollection, err = app.db.GetCollectionForPad(collAlias) + if err != nil { + return err + } + } else { + // Editing a floating article + appData.Post = getRawPost(app, action) + appData.Post.Id = action + } + appData.NeedsToken = appData.User == nil || appData.User.ID != appData.Post.OwnerID + + if appData.Post.Gone { + return ErrPostUnpublished + } else if appData.Post.Found && appData.Post.Content != "" { + // Got the post + } else if appData.Post.Found { + return ErrPostFetchError + } else { + return ErrPostNotFound + } + appData.Flashes, _ = getSessionFlashes(app, w, r, nil) + + if err = templates["edit-meta"].ExecuteTemplate(w, "edit-meta", appData); err != nil { + log.Error("Unable to execute template: %v", err) + } + return nil +} diff --git a/routes.go b/routes.go index ff84ca2..92462b6 100644 --- a/routes.go +++ b/routes.go @@ -1,57 +1,64 @@ package writefreely import ( "github.com/gorilla/mux" "github.com/writeas/go-nodeinfo" "github.com/writeas/web-core/log" "github.com/writeas/writefreely/config" "net/http" "strings" ) func initRoutes(handler *Handler, r *mux.Router, cfg *config.Config, db *datastore) { hostSubroute := cfg.App.Host[strings.Index(cfg.App.Host, "://")+3:] if cfg.App.SingleUser { hostSubroute = "{domain}" } else { if strings.HasPrefix(hostSubroute, "localhost") { hostSubroute = "localhost" } } if cfg.App.SingleUser { log.Info("Adding %s routes (single user)...", hostSubroute) } else { log.Info("Adding %s routes (multi-user)...", hostSubroute) } // Primary app routes write := r.Host(hostSubroute).Subrouter() // Federation endpoints // nodeinfo niCfg := nodeInfoConfig(cfg) ni := nodeinfo.NewService(*niCfg, nodeInfoResolver{cfg, db}) write.HandleFunc(nodeinfo.NodeInfoPath, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfoDiscover))) write.HandleFunc(niCfg.InfoURL, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfo))) // Handle posts write.HandleFunc("/api/posts", handler.All(newPost)).Methods("POST") posts := write.PathPrefix("/api/posts/").Subrouter() posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}", handler.All(fetchPost)).Methods("GET") posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}", handler.All(existingPost)).Methods("POST", "PUT") posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}", handler.All(deletePost)).Methods("DELETE") posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}/{property}", handler.All(fetchPostProperty)).Methods("GET") posts.HandleFunc("/claim", handler.All(addPost)).Methods("POST") posts.HandleFunc("/disperse", handler.All(dispersePost)).Methods("POST") + if cfg.App.SingleUser { + write.HandleFunc("/me/new", handler.Web(handleViewPad, UserLevelOptional)).Methods("GET") + } else { + write.HandleFunc("/new", handler.Web(handleViewPad, UserLevelOptional)).Methods("GET") + } + // All the existing stuff write.HandleFunc("/{action}/edit", handler.Web(handleViewPad, UserLevelOptional)).Methods("GET") write.HandleFunc("/{action}/meta", handler.Web(handleViewMeta, UserLevelOptional)).Methods("GET") // Collections if cfg.App.SingleUser { } else { // Posts write.HandleFunc("/{post}", handler.Web(handleViewPost, UserLevelOptional)) } + write.HandleFunc("/", handler.Web(handleViewHome, UserLevelOptional)) } diff --git a/static/img/ic_blogs@2x.png b/static/img/ic_blogs@2x.png new file mode 100644 index 0000000..b2ecd31 Binary files /dev/null and b/static/img/ic_blogs@2x.png differ diff --git a/static/img/ic_blogs_dark@2x.png b/static/img/ic_blogs_dark@2x.png new file mode 100644 index 0000000..4ebf9d7 Binary files /dev/null and b/static/img/ic_blogs_dark@2x.png differ diff --git a/static/img/ic_brightness@2x.png b/static/img/ic_brightness@2x.png new file mode 100644 index 0000000..9352818 Binary files /dev/null and b/static/img/ic_brightness@2x.png differ diff --git a/static/img/ic_brightness_dark@2x.png b/static/img/ic_brightness_dark@2x.png new file mode 100644 index 0000000..abef5bd Binary files /dev/null and b/static/img/ic_brightness_dark@2x.png differ diff --git a/static/img/ic_down_arrow@2x.png b/static/img/ic_down_arrow@2x.png new file mode 100644 index 0000000..bbb4fb4 Binary files /dev/null and b/static/img/ic_down_arrow@2x.png differ diff --git a/static/img/ic_down_arrow_dark@2x.png b/static/img/ic_down_arrow_dark@2x.png new file mode 100644 index 0000000..3d7f83f Binary files /dev/null and b/static/img/ic_down_arrow_dark@2x.png differ diff --git a/static/img/ic_edit@2x.png b/static/img/ic_edit@2x.png new file mode 100644 index 0000000..5a06bff Binary files /dev/null and b/static/img/ic_edit@2x.png differ diff --git a/static/img/ic_edit_dark@2x.png b/static/img/ic_edit_dark@2x.png new file mode 100644 index 0000000..87f8de1 Binary files /dev/null and b/static/img/ic_edit_dark@2x.png differ diff --git a/static/img/ic_font@2x.png b/static/img/ic_font@2x.png new file mode 100644 index 0000000..612d143 Binary files /dev/null and b/static/img/ic_font@2x.png differ diff --git a/static/img/ic_font_dark@2x.png b/static/img/ic_font_dark@2x.png new file mode 100644 index 0000000..44c3710 Binary files /dev/null and b/static/img/ic_font_dark@2x.png differ diff --git a/static/img/ic_info@2x.png b/static/img/ic_info@2x.png new file mode 100644 index 0000000..c571b2e Binary files /dev/null and b/static/img/ic_info@2x.png differ diff --git a/static/img/ic_info_dark@2x.png b/static/img/ic_info_dark@2x.png new file mode 100644 index 0000000..b706f0d Binary files /dev/null and b/static/img/ic_info_dark@2x.png differ diff --git a/static/img/ic_list@2x.png b/static/img/ic_list@2x.png new file mode 100644 index 0000000..b81d910 Binary files /dev/null and b/static/img/ic_list@2x.png differ diff --git a/static/img/ic_list_dark@2x.png b/static/img/ic_list_dark@2x.png new file mode 100644 index 0000000..73372f4 Binary files /dev/null and b/static/img/ic_list_dark@2x.png differ diff --git a/static/img/ic_send@2x.png b/static/img/ic_send@2x.png new file mode 100644 index 0000000..ef59e77 Binary files /dev/null and b/static/img/ic_send@2x.png differ diff --git a/static/img/ic_send_dark@2x.png b/static/img/ic_send_dark@2x.png new file mode 100644 index 0000000..625bed9 Binary files /dev/null and b/static/img/ic_send_dark@2x.png differ diff --git a/static/js/h.js b/static/js/h.js new file mode 100644 index 0000000..49720be --- /dev/null +++ b/static/js/h.js @@ -0,0 +1,257 @@ +/** + * H.js + * + * Lightweight, extremely bare-bones library for manipulating the DOM and + * saving some typing. + */ + +var Element = function(domElement) { + this.el = domElement; +}; + +/** + * Creates a toggle button that adds / removes the given class name from the + * given element. + * + * @param {Element} $el - The element to modify. + * @param {string} onClass - The class to add to the given element. + * @param {function} onFunc - Additional actions when toggling on. + * @param {function} offFunc - Additional actions when toggling off. + */ +Element.prototype.createToggle = function($el, onClass, onFunc, offFunc) { + this.on('click', function(e) { + if ($el.el.className === '') { + $el.el.className = onClass; + onFunc(new Element(this), e); + } else { + $el.el.className = ''; + offFunc(new Element(this), e); + } + e.preventDefault(); + }, false); +}; +Element.prototype.on = function(event, func) { + events = event.split(' '); + var el = this.el; + if (el == null) { + console.error("Error: element for event is null"); + return; + } + var addEvent = function(e) { + if (el.addEventListener) { + el.addEventListener(e, func, false); + } else if (el.attachEvent) { + el.attachEvent(e, func); + } + }; + if (events.length === 1) { + addEvent(event); + } else { + for(var i=0; i summaryLen) { + summary = summary.substring(0, summaryLen) + "..."; + } + return { + title: content.substring("# ".length, eol), + summary: summary, + }; + } + + var blankLine = content.indexOf("\n\n"); + if (blankLine !== -1 && blankLine <= eol && blankLine <= titleLen) { + // Title is in the format: + // + // Some title + // + // The body starts after that blank line above it. + var summary = content.substring(blankLine).trim(); + if (summary.length > summaryLen) { + summary = summary.substring(0, summaryLen) + "..."; + } + return { + title: content.substring(0, blankLine), + summary: summary, + }; + } + + // TODO: move this to the beginning + var title = content.trim(); + var summary = ""; + if (title.length > titleLen) { + // Content can't fit in the title, so figure out the summary + summary = title; + title = ""; + if (summary.length > summaryLen) { + summary = summary.substring(0, summaryLen) + "..."; + } + } else if (eol > 0) { + summary = title.substring(eol+1); + title = title.substring(0, eol); + } + return { + title: title, + summary: summary + }; + }; + + var post = getPostMeta(content); + post.id = id; + post.token = editToken; + post.created = created ? new Date(created) : new Date(); + post.client = "Pad"; + + return post; + }, + getTitleStrict: function(content) { + var eol = content.indexOf("\n"); + var title = ""; + var newContent = content; + if (content.indexOf("# ") === 0) { + // Title is in the format: + // # Some title + if (eol !== -1) { + // First line should start with # and end with \n + newContent = content.substring(eol).leftTrim(); + title = content.substring("# ".length, eol); + } + } + return { + title: title, + content: newContent + }; + }, +}; + +var He = { + create: function(name) { + return document.createElement(name); + }, + get: function(id) { + return document.getElementById(id); + }, + $: function(selector) { + var els = document.querySelectorAll(selector); + return els; + }, + postJSON: function(url, params, callback) { + var http = new XMLHttpRequest(); + + http.open("POST", url, true); + + // Send the proper header information along with the request + http.setRequestHeader("Content-type", "application/json"); + + http.onreadystatechange = function() { + if (http.readyState == 4) { + callback(http.status, JSON.parse(http.responseText)); + } + } + http.send(JSON.stringify(params)); + }, +}; + +String.prototype.leftTrim = function() { + return this.replace(/^\s+/,""); +}; diff --git a/templates/pad.tmpl b/templates/pad.tmpl new file mode 100644 index 0000000..cbce9bf --- /dev/null +++ b/templates/pad.tmpl @@ -0,0 +1,358 @@ +{{define "pad"}} + + + + {{if .Editing}}Editing {{if .Post.Title}}{{.Post.Title}}{{else}}{{.Post.Id}}{{end}}{{else}}New Post{{end}} — {{.SiteName}} + + + + + + + + +
+ + + +
+
+

{{if .User}}{{else}}write.as{{end}} +

+ + + +
+ +
+ {{if .Editing}}{{end}} + +
+
+
+
+ + + + + +{{end}}