diff --git a/admin.go b/admin.go index c0ad436..b1f1533 100644 --- a/admin.go +++ b/admin.go @@ -1,631 +1,633 @@ /* * Copyright © 2018-2020 A Bunch Tell LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package writefreely import ( "database/sql" "fmt" "net/http" "runtime" "strconv" "strings" "time" "github.com/gorilla/mux" "github.com/writeas/impart" "github.com/writeas/web-core/auth" "github.com/writeas/web-core/log" "github.com/writeas/web-core/passgen" "github.com/writeas/writefreely/appstats" "github.com/writeas/writefreely/config" ) var ( appStartTime = time.Now() sysStatus systemStatus ) const adminUsersPerPage = 30 type systemStatus struct { Uptime string NumGoroutine int // General statistics. MemAllocated string // bytes allocated and still in use MemTotal string // bytes allocated (even if freed) MemSys string // bytes obtained from system (sum of XxxSys below) Lookups uint64 // number of pointer lookups MemMallocs uint64 // number of mallocs MemFrees uint64 // number of frees // Main allocation heap statistics. HeapAlloc string // bytes allocated and still in use HeapSys string // bytes obtained from system HeapIdle string // bytes in idle spans HeapInuse string // bytes in non-idle span HeapReleased string // bytes released to the OS HeapObjects uint64 // total number of allocated objects // Low-level fixed-size structure allocator statistics. // Inuse is bytes used now. // Sys is bytes obtained from system. StackInuse string // bootstrap stacks StackSys string MSpanInuse string // mspan structures MSpanSys string MCacheInuse string // mcache structures MCacheSys string BuckHashSys string // profiling bucket hash table GCSys string // GC metadata OtherSys string // other system allocations // Garbage collector statistics. NextGC string // next run in HeapAlloc time (bytes) LastGC string // last run in absolute time (ns) PauseTotalNs string PauseNs string // circular buffer of recent GC pause times, most recent at [(NumGC+255)%256] NumGC uint32 } type inspectedCollection struct { CollectionObj Followers int LastPost string } type instanceContent struct { ID string Type string Title sql.NullString Content string Updated time.Time } type AdminPage struct { UpdateAvailable bool } func NewAdminPage(app *App) *AdminPage { return &AdminPage{ UpdateAvailable: app.updates.AreAvailableNoCheck(), } } func (c instanceContent) UpdatedFriendly() string { /* // TODO: accept a locale in this method and use that for the format var loc monday.Locale = monday.LocaleEnUS return monday.Format(u.Created, monday.DateTimeFormatsByLocale[loc], loc) */ return c.Updated.Format("January 2, 2006, 3:04 PM") } func handleViewAdminDash(app *App, u *User, w http.ResponseWriter, r *http.Request) error { p := struct { *UserPage *AdminPage Message string UsersCount, CollectionsCount, PostsCount int64 }{ UserPage: NewUserPage(app, r, u, "Admin", nil), AdminPage: NewAdminPage(app), Message: r.FormValue("m"), } // Get user stats p.UsersCount = app.db.GetAllUsersCount() var err error p.CollectionsCount, err = app.db.GetTotalCollections() if err != nil { return err } p.PostsCount, err = app.db.GetTotalPosts() if err != nil { return err } showUserPage(w, "admin", p) return nil } func handleViewAdminMonitor(app *App, u *User, w http.ResponseWriter, r *http.Request) error { updateAppStats() p := struct { *UserPage *AdminPage SysStatus systemStatus Config config.AppCfg Message, ConfigMessage string }{ UserPage: NewUserPage(app, r, u, "Admin", nil), AdminPage: NewAdminPage(app), SysStatus: sysStatus, Config: app.cfg.App, Message: r.FormValue("m"), ConfigMessage: r.FormValue("cm"), } showUserPage(w, "monitor", p) return nil } func handleViewAdminSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) error { p := struct { *UserPage *AdminPage Config config.AppCfg Message, ConfigMessage string }{ UserPage: NewUserPage(app, r, u, "Admin", nil), AdminPage: NewAdminPage(app), Config: app.cfg.App, Message: r.FormValue("m"), ConfigMessage: r.FormValue("cm"), } showUserPage(w, "app-settings", p) return nil } func handleViewAdminUsers(app *App, u *User, w http.ResponseWriter, r *http.Request) error { p := struct { *UserPage *AdminPage Config config.AppCfg Message string Users *[]User CurPage int TotalUsers int64 TotalPages []int }{ UserPage: NewUserPage(app, r, u, "Users", nil), AdminPage: NewAdminPage(app), Config: app.cfg.App, Message: r.FormValue("m"), } p.TotalUsers = app.db.GetAllUsersCount() ttlPages := p.TotalUsers / adminUsersPerPage p.TotalPages = []int{} for i := 1; i <= int(ttlPages); i++ { p.TotalPages = append(p.TotalPages, i) } var err error p.CurPage, err = strconv.Atoi(r.FormValue("p")) if err != nil || p.CurPage < 1 { p.CurPage = 1 } else if p.CurPage > int(ttlPages) { p.CurPage = int(ttlPages) } p.Users, err = app.db.GetAllUsers(uint(p.CurPage)) if err != nil { return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get users: %v", err)} } showUserPage(w, "users", p) return nil } func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) username := vars["username"] if username == "" { return impart.HTTPError{http.StatusFound, "/admin/users"} } p := struct { *UserPage *AdminPage Config config.AppCfg Message string User *User Colls []inspectedCollection LastPost string NewPassword string TotalPosts int64 ClearEmail string }{ Config: app.cfg.App, Message: r.FormValue("m"), Colls: []inspectedCollection{}, } var err error p.User, err = app.db.GetUserForAuth(username) if err != nil { if err == ErrUserNotFound { return err } log.Error("Could not get user: %v", err) return impart.HTTPError{http.StatusInternalServerError, err.Error()} } flashes, _ := getSessionFlashes(app, w, r, nil) for _, flash := range flashes { if strings.HasPrefix(flash, "SUCCESS: ") { p.NewPassword = strings.TrimPrefix(flash, "SUCCESS: ") p.ClearEmail = p.User.EmailClear(app.keys) } } p.UserPage = NewUserPage(app, r, u, p.User.Username, nil) p.TotalPosts = app.db.GetUserPostsCount(p.User.ID) lp, err := app.db.GetUserLastPostTime(p.User.ID) if err != nil { return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user's last post time: %v", err)} } if lp != nil { p.LastPost = lp.Format("January 2, 2006, 3:04 PM") } colls, err := app.db.GetCollections(p.User, app.cfg.App.Host) if err != nil { return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user's collections: %v", err)} } for _, c := range *colls { ic := inspectedCollection{ CollectionObj: CollectionObj{Collection: c}, } if app.cfg.App.Federation { folls, err := app.db.GetAPFollowers(&c) if err == nil { // TODO: handle error here (at least log it) ic.Followers = len(*folls) } } app.db.GetPostsCount(&ic.CollectionObj, true) lp, err := app.db.GetCollectionLastPostTime(c.ID) if err != nil { log.Error("Didn't get last post time for collection %d: %v", c.ID, err) } if lp != nil { ic.LastPost = lp.Format("January 2, 2006, 3:04 PM") } p.Colls = append(p.Colls, ic) } showUserPage(w, "view-user", p) return nil } func handleAdminToggleUserStatus(app *App, u *User, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) username := vars["username"] if username == "" { return impart.HTTPError{http.StatusFound, "/admin/users"} } user, err := app.db.GetUserForAuth(username) if err != nil { log.Error("failed to get user: %v", err) return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user from username: %v", err)} } if user.IsSilenced() { err = app.db.SetUserStatus(user.ID, UserActive) } else { err = app.db.SetUserStatus(user.ID, UserSilenced) } if err != nil { log.Error("toggle user silenced: %v", err) return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not toggle user status: %v", err)} } return impart.HTTPError{http.StatusFound, fmt.Sprintf("/admin/user/%s#status", username)} } func handleAdminResetUserPass(app *App, u *User, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) username := vars["username"] if username == "" { return impart.HTTPError{http.StatusFound, "/admin/users"} } // Generate new random password since none supplied pass := passgen.NewWordish() hashedPass, err := auth.HashPass([]byte(pass)) if err != nil { return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not create password hash: %v", err)} } userIDVal := r.FormValue("user") log.Info("ADMIN: Changing user %s password", userIDVal) id, err := strconv.Atoi(userIDVal) if err != nil { return impart.HTTPError{http.StatusBadRequest, fmt.Sprintf("Invalid user ID: %v", err)} } err = app.db.ChangePassphrase(int64(id), true, "", hashedPass) if err != nil { return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not update passphrase: %v", err)} } log.Info("ADMIN: Successfully changed.") addSessionFlash(app, w, r, fmt.Sprintf("SUCCESS: %s", pass), nil) return impart.HTTPError{http.StatusFound, fmt.Sprintf("/admin/user/%s", username)} } func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Request) error { p := struct { *UserPage *AdminPage Config config.AppCfg Message string Pages []*instanceContent }{ UserPage: NewUserPage(app, r, u, "Pages", nil), AdminPage: NewAdminPage(app), Config: app.cfg.App, Message: r.FormValue("m"), } var err error p.Pages, err = app.db.GetInstancePages() if err != nil { return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get pages: %v", err)} } // Add in default pages var hasAbout, hasPrivacy bool for i, c := range p.Pages { if hasAbout && hasPrivacy { break } if c.ID == "about" { hasAbout = true if !c.Title.Valid { p.Pages[i].Title = defaultAboutTitle(app.cfg) } } else if c.ID == "privacy" { hasPrivacy = true if !c.Title.Valid { p.Pages[i].Title = defaultPrivacyTitle() } } } if !hasAbout { p.Pages = append(p.Pages, &instanceContent{ ID: "about", Title: defaultAboutTitle(app.cfg), Content: defaultAboutPage(app.cfg), Updated: defaultPageUpdatedTime, }) } if !hasPrivacy { p.Pages = append(p.Pages, &instanceContent{ ID: "privacy", Title: defaultPrivacyTitle(), Content: defaultPrivacyPolicy(app.cfg), Updated: defaultPageUpdatedTime, }) } showUserPage(w, "pages", p) return nil } func handleViewAdminPage(app *App, u *User, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) slug := vars["slug"] if slug == "" { return impart.HTTPError{http.StatusFound, "/admin/pages"} } p := struct { *UserPage *AdminPage Config config.AppCfg Message string Banner *instanceContent Content *instanceContent }{ Config: app.cfg.App, Message: r.FormValue("m"), } var err error // Get pre-defined pages, or select slug if slug == "about" { p.Content, err = getAboutPage(app) } else if slug == "privacy" { p.Content, err = getPrivacyPage(app) } else if slug == "landing" { p.Banner, err = getLandingBanner(app) if err != nil { return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get banner: %v", err)} } p.Content, err = getLandingBody(app) p.Content.ID = "landing" } else if slug == "reader" { p.Content, err = getReaderSection(app) } else { p.Content, err = app.db.GetDynamicContent(slug) } if err != nil { return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get page: %v", err)} } title := "New page" if p.Content != nil { title = "Edit " + p.Content.ID } else { p.Content = &instanceContent{} } p.UserPage = NewUserPage(app, r, u, title, nil) showUserPage(w, "view-page", p) return nil } func handleAdminUpdateSite(app *App, u *User, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) id := vars["page"] // Validate if id != "about" && id != "privacy" && id != "landing" && id != "reader" { return impart.HTTPError{http.StatusNotFound, "No such page."} } var err error m := "" if id == "landing" { // Handle special landing page err = app.db.UpdateDynamicContent("landing-banner", "", r.FormValue("banner"), "section") if err != nil { m = "?m=" + err.Error() return impart.HTTPError{http.StatusFound, "/admin/page/" + id + m} } err = app.db.UpdateDynamicContent("landing-body", "", r.FormValue("content"), "section") } else if id == "reader" { // Update sections with titles err = app.db.UpdateDynamicContent(id, r.FormValue("title"), r.FormValue("content"), "section") } else { // Update page err = app.db.UpdateDynamicContent(id, r.FormValue("title"), r.FormValue("content"), "page") } if err != nil { m = "?m=" + err.Error() } return impart.HTTPError{http.StatusFound, "/admin/page/" + id + m} } func handleAdminUpdateConfig(apper Apper, u *User, w http.ResponseWriter, r *http.Request) error { apper.App().cfg.App.SiteName = r.FormValue("site_name") apper.App().cfg.App.SiteDesc = r.FormValue("site_desc") apper.App().cfg.App.Landing = r.FormValue("landing") apper.App().cfg.App.OpenRegistration = r.FormValue("open_registration") == "on" mul, err := strconv.Atoi(r.FormValue("min_username_len")) if err == nil { apper.App().cfg.App.MinUsernameLen = mul } mb, err := strconv.Atoi(r.FormValue("max_blogs")) if err == nil { apper.App().cfg.App.MaxBlogs = mb } apper.App().cfg.App.Federation = r.FormValue("federation") == "on" apper.App().cfg.App.PublicStats = r.FormValue("public_stats") == "on" apper.App().cfg.App.Private = r.FormValue("private") == "on" apper.App().cfg.App.LocalTimeline = r.FormValue("local_timeline") == "on" if apper.App().cfg.App.LocalTimeline && apper.App().timeline == nil { log.Info("Initializing local timeline...") initLocalTimeline(apper.App()) } apper.App().cfg.App.UserInvites = r.FormValue("user_invites") if apper.App().cfg.App.UserInvites == "none" { apper.App().cfg.App.UserInvites = "" } apper.App().cfg.App.DefaultVisibility = r.FormValue("default_visibility") m := "?cm=Configuration+saved." err = apper.SaveConfig(apper.App().cfg) if err != nil { m = "?cm=" + err.Error() } return impart.HTTPError{http.StatusFound, "/admin/settings" + m + "#config"} } func updateAppStats() { sysStatus.Uptime = appstats.TimeSincePro(appStartTime) m := new(runtime.MemStats) runtime.ReadMemStats(m) sysStatus.NumGoroutine = runtime.NumGoroutine() sysStatus.MemAllocated = appstats.FileSize(int64(m.Alloc)) sysStatus.MemTotal = appstats.FileSize(int64(m.TotalAlloc)) sysStatus.MemSys = appstats.FileSize(int64(m.Sys)) sysStatus.Lookups = m.Lookups sysStatus.MemMallocs = m.Mallocs sysStatus.MemFrees = m.Frees sysStatus.HeapAlloc = appstats.FileSize(int64(m.HeapAlloc)) sysStatus.HeapSys = appstats.FileSize(int64(m.HeapSys)) sysStatus.HeapIdle = appstats.FileSize(int64(m.HeapIdle)) sysStatus.HeapInuse = appstats.FileSize(int64(m.HeapInuse)) sysStatus.HeapReleased = appstats.FileSize(int64(m.HeapReleased)) sysStatus.HeapObjects = m.HeapObjects sysStatus.StackInuse = appstats.FileSize(int64(m.StackInuse)) sysStatus.StackSys = appstats.FileSize(int64(m.StackSys)) sysStatus.MSpanInuse = appstats.FileSize(int64(m.MSpanInuse)) sysStatus.MSpanSys = appstats.FileSize(int64(m.MSpanSys)) sysStatus.MCacheInuse = appstats.FileSize(int64(m.MCacheInuse)) sysStatus.MCacheSys = appstats.FileSize(int64(m.MCacheSys)) sysStatus.BuckHashSys = appstats.FileSize(int64(m.BuckHashSys)) sysStatus.GCSys = appstats.FileSize(int64(m.GCSys)) sysStatus.OtherSys = appstats.FileSize(int64(m.OtherSys)) sysStatus.NextGC = appstats.FileSize(int64(m.NextGC)) sysStatus.LastGC = fmt.Sprintf("%.1fs", float64(time.Now().UnixNano()-int64(m.LastGC))/1000/1000/1000) sysStatus.PauseTotalNs = fmt.Sprintf("%.1fs", float64(m.PauseTotalNs)/1000/1000/1000) sysStatus.PauseNs = fmt.Sprintf("%.3fs", float64(m.PauseNs[(m.NumGC+255)%256])/1000/1000/1000) sysStatus.NumGC = m.NumGC } func adminResetPassword(app *App, u *User, newPass string) error { hashedPass, err := auth.HashPass([]byte(newPass)) if err != nil { return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not create password hash: %v", err)} } err = app.db.ChangePassphrase(u.ID, true, "", hashedPass) if err != nil { return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not update passphrase: %v", err)} } return nil } func handleViewAdminUpdates(app *App, u *User, w http.ResponseWriter, r *http.Request) error { check := r.URL.Query().Get("check") if check == "now" && app.cfg.App.UpdateChecks { app.updates.CheckNow() } p := struct { *UserPage *AdminPage CurReleaseNotesURL string LastChecked string LastChecked8601 string LatestVersion string LatestReleaseURL string LatestReleaseNotesURL string + CheckFailed bool }{ UserPage: NewUserPage(app, r, u, "Updates", nil), AdminPage: NewAdminPage(app), } p.CurReleaseNotesURL = wfReleaseNotesURL(p.Version) if app.cfg.App.UpdateChecks { p.LastChecked = app.updates.lastCheck.Format("January 2, 2006, 3:04 PM") p.LastChecked8601 = app.updates.lastCheck.Format("2006-01-02T15:04:05Z") p.LatestVersion = app.updates.LatestVersion() p.LatestReleaseURL = app.updates.ReleaseURL() p.LatestReleaseNotesURL = app.updates.ReleaseNotesURL() p.UpdateAvailable = app.updates.AreAvailable() + p.CheckFailed = app.updates.checkError != nil } showUserPage(w, "app-updates", p) return nil } diff --git a/less/core.less b/less/core.less index 05a050c..afccc9c 100644 --- a/less/core.less +++ b/less/core.less @@ -1,1544 +1,1549 @@ @primary: rgb(114, 120, 191); @secondary: rgb(114, 191, 133); @subheaders: #444; @headerTextColor: black; @sansFont: 'Open Sans', 'Segoe UI', Tahoma, Arial, sans-serif; @serifFont: Lora, 'Palatino Linotype', 'Book Antiqua', 'New York', 'DejaVu serif', serif; @monoFont: Hack, consolas, Menlo-Regular, Menlo, Monaco, 'ubuntu mono', monospace, monospace; @dangerCol: #e21d27; @errUrgentCol: #ecc63c; @proSelectedCol: #71D571; @textLinkColor: rgb(0, 0, 238); body { font-family: @serifFont; font-size-adjust: 0.5; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; background-color: white; color: #111; h1, header h2 { a { color: @headerTextColor; .transition-duration(0.2s); &:hover { color: #303030; text-decoration: none; } } } h1, h2, h3 { line-height: 1.2; } &#post article, &#collection article p, &#subpage article p { display: block; unicode-bidi: embed; white-space: pre; } &#post { #wrapper, pre { max-width: 40em; margin: 0 auto; a:hover { text-decoration: underline; } } blockquote { p + p { margin: -2em 0 0.5em; } } article { margin-bottom: 2em !important; h1, h2, h3, h4, h5, h6, p, ul, ol, code { display: inline; margin: 0; } hr + p, ol, ul { display: block; margin-top: -1rem; margin-bottom: -1rem; } ol, ul { margin: 2rem 0 -1rem; ol, ul { margin: 1.25rem 0 -0.5rem; } } li { margin-top: -0.5rem; margin-bottom: -0.5rem; } h2#title { .article-title; } h1 { font-size: 1.5em; } h2 { font-size: 1.17em; } } header { nav { span, a { &.pinned { &.selected { font-weight: bold; } &+.views { margin-left: 2em; } } } } } .owner-visible { display: none; } } &#post, &#collection, &#subpage { code { .article-code; } img, video, audio { max-width: 100%; } audio { width: 100%; white-space: initial; } pre { .code-block; code { background: transparent; border: 0; padding: 0; font-size: 1em; white-space: pre-wrap; /* CSS 3 */ white-space: -moz-pre-wrap; /* Mozilla, since 1999 */ white-space: -pre-wrap; /* Opera 4-6 */ white-space: -o-pre-wrap; /* Opera 7 */ word-wrap: break-word; /* Internet Explorer 5.5+ */ } } blockquote { .article-blockquote; } article { hr { margin-top: 0; margin-bottom: 0; } p.badge { background-color: #aaa; display: inline-block; padding: 0.25em 0.5em; margin: 0; float: right; color: white; .rounded(.25em); } } header { nav { span, a { &.pinned { &+.pinned { margin-left: 1.5em; } } } } } footer { nav { a { margin-top: 0; } } } } &#collection { #welcome, .access { margin: 0 auto; max-width: 35em; h2 { font-weight: normal; margin-bottom: 1em; } p { font-size: 1.2em; line-height: 1.6; } } .access { margin: 8em auto; text-align: center; h2, ul.errors { font-size: 1.2em; margin-bottom: 1.5em !important; } } header { padding: 0 1em; text-align: center; max-width: 50em; margin: 3em auto 4em; .writeas-prefix { a { color: #aaa; } display: block; margin-bottom: 0.5em; } nav { display: block; margin: 1em 0; a:first-child { margin: 0; } } } nav#manage { position: absolute; top: 1em; left: 1.5em; li a.write { font-family: @serifFont; padding-top: 0.2em; padding-bottom: 0.2em; } } pre { line-height: 1.5; } } &#subpage { #wrapper { h1 { font-size: 2.5em; letter-spacing: -2px; padding: 0 2rem 2rem; } } } &#post { pre { font-size: 0.75em; } } &#collection, &#subpage { #wrapper { margin-left: auto; margin-right: auto; article { margin-bottom: 4em; &:hover { .hidden { .opacity(1); } } } h2 { margin-top: 0em; margin-bottom: 0.25em; &+time { display: block; margin-top: 0.25em; margin-bottom: 0.25em; } } time { font-size: 1.1em; &+p { margin-top: 0.25em; } } footer { text-align: left; padding: 0; } } #paging { overflow: visible; padding: 1em 6em 0; } a.read-more { color: #666; } } &#me #official-writing { h2 { font-weight: normal; a { font-size: 0.6em; margin-left: 1em; } a[name] { margin-left: 0; } a:link, a:visited { color: @textLinkColor; } a:hover { text-decoration: underline; } } } &#promo { div.heading { margin: 8em 0; } div.heading, div.attention-form { h1 { font-size: 3.5em; } input { padding-left: 0.75em; padding-right: 0.75em; &[type=email] { max-width: 16em; } &[type=submit] { padding-left: 1.5em; padding-right: 1.5em; } } } h2 { margin-bottom: 0; font-size: 1.8em; font-weight: normal; span.write-as { color: black; } &.soon { color: lighten(@subheaders, 50%); span { &.write-as { color: lighten(#000, 50%); } &.note { color: lighten(#333, 50%); font-variant: small-caps; margin-left: 0.5em; } } } } .half-col a { margin-left: 1em; margin-right: 1em; } } nav#top-nav { display: inline; position: absolute; top: 1.5em; right: 1.5em; font-size: 0.95rem; font-family: @sansFont; text-transform: uppercase; a { color: #777; } a + a { margin-left: 1em; } } footer { nav, ul { a { display: inline-block; margin-top: 0.8em; .transition-duration(0.1s); text-decoration: none; + a { margin-left: 0.8em; } &:link, &:visited { color: #999; } &:hover { color: #666; text-decoration: none; } } } a.home { &:link, &:visited { color: #333; } font-weight: bold; text-decoration: none; &:hover { color: #000; } } ul { list-style: none; text-align: left; padding-left: 0 !important; margin-left: 0 !important; .icons img { height: 16px; width: 16px; fill: #999; } } } } nav#full-nav { margin: 0; .left-side { display: inline-block; a:first-child { margin-left: 0; } } .right-side { float: right; } } nav#full-nav a.simple-btn, .tool button { font-family: @sansFont; border: 1px solid #ccc !important; padding: .5rem 1rem; margin: 0; .rounded(.25em); text-decoration: none; } .post-title { a { &:link { color: #333; } &:visited { color: #444; } } time, time a:link, time a:visited, &+.time { color: #999; } } .hidden { -moz-transition-property: opacity; -webkit-transition-property: opacity; -o-transition-property: opacity; transition-property: opacity; .transition-duration(0.4s); .opacity(0); } a { text-decoration: none; &:hover { text-decoration: underline; } &.subdued { color: #999; &:hover { border-bottom: 1px solid #999; text-decoration: none; } } &.danger { color: @dangerCol; font-size: 0.86em; } &.simple-cta { text-decoration: none; border-bottom: 1px solid #ccc; color: #333; padding-bottom: 2px; &:hover { text-decoration: none; } } &.action-btn { font-family: @sansFont; text-transform: uppercase; .rounded(.25em); background-color: red; color: white; font-weight: bold; padding: 0.5em 0.75em; &:hover { background-color: lighten(#f00, 5%); text-decoration: none; } } &.hashtag:hover { text-decoration: none; span + span { text-decoration: underline; } } &.hashtag { span:first-child { color: #999; margin-right: 0.1em; font-size: 0.86em; text-decoration: none; } } } abbr { border-bottom: 1px dotted #999; text-decoration: none; cursor: help; } body#collection article p, body#subpage article p { .article-p; } pre, body#post article, #post .alert, #subpage .alert, body#collection article, body#subpage article, body#subpage #wrapper h1 { max-width: 40rem; margin: 0 auto; } #collection header .alert, #post .alert, #subpage .alert { margin-bottom: 1em; p { text-align: left; line-height: 1.4; } } textarea, pre, body#post article, body#collection article p { &.norm, &.sans, &.wrap { line-height: 1.4em; white-space: pre-wrap; /* CSS 3 */ white-space: -moz-pre-wrap; /* Mozilla, since 1999 */ white-space: -pre-wrap; /* Opera 4-6 */ white-space: -o-pre-wrap; /* Opera 7 */ word-wrap: break-word; /* Internet Explorer 5.5+ */ } } textarea, pre, body#post article, body#collection article, body#subpage article, span, .font { &.norm { font-family: @serifFont; } &.sans { font-family: @sansFont; } &.mono, &.wrap, &.code { font-family: @monoFont; } &.mono, &.code { max-width: none !important; } } textarea { &.section { border: 1px solid #ccc; padding: 0.65em 0.75em; .rounded(.25em); &.codable { height: 12em; resize: vertical; } } } .ace_editor { height: 12em; border: 1px solid #333; max-width: initial; width: 100%; font-size: 0.86em !important; border: 1px solid #ccc; padding: 0.65em 0.75em; margin: 0; .rounded(.25em); } p { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; &.intro { font-size: 1.25em; text-align: center; } &.upgrade-prompt { font-size: 0.9em; color: #444; } &.text-cta { font-size: 1.2em; text-align: center; margin-bottom: 0.5em; &+ p { text-align: center; font-size: 0.7em; margin-top: 0; color: #666; } } &.error { font-style: italic; color: @errUrgentCol; } &.headeresque { font-size: 2em; } } table.classy { width: 95%; border-collapse: collapse; margin-bottom: 2em; tr + tr { border-top: 1px solid #ccc; } th { text-transform: uppercase; font-weight: normal; font-size: 95%; font-family: @sansFont; padding: 1rem 0.75rem; text-align: center; } td { height: 3.5rem; } p { margin-top: 0 !important; margin-bottom: 0 !important; } &.export { .disabled { color: #999; } .disabled, a { text-transform: lowercase; } } } body#collection article, body#subpage article { padding-top: 0; padding-bottom: 0; .book { h2 { font-size: 1.4em; } a.hidden.action { color: #666; float: right; font-size: 1em; margin-left: 1em; margin-bottom: 1em; } } } body#post article { p.badge { font-size: 0.9em; } } article { h2.post-title a[rel=nofollow]::after { content: '\a0 \2934'; } } table.downloads { width: 100%; td { text-align: center; } img.os { width: 48px; vertical-align: middle; margin-bottom: 6px; } } select.inputform, textarea.inputform { border: 1px solid #999; } input, button, select.inputform, textarea.inputform, a.btn { padding: 0.5em; font-family: @serifFont; font-size: 100%; .rounded(.25em); &[type=submit], &.submit, &.cta { border: 1px solid @primary; background: @primary; color: white; .transition(0.2s); &:hover { background-color: lighten(@primary, 3%); text-decoration: none; } &:disabled { cursor: default; background-color: desaturate(@primary, 100%) !important; border-color: desaturate(@primary, 100%) !important; } } &.error[type=text], textarea.error { -webkit-transition: all 0.30s ease-in-out; -moz-transition: all 0.30s ease-in-out; -ms-transition: all 0.30s ease-in-out; -o-transition: all 0.30s ease-in-out; outline: none; } &.danger { border: 1px solid @dangerCol; background: @dangerCol; color: white; &:hover { background-color: lighten(@dangerCol, 3%); } } &.error[type=text]:focus, textarea.error:focus { box-shadow: 0 0 5px @errUrgentCol; border: 1px solid @errUrgentCol; } } div.flat-select { display: inline-block; position: relative; select { border: 0; background: 0; -webkit-appearance: none; -moz-appearance: none; appearance: none; position: absolute; top: 0; left: 0; right: 0; bottom: 0; width: 100%; height: 100%; opacity: 0; } &.action { &:hover { label { text-decoration: underline; } } label, select { cursor: pointer; } } } input { &.underline{ border: none; border-bottom: 1px solid #ccc; padding: 0 .2em .2em; font-size: 0.9em; color: #333; } &.inline { padding: 0.2rem 0.2rem; margin-left: 0; font-size: 1em; border: 0 !important; border-bottom: 1px solid #999 !important; width: 7em; .rounded(0); } &[type=tel], &[type=text], &[type=email], &[type=password] { border: 1px solid #999; } &.boxy { border: 1px solid #999 !important; } } #beta, .content-container { max-width: 50em; margin: 0 auto 3em; font-size: 1.2em; &.tight { max-width: 30em; } &.snug { max-width: 40em; } &.regular { font-size: 1em; } .app { + .app { margin-top: 1.5em; } h2 { margin-bottom: 0.25em; } p { margin-top: 0.25em; } } h2.intro { font-weight: normal; } p { line-height: 1.4; } li { margin: 0.3em 0; } h2 { &.light { font-weight: normal; } a { .transition-duration(0.2s); -moz-transition-property: color; -webkit-transition-property: color; -o-transition-property: color; transition-property: color; &:link, &:visited, &:hover { color: @subheaders; } &:hover { color: lighten(@subheaders, 10%); text-decoration: none; } } } } .content-container { &#pricing { button { cursor: pointer; color: white; margin-top: 1em; margin-bottom: 1em; padding-left: 1.5em; padding-right: 1.5em; border: 0; background: @primary; .rounded(.25em); .transition(0.2s); &:hover { background-color: lighten(@primary, 5%); } &.unselected { cursor: pointer; } } h2 span { font-weight: normal; } .half { margin: 0 0 1em 0; text-align: center; } } div.features { margin-top: 1.5em; text-align: center; font-size: 0.86em; ul { text-align: left; max-width: 26em; margin-left: auto !important; margin-right: auto !important; li.soon, span.soon { color: lighten(#111, 40%); } } } div.blurbs { >h2 { text-align: center; color: #333; font-weight: normal; } p.price { font-size: 1.2em; margin-bottom: 0; color: #333; margin-top: 0.5em; &+p { margin-top: 0; font-size: 0.8em; } } p.text-cta { font-size: 1em; } } } footer div.blurbs { display: flex; flex-flow: row; flex-wrap: wrap; } div.blurbs { .half, .third, .fourth { font-size: 0.86em; h3 { font-weight: normal; } p, ul { color: #595959; } hr { margin: 1em 0; } } .half { padding: 0 1em 0 0; width: ~"calc(50% - 1em)"; &+.half { padding: 0 0 0 1em; } } .third { padding: 0; width: ~"calc(33% - 1em)"; &+.third { padding: 0 0 0 1em; } } .fourth { flex: 1 1 25%; -webkit-flex: 1 1 25%; h3 { margin-bottom: 0.5em; } ul { margin-top: 0.5em; } } } .contain-me { text-align: left; margin: 0 auto 4em; max-width: 50em; h2 + p, h2 + p + p, p.describe-me { margin-left: 1.5em; margin-right: 1.5em; color: #333; } } footer.contain-me { font-size: 1.1em; } #official-writing, #wrapper { h2, h3, h4 { color: @subheaders; } ul { &.collections { margin-left: 0; li { &.collection { a.title { &:link, &:visited { color: @headerTextColor; } } } a.create { color: #444; } } & + p { margin-top: 2em; margin-left: 1em; } } } } #official-writing, #wrapper { h2 { &.major { color: #222; } &.bugfix { color: #666; } +.android-version { a { color: #999; &:hover { text-decoration: underline; } } } } } li { line-height: 1.4; .item-desc, .prog-lang { font-size: 0.6em; font-family: 'Open Sans', sans-serif; font-weight: bold; margin-left: 0.5em; margin-right: 0.5em; text-transform: uppercase; color: #999; } } .success { color: darken(@proSelectedCol, 20%); } .alert { padding: 1em; margin-bottom: 1.25em; border: 1px solid transparent; .rounded(.25em); &.info { color: #31708f; background-color: #d9edf7; border-color: #bce8f1; } &.success { color: #3c763d; background-color: #dff0d8; border-color: #d6e9c6; } p { margin: 0; &+p { margin-top: 0.5em; } } p.dismiss { font-family: @sansFont; text-align: right; font-size: 0.86em; text-transform: uppercase; } } ul.errors { padding: 0; text-indent: 0; li.urgent { list-style: none; font-style: italic; text-align: center; color: @errUrgentCol; a:link, a:visited { color: purple; } } li.info { list-style: none; font-size: 1.1em; text-align: center; } } body#pad #target a.upgrade-prompt { padding-left: 1em; padding-right: 1em; text-align: center; font-style: italic; color: @primary; } body#pad-sub #posts, .atoms { margin-top: 1.5em; h3 { margin-bottom: 0.25em; &+ h4 { margin-top: 0.25em; margin-bottom: 0.5em; &+ p { margin-top: 0.5em; } } .electron { font-weight: normal; margin-left: 0.5em; } } h3, h4 { a { .transition-duration(0.2s); -moz-transition-property: color; -webkit-transition-property: color; -o-transition-property: color; transition-property: color; } } h4 { font-size: 0.9em; font-weight: normal; } date, .electron { margin-right: 0.5em; } .action { font-size: 1em; } #more-posts p { text-align: center; font-size: 1.1em; } p { font-size: 0.86em; } .error { display: inline-block; font-size: 0.8em; font-style: italic; color: @errUrgentCol; strong { font-style: normal; } } .error + nav { display: inline-block; font-size: 0.8em; margin-left: 1em; a + a { margin-left: 0.75em; } } } h2 { a, time { &+.action { margin-left: 0.5em; } } } .action { font-size: 0.7em; font-weight: normal; font-family: @serifFont; &+ .action { margin-left: 0.5em; } &.new-post { font-weight: bold; } } article.moved { p { font-size: 1.2em; color: #999; } } span.as { .opacity(0.2); font-weight: normal; } span.ras { .opacity(0.6); font-weight: normal; } header { nav { .username { font-size: 2em; font-weight: normal; color: #555; } &#user-nav { margin-left: 0; & > a, .tabs > a { &.selected { cursor: default; font-weight: bold; &:hover { text-decoration: none; } } & + a { margin-left: 2em; } } a { font-size: 1.2em; font-family: @sansFont; span { font-size: 0.7em; color: #999; text-transform: uppercase; margin-left: 0.5em; margin-right: 0.5em; } &.title { font-size: 1.6em; font-family: @serifFont; font-weight: bold; } } nav > ul > li:first-child { &> a { display: inline-block; } img { position: relative; top: -0.5em; right: 0.3em; } } ul ul { font-size: 0.8em; a { padding-top: 0.25em; padding-bottom: 0.25em; } } li { line-height: 1.5; } } &.tabs { margin: 0 0 0 1em; } &+ nav.tabs { margin: 0; } } &.singleuser { margin: 0.5em 0.25em; nav#user-nav { nav > ul > li:first-child { img { top: -0.75em; } } } } .dash-nav { font-weight: bold; } } li#create-collection { display: none; h4 { margin-top: 0px; margin-bottom: 0px; } input[type=submit] { margin-left: 0.5em; } } #collection-options { .option { textarea { font-size: 0.86em; font-family: @monoFont; } .section > p.explain { font-size: 0.8em; } } } .img-placeholder { text-align: center; img { max-width: 100%; } } dl { &.admin-dl-horizontal { dt { font-weight: bolder; width: 360px; } dd { line-height: 1.5; } } } dt { float: left; clear: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } form { dt, dd { padding: 0.5rem 0; } dt { line-height: 1.8; } dd { font-size: 0.86em; line-height: 2; } &.prominent { margin: 1em 0; label { font-weight: bold; } input, select { width: 100%; } select { font-size: 1em; padding: 0.5rem; display: block; border-radius: 0.25rem; margin: 0.5rem 0; } } } div.row { display: flex; align-items: center; > div { flex: 1; } } .check, .blip { font-size: 1.125em; color: #71D571; } +.ex.failure { + font-weight: bold; + color: @dangerCol; +} + @media all and (max-width: 450px) { body#post { header { nav { .xtra-feature { display: none; } } } } } @media all and (min-width: 1280px) { body#promo { div.heading { margin: 10em 0; } } } @media all and (min-width: 1600px) { body#promo { div.heading { margin: 14em 0; } } } @media all and (max-width: 900px) { .half.big { padding: 0 !important; width: 100% !important; } .third { padding: 0 !important; float: none; width: 100% !important; p.introduction { font-size: 0.86em; } } div.blurbs { .fourth { flex: 1 1 15em; -webkit-flex: 1 1 15em; } } .blurbs .third, .blurbs .half { p, ul { text-align: left; } } .half-col, .big { float: none; text-align: center; &+.half-col, &+.big { margin-top: 4em !important; margin-left: 0; } } #beta, .content-container { font-size: 1.15em; } } @media all and (max-width: 600px) { div.row { flex-direction: column; } .half { padding: 0 !important; width: 100% !important; } .third { width: 100% !important; float: none; } body#promo { div.heading { margin: 6em 0; } h2 { font-size: 1.6em; } .half-col a + a { margin-left: 1em; } .half-col a.channel { margin-left: auto !important; margin-right: auto !important; } } ul.add-integrations { li { display: list-item; &+ li { margin-left: 0; } } } } @media all and (max-height: 500px) { body#promo { div.heading { margin: 5em 0; } } } @media all and (max-height: 400px) { body#promo { div.heading { margin: 0em 0; } } } /* Smartphones (portrait and landscape) ----------- */ @media only screen and (min-device-width : 320px) and (max-device-width : 480px) { header { .opacity(1); } } /* Smartphones (portrait) ----------- */ @media only screen and (max-width : 320px) { .content-container#pricing { .half { float: none; width: 100%; } } header { .opacity(1); } } /* iPads (portrait and landscape) ----------- */ @media only screen and (min-device-width : 768px) and (max-device-width : 1024px) { header { .opacity(1); } } @media (pointer: coarse) { body footer nav a:not(.pubd) { padding: 0.8em 1em; margin-left: 0; margin-top: 0; } } @media print { h1 { page-break-before: always; } h1, h2, h3, h4, h5, h6 { page-break-after: avoid; } table, figure { page-break-inside: avoid; } header, footer { display: none; } article#post-body { margin-top: 2em; margin-left: 0; margin-right: 0; } hr { border: 1px solid #ccc; } } .code-block { padding: 0; max-width: 100%; margin: 0; background: #f8f8f8; border: 1px solid #ccc; padding: 0.375em 0.625em; font-size: 0.86em; .rounded(.25em); } pre.code-block { overflow-x: auto; } diff --git a/templates/user/admin/app-updates.tmpl b/templates/user/admin/app-updates.tmpl index 36e7f23..62fd83d 100644 --- a/templates/user/admin/app-updates.tmpl +++ b/templates/user/admin/app-updates.tmpl @@ -1,44 +1,48 @@ {{define "app-updates"}} {{template "header" .}}
{{template "admin-header" .}} {{ if .UpdateChecks }} - {{if not .UpdateAvailable}} + {{if .CheckFailed}} +

× Automated update check failed.

+

Installed version: {{.Version}} (release notes).

+

Learn about latest releases on the WriteFreely blog or forum.

+ {{else if not .UpdateAvailable}}

WriteFreely is up to date.

Installed version: {{.Version}} (release notes).

{{else}}

A new version of WriteFreely is available! Get {{.LatestVersion}}

Read the release notes for details on features, bug fixes, and notes on upgrading from your current version, {{.Version}}.

{{end}}

Last checked: . Check now.

{{ else }}

Automated update checks are disabled.

Installed version: {{.Version}} (release notes).

Learn about latest releases on the WriteFreely blog or forum.

{{ end }} {{template "footer" .}} {{template "body-end" .}} {{end}} diff --git a/updates.go b/updates.go index b1893fd..e41b00b 100644 --- a/updates.go +++ b/updates.go @@ -1,120 +1,122 @@ /* * Copyright © 2019-2020 A Bunch Tell LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package writefreely import ( "io/ioutil" "net/http" "strings" "sync" "time" ) // updatesCacheTime is the default interval between cache updates for new // software versions const defaultUpdatesCacheTime = 12 * time.Hour // updatesCache holds data about current and new releases of the writefreely // software type updatesCache struct { mu sync.Mutex frequency time.Duration lastCheck time.Time latestVersion string currentVersion string + checkError error } // CheckNow asks for the latest released version of writefreely and updates // the cache last checked time. If the version postdates the current 'latest' // the version value is replaced. func (uc *updatesCache) CheckNow() error { uc.mu.Lock() defer uc.mu.Unlock() + uc.lastCheck = time.Now() latestRemote, err := newVersionCheck() if err != nil { + uc.checkError = err return err } - uc.lastCheck = time.Now() if CompareSemver(latestRemote, uc.latestVersion) == 1 { uc.latestVersion = latestRemote } return nil } // AreAvailable updates the cache if the frequency duration has passed // then returns if the latest release is newer than the current running version. func (uc updatesCache) AreAvailable() bool { if time.Since(uc.lastCheck) > uc.frequency { uc.CheckNow() } return CompareSemver(uc.latestVersion, uc.currentVersion) == 1 } // AreAvailableNoCheck returns if the latest release is newer than the current // running version. func (uc updatesCache) AreAvailableNoCheck() bool { return CompareSemver(uc.latestVersion, uc.currentVersion) == 1 } // LatestVersion returns the latest stored version available. func (uc updatesCache) LatestVersion() string { return uc.latestVersion } func (uc updatesCache) ReleaseURL() string { return "https://writefreely.org/releases/" + uc.latestVersion } // ReleaseNotesURL returns the full URL to the blog.writefreely.org release notes // for the latest version as stored in the cache. func (uc updatesCache) ReleaseNotesURL() string { return wfReleaseNotesURL(uc.latestVersion) } func wfReleaseNotesURL(v string) string { ver := strings.TrimPrefix(v, "v") ver = strings.TrimSuffix(ver, ".0") // hack until go 1.12 in build/travis seg := strings.Split(ver, ".") return "https://blog.writefreely.org/version-" + strings.Join(seg, "-") } // newUpdatesCache returns an initialized updates cache func newUpdatesCache(expiry time.Duration) *updatesCache { cache := updatesCache{ frequency: expiry, currentVersion: "v" + softwareVer, } cache.CheckNow() return &cache } // InitUpdates initializes the updates cache, if the config value is set // It uses the defaultUpdatesCacheTime for the cache expiry func (app *App) InitUpdates() { if app.cfg.App.UpdateChecks { app.updates = newUpdatesCache(defaultUpdatesCacheTime) } } func newVersionCheck() (string, error) { res, err := http.Get("https://version.writefreely.org") if err == nil && res.StatusCode == http.StatusOK { defer res.Body.Close() body, err := ioutil.ReadAll(res.Body) if err != nil { return "", err } return string(body), nil } return "", err }