diff --git a/Dockerfile b/Dockerfile index 762a1ee..8bcd342 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,50 +1,49 @@ # Build image # SHA256 of golang:1.21-alpine3.18 linux/amd64 FROM golang@sha256:f475434ea2047a83e9ba02a1da8efc250fa6b2ed0e9e8e4eb8c5322ea6997795 as build LABEL org.opencontainers.image.source="https://github.com/writefreely/writefreely" LABEL org.opencontainers.image.description="WriteFreely is a clean, minimalist publishing platform made for writers. Start a blog, share knowledge within your organization, or build a community around the shared act of writing." -RUN apk -U upgrade \ - && apk add --no-cache nodejs npm make g++ git \ - && npm install -g less less-plugin-clean-css \ - && mkdir -p /go/src/github.com/writefreely/writefreely +RUN apk add --update nodejs npm make g++ git +RUN npm install -g less less-plugin-clean-css +RUN go get -u github.com/go-bindata/go-bindata/... WORKDIR /go/src/github.com/writefreely/writefreely COPY . . RUN cat ossl_legacy.cnf > /etc/ssl/openssl.cnf ENV GO111MODULE=on ENV NODE_OPTIONS=--openssl-legacy-provider RUN make build \ && make ui \ && mkdir /stage \ && cp -R /go/bin \ /go/src/github.com/writefreely/writefreely/templates \ /go/src/github.com/writefreely/writefreely/static \ /go/src/github.com/writefreely/writefreely/pages \ /go/src/github.com/writefreely/writefreely/keys \ /go/src/github.com/writefreely/writefreely/cmd \ /stage # Final image # SHA256 of alpine:3.18.4 linux/amd64 FROM alpine@sha256:48d9183eb12a05c99bcc0bf44a003607b8e941e1d4f41f9ad12bdcc4b5672f86 RUN apk -U upgrade \ && apk add --no-cache openssl ca-certificates COPY --from=build --chown=daemon:daemon /stage /go WORKDIR /go VOLUME /go/keys EXPOSE 8080 USER daemon ENTRYPOINT ["cmd/writefreely/writefreely"] HEALTHCHECK --start-period=5s --interval=15s --timeout=5s \ CMD curl -fSs http://localhost:8080/ || exit 1 \ No newline at end of file diff --git a/Makefile b/Makefile index b230cb7..3eabbf7 100644 --- a/Makefile +++ b/Makefile @@ -1,149 +1,172 @@ GITREV=`git describe | cut -c 2-` LDFLAGS=-ldflags="-s -w -X 'github.com/writefreely/writefreely.softwareVer=$(GITREV)'" GOCMD=go GOINSTALL=$(GOCMD) install $(LDFLAGS) GOBUILD=$(GOCMD) build $(LDFLAGS) GOTEST=$(GOCMD) test $(LDFLAGS) GOGET=$(GOCMD) get BINARY_NAME=writefreely BUILDPATH=build/$(BINARY_NAME) DOCKERCMD=docker IMAGE_NAME=writeas/writefreely TMPBIN=./tmp all : build -ci: deps +ci: ci-assets deps cd cmd/writefreely; $(GOBUILD) -v build: deps cd cmd/writefreely; $(GOBUILD) -v -tags='netgo sqlite' build-no-sqlite: deps-no-sqlite cd cmd/writefreely; $(GOBUILD) -v -tags='netgo' -o $(BINARY_NAME) build-linux: deps @hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ $(GOCMD) install src.techknowlogick.com/xgo@latest; \ fi xgo --targets=linux/amd64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.19.x -out writefreely -pkg ./cmd/writefreely . build-windows: deps @hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ $(GOCMD) install src.techknowlogick.com/xgo@latest; \ fi xgo --targets=windows/amd64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.19.x -out writefreely -pkg ./cmd/writefreely . build-darwin: deps @hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ $(GOCMD) install src.techknowlogick.com/xgo@latest; \ fi xgo --targets=darwin/amd64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.19.x -out writefreely -pkg ./cmd/writefreely . build-arm6: deps @hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ $(GOCMD) install src.techknowlogick.com/xgo@latest; \ fi xgo --targets=linux/arm-6, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.19.x -out writefreely -pkg ./cmd/writefreely . build-arm7: deps @hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ $(GOCMD) install src.techknowlogick.com/xgo@latest; \ fi xgo --targets=linux/arm-7, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.19.x -out writefreely -pkg ./cmd/writefreely . build-arm64: deps @hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ $(GOCMD) install src.techknowlogick.com/xgo@latest; \ fi xgo --targets=linux/arm64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.19.x -out writefreely -pkg ./cmd/writefreely . build-docker : $(DOCKERCMD) build -t $(IMAGE_NAME):latest -t $(IMAGE_NAME):$(GITREV) . test: $(GOTEST) -v ./... run: $(GOINSTALL) -tags='netgo sqlite' ./... $(BINARY_NAME) --debug deps : $(GOGET) -tags='sqlite' -d -v ./... deps-no-sqlite: $(GOGET) -d -v ./... install : build cmd/writefreely/$(BINARY_NAME) --config cmd/writefreely/$(BINARY_NAME) --gen-keys cmd/writefreely/$(BINARY_NAME) --init-db cd less/; $(MAKE) install $(MFLAGS) -release : clean ui +release : clean ui assets mkdir -p $(BUILDPATH) cp -r templates $(BUILDPATH) cp -r pages $(BUILDPATH) cp -r static $(BUILDPATH) rm -r $(BUILDPATH)/static/local scripts/invalidate-css.sh $(BUILDPATH) mkdir $(BUILDPATH)/keys $(MAKE) build-linux mv build/$(BINARY_NAME)-linux-amd64 $(BUILDPATH)/$(BINARY_NAME) tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_amd64.tar.gz -C build $(BINARY_NAME) rm $(BUILDPATH)/$(BINARY_NAME) $(MAKE) build-arm6 mv build/$(BINARY_NAME)-linux-arm-6 $(BUILDPATH)/$(BINARY_NAME) tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_arm6.tar.gz -C build $(BINARY_NAME) rm $(BUILDPATH)/$(BINARY_NAME) $(MAKE) build-arm7 mv build/$(BINARY_NAME)-linux-arm-7 $(BUILDPATH)/$(BINARY_NAME) tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_arm7.tar.gz -C build $(BINARY_NAME) rm $(BUILDPATH)/$(BINARY_NAME) $(MAKE) build-arm64 mv build/$(BINARY_NAME)-linux-arm64 $(BUILDPATH)/$(BINARY_NAME) tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_arm64.tar.gz -C build $(BINARY_NAME) rm $(BUILDPATH)/$(BINARY_NAME) $(MAKE) build-darwin mv build/$(BINARY_NAME)-darwin-10.12-amd64 $(BUILDPATH)/$(BINARY_NAME) tar -cvzf $(BINARY_NAME)_$(GITREV)_macos_amd64.tar.gz -C build $(BINARY_NAME) rm $(BUILDPATH)/$(BINARY_NAME) $(MAKE) build-windows mv build/$(BINARY_NAME)-windows-4.0-amd64.exe $(BUILDPATH)/$(BINARY_NAME).exe cd build; zip -r ../$(BINARY_NAME)_$(GITREV)_windows_amd64.zip ./$(BINARY_NAME) rm $(BUILDPATH)/$(BINARY_NAME).exe $(MAKE) build-docker $(MAKE) release-docker # This assumes you're on linux/amd64 release-linux : clean ui mkdir -p $(BUILDPATH) cp -r templates $(BUILDPATH) cp -r pages $(BUILDPATH) cp -r static $(BUILDPATH) mkdir $(BUILDPATH)/keys $(MAKE) build-no-sqlite mv cmd/writefreely/$(BINARY_NAME) $(BUILDPATH)/$(BINARY_NAME) tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_amd64.tar.gz -C build $(BINARY_NAME) release-docker : $(DOCKERCMD) push $(IMAGE_NAME) ui : force_look cd less/; $(MAKE) $(MFLAGS) cd prose/; $(MAKE) $(MFLAGS) +assets : generate + go-bindata -pkg writefreely -ignore=\\.gitignore -tags="!wflib" schema.sql sqlite.sql + +assets-no-sqlite: generate + go-bindata -pkg writefreely -ignore=\\.gitignore -tags="!wflib" schema.sql + +dev-assets : generate + go-bindata -pkg writefreely -ignore=\\.gitignore -debug -tags="!wflib" schema.sql sqlite.sql + +lib-assets : generate + go-bindata -pkg writefreely -ignore=\\.gitignore -o bindata-lib.go -tags="wflib" schema.sql + +generate : + @hash go-bindata > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ + $(GOGET) -u github.com/jteeuwen/go-bindata/go-bindata; \ + fi + $(TMPBIN): mkdir -p $(TMPBIN) +$(TMPBIN)/go-bindata: deps $(TMPBIN) + $(GOBUILD) -o $(TMPBIN)/go-bindata github.com/jteeuwen/go-bindata/go-bindata + $(TMPBIN)/xgo: deps $(TMPBIN) $(GOBUILD) -o $(TMPBIN)/xgo src.techknowlogick.com/xgo +ci-assets : $(TMPBIN)/go-bindata + $(TMPBIN)/go-bindata -pkg writefreely -ignore=\\.gitignore -tags="!wflib" schema.sql sqlite.sql + clean : -rm -rf build -rm -rf tmp cd less/; $(MAKE) clean $(MFLAGS) force_look : true diff --git a/app.go b/app.go index 43a5c09..b4f18dc 100644 --- a/app.go +++ b/app.go @@ -1,1003 +1,998 @@ /* * Copyright © 2018-2021 Musing Studio 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 ( "crypto/tls" "database/sql" - _ "embed" "fmt" "html/template" "net" "net/http" "net/url" "os" "os/signal" "path/filepath" "regexp" "strings" "syscall" "time" "github.com/gorilla/mux" "github.com/gorilla/schema" "github.com/gorilla/sessions" "github.com/manifoldco/promptui" stripmd "github.com/writeas/go-strip-markdown/v2" "github.com/writeas/impart" "github.com/writeas/web-core/auth" "github.com/writeas/web-core/converter" "github.com/writeas/web-core/log" - "golang.org/x/crypto/acme/autocert" - "github.com/writefreely/writefreely/author" "github.com/writefreely/writefreely/config" "github.com/writefreely/writefreely/key" "github.com/writefreely/writefreely/migrations" "github.com/writefreely/writefreely/page" + "golang.org/x/crypto/acme/autocert" ) const ( staticDir = "static" assumedTitleLen = 80 postsPerPage = 10 serverSoftware = "WriteFreely" softwareURL = "https://writefreely.org" ) var ( debugging bool // Software version can be set from git env using -ldflags softwareVer = "0.15.0" // DEPRECATED VARS isSingleUser bool ) // App holds data and configuration for an individual WriteFreely instance. type App struct { router *mux.Router shttp *http.ServeMux db *datastore cfg *config.Config cfgFile string keys *key.Keychain sessionStore sessions.Store formDecoder *schema.Decoder updates *updatesCache timeline *localTimeline } // DB returns the App's datastore func (app *App) DB() *datastore { return app.db } // Router returns the App's router func (app *App) Router() *mux.Router { return app.router } // Config returns the App's current configuration. func (app *App) Config() *config.Config { return app.cfg } // SetConfig updates the App's Config to the given value. func (app *App) SetConfig(cfg *config.Config) { app.cfg = cfg } // SetKeys updates the App's Keychain to the given value. func (app *App) SetKeys(k *key.Keychain) { app.keys = k } func (app *App) SessionStore() sessions.Store { return app.sessionStore } func (app *App) SetSessionStore(s sessions.Store) { app.sessionStore = s } // Apper is the interface for getting data into and out of a WriteFreely // instance (or "App"). // // App returns the App for the current instance. // // LoadConfig reads an app configuration into the App, returning any error // encountered. // // SaveConfig persists the current App configuration. // // LoadKeys reads the App's encryption keys and loads them into its // key.Keychain. type Apper interface { App() *App LoadConfig() error SaveConfig(*config.Config) error LoadKeys() error ReqLog(r *http.Request, status int, timeSince time.Duration) string } // App returns the App func (app *App) App() *App { return app } // LoadConfig loads and parses a config file. func (app *App) LoadConfig() error { log.Info("Loading %s configuration...", app.cfgFile) cfg, err := config.Load(app.cfgFile) if err != nil { log.Error("Unable to load configuration: %v", err) os.Exit(1) return err } app.cfg = cfg return nil } // SaveConfig saves the given Config to disk -- namely, to the App's cfgFile. func (app *App) SaveConfig(c *config.Config) error { return config.Save(c, app.cfgFile) } // LoadKeys reads all needed keys from disk into the App. In order to use the // configured `Server.KeysParentDir`, you must call initKeyPaths(App) before // this. func (app *App) LoadKeys() error { var err error app.keys = &key.Keychain{} if debugging { log.Info(" %s", emailKeyPath) } executable, err := os.Executable() if err != nil { executable = "writefreely" } else { executable = filepath.Base(executable) } app.keys.EmailKey, err = os.ReadFile(emailKeyPath) if err != nil { return err } if debugging { log.Info(" %s", cookieAuthKeyPath) } app.keys.CookieAuthKey, err = os.ReadFile(cookieAuthKeyPath) if err != nil { return err } if debugging { log.Info(" %s", cookieKeyPath) } app.keys.CookieKey, err = os.ReadFile(cookieKeyPath) if err != nil { return err } if debugging { log.Info(" %s", csrfKeyPath) } app.keys.CSRFKey, err = os.ReadFile(csrfKeyPath) if err != nil { if os.IsNotExist(err) { log.Error(`Missing key: %s. Run this command to generate missing keys: %s keys generate `, csrfKeyPath, executable) } return err } return nil } func (app *App) ReqLog(r *http.Request, status int, timeSince time.Duration) string { return fmt.Sprintf("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, timeSince, r.UserAgent()) } // handleViewHome shows page at root path. It checks the configuration and // authentication state to show the correct page. 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 forceLanding := r.FormValue("landing") == "1" if !forceLanding { // Show correct page based on user auth status and configured landing path u := getUserSession(app, r) if app.cfg.App.Chorus { // This instance is focused on reading, so show Reader on home route if not // private or a private-instance user is logged in. if !app.cfg.App.Private || u != nil { return viewLocalTimeline(app, w, r) } } if u != nil { // User is logged in, so show the Pad return handleViewPad(app, w, r) } if app.cfg.App.Private { return viewLogin(app, w, r) } if land := app.cfg.App.LandingPath(); land != "/" { return impart.HTTPError{http.StatusFound, land} } } return handleViewLanding(app, w, r) } func handleViewLanding(app *App, w http.ResponseWriter, r *http.Request) error { forceLanding := r.FormValue("landing") == "1" p := struct { page.StaticPage *OAuthButtons Flashes []template.HTML Banner template.HTML Content template.HTML ForcedLanding bool }{ StaticPage: pageForReq(app, r), OAuthButtons: NewOAuthButtons(app.Config()), ForcedLanding: forceLanding, } banner, err := getLandingBanner(app) if err != nil { log.Error("unable to get landing banner: %v", err) return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get banner: %v", err)} } p.Banner = template.HTML(applyMarkdown([]byte(banner.Content), "", app.cfg)) content, err := getLandingBody(app) if err != nil { log.Error("unable to get landing content: %v", err) return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get content: %v", err)} } p.Content = template.HTML(applyMarkdown([]byte(content.Content), "", app.cfg)) // Get error messages session, err := app.sessionStore.Get(r, cookieName) if err != nil { // Ignore this log.Error("Unable to get session in handleViewHome; ignoring: %v", err) } flashes, _ := getSessionFlashes(app, w, r, session) for _, flash := range flashes { p.Flashes = append(p.Flashes, template.HTML(flash)) } // Show landing page return renderPage(w, "landing.tmpl", p) } func handleTemplatedPage(app *App, w http.ResponseWriter, r *http.Request, t *template.Template) error { p := struct { page.StaticPage ContentTitle string Content template.HTML PlainContent string Updated string AboutStats *InstanceStats }{ StaticPage: pageForReq(app, r), } if r.URL.Path == "/about" || r.URL.Path == "/contact" || r.URL.Path == "/privacy" { var c *instanceContent var err error if r.URL.Path == "/about" { c, err = getAboutPage(app) // Fetch stats p.AboutStats = &InstanceStats{} p.AboutStats.NumPosts, _ = app.db.GetTotalPosts() p.AboutStats.NumBlogs, _ = app.db.GetTotalCollections() } else if r.URL.Path == "/contact" { c, err = getContactPage(app) if c.Updated.IsZero() { // Page was never set up, so return 404 return ErrPostNotFound } } else { c, err = getPrivacyPage(app) } if err != nil { return err } p.ContentTitle = c.Title.String p.Content = template.HTML(applyMarkdown([]byte(c.Content), "", app.cfg)) p.PlainContent = shortPostDescription(stripmd.Strip(c.Content)) if !c.Updated.IsZero() { p.Updated = c.Updated.Format("January 2, 2006") } } // Serve templated page err := t.ExecuteTemplate(w, "base", p) if err != nil { log.Error("Unable to render page: %v", err) } return nil } func pageForReq(app *App, r *http.Request) page.StaticPage { p := page.StaticPage{ AppCfg: app.cfg.App, Path: r.URL.Path, Version: "v" + softwareVer, } // Use custom style, if file exists if _, err := os.Stat(filepath.Join(app.cfg.Server.StaticParentDir, staticDir, "local", "custom.css")); err == nil { p.CustomCSS = true } // 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 p.IsAdmin = u != nil && u.IsAdmin() p.CanInvite = canUserInvite(app.cfg, p.IsAdmin) } } p.CanViewReader = !app.cfg.App.Private || u != nil return p } var fileRegex = regexp.MustCompile("/([^/]*\\.[^/]*)$") // Initialize loads the app configuration and initializes templates, keys, // session, route handlers, and the database connection. func Initialize(apper Apper, debug bool) (*App, error) { debugging = debug apper.LoadConfig() // Load templates err := InitTemplates(apper.App().Config()) if err != nil { return nil, fmt.Errorf("load templates: %s", err) } // Load keys and set up session initKeyPaths(apper.App()) // TODO: find a better way to do this, since it's unneeded in all Apper implementations err = InitKeys(apper) if err != nil { return nil, fmt.Errorf("init keys: %s", err) } apper.App().InitUpdates() apper.App().InitSession() apper.App().InitDecoder() err = ConnectToDatabase(apper.App()) if err != nil { return nil, fmt.Errorf("connect to DB: %s", err) } initActivityPub(apper.App()) if apper.App().cfg.Email.Domain != "" || apper.App().cfg.Email.MailgunPrivate != "" { if apper.App().cfg.Email.Domain == "" { log.Error("[FAILED] Starting publish jobs queue: no [letters]domain config value set.") } else if apper.App().cfg.Email.MailgunPrivate == "" { log.Error("[FAILED] Starting publish jobs queue: no [letters]mailgun_private config value set.") } else { log.Info("Starting publish jobs queue...") go startPublishJobsQueue(apper.App()) } } // Handle local timeline, if enabled if apper.App().cfg.App.LocalTimeline { log.Info("Initializing local timeline...") initLocalTimeline(apper.App()) } return apper.App(), nil } func Serve(app *App, r *mux.Router) { log.Info("Going to serve...") isSingleUser = app.cfg.App.SingleUser app.cfg.Server.Dev = debugging // 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 gopher server if app.cfg.Server.GopherPort > 0 && !app.cfg.App.Private { go initGopher(app) } // Start web application server var bindAddress = app.cfg.Server.Bind if bindAddress == "" { bindAddress = "localhost" } var err error if app.cfg.IsSecureStandalone() { if app.cfg.Server.Autocert { m := &autocert.Manager{ Prompt: autocert.AcceptTOS, Cache: autocert.DirCache(app.cfg.Server.TLSCertPath), } host, err := url.Parse(app.cfg.App.Host) if err != nil { log.Error("[WARNING] Unable to parse configured host! %s", err) log.Error(`[WARNING] ALL hosts are allowed, which can open you to an attack where clients connect to a server by IP address and pretend to be asking for an incorrect host name, and cause you to reach the CA's rate limit for certificate requests. We recommend supplying a valid host name.`) log.Info("Using autocert on ANY host") } else { log.Info("Using autocert on host %s", host.Host) m.HostPolicy = autocert.HostWhitelist(host.Host) } s := &http.Server{ Addr: ":https", Handler: r, TLSConfig: &tls.Config{ GetCertificate: m.GetCertificate, }, } s.SetKeepAlivesEnabled(false) go func() { log.Info("Serving redirects on http://%s:80", bindAddress) err = http.ListenAndServe(":80", m.HTTPHandler(nil)) log.Error("Unable to start redirect server: %v", err) }() log.Info("Serving on https://%s:443", bindAddress) log.Info("---") err = s.ListenAndServeTLS("", "") } else { go func() { log.Info("Serving redirects on http://%s:80", bindAddress) err = http.ListenAndServe(fmt.Sprintf("%s:80", bindAddress), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, app.cfg.App.Host, http.StatusMovedPermanently) })) log.Error("Unable to start redirect server: %v", err) }() log.Info("Serving on https://%s:443", bindAddress) log.Info("Using manual certificates") log.Info("---") err = http.ListenAndServeTLS(fmt.Sprintf("%s:443", bindAddress), app.cfg.Server.TLSCertPath, app.cfg.Server.TLSKeyPath, r) } } else { network := "tcp" protocol := "http" if strings.HasPrefix(bindAddress, "/") { network = "unix" protocol = "http+unix" // old sockets will remain after server closes; // we need to delete them in order to open new ones err = os.Remove(bindAddress) if err != nil && !os.IsNotExist(err) { log.Error("%s already exists but could not be removed: %v", bindAddress, err) os.Exit(1) } } else { bindAddress = fmt.Sprintf("%s:%d", bindAddress, app.cfg.Server.Port) } log.Info("Serving on %s://%s", protocol, bindAddress) log.Info("---") listener, err := net.Listen(network, bindAddress) if err != nil { log.Error("Could not bind to address: %v", err) os.Exit(1) } if network == "unix" { err = os.Chmod(bindAddress, 0o666) if err != nil { log.Error("Could not update socket permissions: %v", err) os.Exit(1) } } defer listener.Close() err = http.Serve(listener, r) } if err != nil { log.Error("Unable to start: %v", err) os.Exit(1) } } func (app *App) InitDecoder() { // TODO: do this at the package level, instead of the App level // Initialize modules app.formDecoder = schema.NewDecoder() app.formDecoder.RegisterConverter(converter.NullJSONString{}, converter.ConvertJSONNullString) app.formDecoder.RegisterConverter(converter.NullJSONBool{}, converter.ConvertJSONNullBool) app.formDecoder.RegisterConverter(sql.NullString{}, converter.ConvertSQLNullString) app.formDecoder.RegisterConverter(sql.NullBool{}, converter.ConvertSQLNullBool) app.formDecoder.RegisterConverter(sql.NullInt64{}, converter.ConvertSQLNullInt64) app.formDecoder.RegisterConverter(sql.NullFloat64{}, converter.ConvertSQLNullFloat64) } // ConnectToDatabase validates and connects to the configured database, then // tests the connection. func ConnectToDatabase(app *App) error { // Check database configuration if app.cfg.Database.Type == driverMySQL && app.cfg.Database.User == "" { return fmt.Errorf("Database user not set.") } if app.cfg.Database.Host == "" { app.cfg.Database.Host = "localhost" } if app.cfg.Database.Database == "" { app.cfg.Database.Database = "writefreely" } // TODO: check err connectToDatabase(app) // Test database connection err := app.db.Ping() if err != nil { return fmt.Errorf("Database ping failed: %s", err) } return nil } // FormatVersion constructs the version string for the application func FormatVersion() string { return serverSoftware + " " + softwareVer } // OutputVersion prints out the version of the application. func OutputVersion() { fmt.Println(FormatVersion()) } // NewApp creates a new app instance. func NewApp(cfgFile string) *App { return &App{ cfgFile: cfgFile, } } // CreateConfig creates a default configuration and saves it to the app's cfgFile. func CreateConfig(app *App) error { log.Info("Creating configuration...") c := config.New() log.Info("Saving configuration %s...", app.cfgFile) err := config.Save(c, app.cfgFile) if err != nil { return fmt.Errorf("Unable to save configuration: %v", err) } return nil } // DoConfig runs the interactive configuration process. func DoConfig(app *App, configSections string) { if configSections == "" { configSections = "server db app" } // let's check there aren't any garbage in the list configSectionsArray := strings.Split(configSections, " ") for _, element := range configSectionsArray { if element != "server" && element != "db" && element != "app" { log.Error("Invalid argument to --sections. Valid arguments are only \"server\", \"db\" and \"app\"") os.Exit(1) } } d, err := config.Configure(app.cfgFile, configSections) if err != nil { log.Error("Unable to configure: %v", err) os.Exit(1) } app.cfg = d.Config connectToDatabase(app) defer shutdown(app) if !app.db.DatabaseInitialized() { err = adminInitDatabase(app) if err != nil { log.Error(err.Error()) os.Exit(1) } } else { log.Info("Database already initialized.") } if d.User != nil { u := &User{ Username: d.User.Username, HashedPass: d.User.HashedPass, Created: time.Now().Truncate(time.Second).UTC(), } // Create blog log.Info("Creating user %s...\n", u.Username) err = app.db.CreateUser(app.cfg, u, app.cfg.App.SiteName, "") if err != nil { log.Error("Unable to create user: %s", err) os.Exit(1) } log.Info("Done!") } os.Exit(0) } // GenerateKeyFiles creates app encryption keys and saves them into the configured KeysParentDir. func GenerateKeyFiles(app *App) error { // Read keys path from config app.LoadConfig() // Create keys dir if it doesn't exist yet fullKeysDir := filepath.Join(app.cfg.Server.KeysParentDir, keysDir) if _, err := os.Stat(fullKeysDir); os.IsNotExist(err) { err = os.Mkdir(fullKeysDir, 0700) if err != nil { return err } } // Generate keys initKeyPaths(app) // TODO: use something like https://github.com/hashicorp/go-multierror to return errors var keyErrs error err := generateKey(emailKeyPath) if err != nil { keyErrs = err } err = generateKey(cookieAuthKeyPath) if err != nil { keyErrs = err } err = generateKey(cookieKeyPath) if err != nil { keyErrs = err } err = generateKey(csrfKeyPath) if err != nil { keyErrs = err } return keyErrs } // CreateSchema creates all database tables needed for the application. func CreateSchema(apper Apper) error { apper.LoadConfig() connectToDatabase(apper.App()) defer shutdown(apper.App()) err := adminInitDatabase(apper.App()) if err != nil { return err } return nil } // Migrate runs all necessary database migrations. func Migrate(apper Apper) error { apper.LoadConfig() connectToDatabase(apper.App()) defer shutdown(apper.App()) err := migrations.Migrate(migrations.NewDatastore(apper.App().db.DB, apper.App().db.driverName)) if err != nil { return fmt.Errorf("migrate: %s", err) } return nil } // ResetPassword runs the interactive password reset process. func ResetPassword(apper Apper, username string) error { // Connect to the database apper.LoadConfig() connectToDatabase(apper.App()) defer shutdown(apper.App()) // Fetch user u, err := apper.App().db.GetUserForAuth(username) if err != nil { log.Error("Get user: %s", err) os.Exit(1) } // Prompt for new password prompt := promptui.Prompt{ Templates: &promptui.PromptTemplates{ Success: "{{ . | bold | faint }}: ", }, Label: "New password", Mask: '*', } newPass, err := prompt.Run() if err != nil { log.Error("%s", err) os.Exit(1) } // Do the update log.Info("Updating...") err = adminResetPassword(apper.App(), u, newPass) if err != nil { log.Error("%s", err) os.Exit(1) } log.Info("Success.") return nil } // DoDeleteAccount runs the confirmation and account delete process. func DoDeleteAccount(apper Apper, username string) error { // Connect to the database apper.LoadConfig() connectToDatabase(apper.App()) defer shutdown(apper.App()) // check user exists u, err := apper.App().db.GetUserForAuth(username) if err != nil { log.Error("%s", err) os.Exit(1) } userID := u.ID // do not delete the admin account // TODO: check for other admins and skip? if u.IsAdmin() { log.Error("Can not delete admin account") os.Exit(1) } // confirm deletion, w/ w/out posts prompt := promptui.Prompt{ Templates: &promptui.PromptTemplates{ Success: "{{ . | bold | faint }}: ", }, Label: fmt.Sprintf("Really delete user : %s", username), IsConfirm: true, } _, err = prompt.Run() if err != nil { log.Info("Aborted...") os.Exit(0) } log.Info("Deleting...") err = apper.App().db.DeleteAccount(userID) if err != nil { log.Error("%s", err) os.Exit(1) } log.Info("Success.") return nil } func connectToDatabase(app *App) { log.Info("Connecting to %s database...", app.cfg.Database.Type) var db *sql.DB var err error if app.cfg.Database.Type == driverMySQL { db, err = sql.Open(app.cfg.Database.Type, fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=%s&tls=%t", app.cfg.Database.User, app.cfg.Database.Password, app.cfg.Database.Host, app.cfg.Database.Port, app.cfg.Database.Database, url.QueryEscape(time.Local.String()), app.cfg.Database.TLS)) db.SetMaxOpenConns(50) } else if app.cfg.Database.Type == driverSQLite { if !SQLiteEnabled { log.Error("Invalid database type '%s'. Binary wasn't compiled with SQLite3 support.", app.cfg.Database.Type) os.Exit(1) } if app.cfg.Database.FileName == "" { log.Error("SQLite database filename value in config.ini is empty.") os.Exit(1) } db, err = sql.Open("sqlite3_with_regex", app.cfg.Database.FileName+"?parseTime=true&cached=shared") db.SetMaxOpenConns(2) } else { log.Error("Invalid database type '%s'. Only 'mysql' and 'sqlite3' are supported right now.", app.cfg.Database.Type) os.Exit(1) } if err != nil { log.Error("%s", err) os.Exit(1) } app.db = &datastore{db, app.cfg.Database.Type} } func shutdown(app *App) { log.Info("Closing database connection...") app.db.Close() if strings.HasPrefix(app.cfg.Server.Bind, "/") { // Clean up socket log.Info("Removing socket file...") err := os.Remove(app.cfg.Server.Bind) if err != nil { log.Error("Unable to remove socket: %s", err) os.Exit(1) } log.Info("Success.") } } // CreateUser creates a new admin or normal user from the given credentials. func CreateUser(apper Apper, username, password string, isAdmin bool) error { // Create an admin user with --create-admin apper.LoadConfig() connectToDatabase(apper.App()) defer shutdown(apper.App()) // Ensure an admin / first user doesn't already exist firstUser, _ := apper.App().db.GetUserByID(1) if isAdmin { // Abort if trying to create admin user, but one already exists if firstUser != nil { return fmt.Errorf("Admin user already exists (%s). Create a regular user with: writefreely --create-user", firstUser.Username) } } else { // Abort if trying to create regular user, but no admin exists yet if firstUser == nil { return fmt.Errorf("No admin user exists yet. Create an admin first with: writefreely --create-admin") } } // Create the user // Normalize and validate username desiredUsername := username username = getSlug(username, "") usernameDesc := username if username != desiredUsername { usernameDesc += " (originally: " + desiredUsername + ")" } if !author.IsValidUsername(apper.App().cfg, username) { return fmt.Errorf("Username %s is invalid, reserved, or shorter than configured minimum length (%d characters).", usernameDesc, apper.App().cfg.App.MinUsernameLen) } // Hash the password hashedPass, err := auth.HashPass([]byte(password)) if err != nil { return fmt.Errorf("Unable to hash password: %v", err) } u := &User{ Username: username, HashedPass: hashedPass, Created: time.Now().Truncate(time.Second).UTC(), } userType := "user" if isAdmin { userType = "admin" } log.Info("Creating %s %s...", userType, usernameDesc) err = apper.App().db.CreateUser(apper.App().Config(), u, desiredUsername, "") if err != nil { return fmt.Errorf("Unable to create user: %s", err) } log.Info("Done!") return nil } -//go:embed schema.sql -var schemaSql string - -//go:embed sqlite.sql -var sqliteSql string - func adminInitDatabase(app *App) error { - var schema string + schemaFileName := "schema.sql" if app.cfg.Database.Type == driverSQLite { - schema = sqliteSql - } else { - schema = schemaSql + schemaFileName = "sqlite.sql" + } + + schema, err := Asset(schemaFileName) + if err != nil { + return fmt.Errorf("Unable to load schema file: %v", err) } tblReg := regexp.MustCompile("CREATE TABLE (IF NOT EXISTS )?`([a-z_]+)`") queries := strings.Split(string(schema), ";\n") for _, q := range queries { if strings.TrimSpace(q) == "" { continue } parts := tblReg.FindStringSubmatch(q) if len(parts) >= 3 { log.Info("Creating table %s...", parts[2]) } else { log.Info("Creating table ??? (Weird query) No match in: %v", parts) } - _, err := app.db.Exec(q) + _, err = app.db.Exec(q) if err != nil { log.Error("%s", err) } else { log.Info("Created.") } } // Set up migrations table log.Info("Initializing appmigrations table...") - err := migrations.SetInitialMigrations(migrations.NewDatastore(app.db.DB, app.db.driverName)) + err = migrations.SetInitialMigrations(migrations.NewDatastore(app.db.DB, app.db.driverName)) if err != nil { return fmt.Errorf("Unable to set initial migrations: %v", err) } log.Info("Running migrations...") err = migrations.Migrate(migrations.NewDatastore(app.db.DB, app.db.driverName)) if err != nil { return fmt.Errorf("migrate: %s", err) } log.Info("Done.") return nil } // ServerUserAgent returns a User-Agent string to use in external requests. The // hostName parameter may be left empty. func ServerUserAgent(hostName string) string { hostUAStr := "" if hostName != "" { hostUAStr = "; +" + hostName } return "Go (" + serverSoftware + "/" + softwareVer + hostUAStr + ")" } diff --git a/bindata-lib.go b/bindata-lib.go new file mode 100644 index 0000000..9f4a3fd --- /dev/null +++ b/bindata-lib.go @@ -0,0 +1,106 @@ +// +build wflib + +package writefreely + +import ( + "bytes" + "compress/gzip" + "fmt" + "io" + "strings" +) + +func bindata_read(data []byte, name string) ([]byte, error) { + gz, err := gzip.NewReader(bytes.NewBuffer(data)) + if err != nil { + return nil, fmt.Errorf("Read %q: %v", name, err) + } + + var buf bytes.Buffer + _, err = io.Copy(&buf, gz) + gz.Close() + + if err != nil { + return nil, fmt.Errorf("Read %q: %v", name, err) + } + + return buf.Bytes(), nil +} + +var _schema_sql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\xd4\x59\x5f\x6f\xa3\x38\x10\x7f\xef\xa7\xf0\xdb\xa6\x52\x23\x6d\x7a\xdd\xaa\xba\xd3\x3e\x64\x53\x76\x2f\xba\x94\xee\x25\x44\xba\x7d\x02\x03\x93\xd4\xaa\xb1\x91\x6d\x92\xe6\xdb\x9f\x8c\x49\x08\x86\x24\xd0\xdb\x3b\x71\x7d\x2a\xcc\x6f\x8c\xfd\x9b\x3f\x9e\x99\x0c\x87\x57\xc3\x21\x7a\xc4\x0a\x87\x58\xc2\xaf\x28\xd8\x0a\xa2\x60\x25\x00\xe8\x2e\xb8\x1a\x0e\xaf\xb4\x78\xf8\xce\x3f\xad\xac\xf5\x3d\x1c\x52\x40\x52\x89\x2c\x52\x99\x00\xb4\xe2\x02\xa9\xfc\x5d\x80\xa3\x08\xa4\x54\xfc\x15\x98\x34\xdf\x9b\xcc\x9d\xb1\xe7\x20\x6f\xfc\x65\xe6\xa0\xe9\x57\xe4\x3e\x7b\xc8\xf9\x6b\xba\xf0\x16\x16\x1a\x0d\xae\x10\x0a\xf2\x87\x00\x85\x84\x61\xb1\x1b\x8c\xee\xaf\x73\x05\x77\x39\x9b\xdd\x68\x71\x26\x41\xf8\x24\x0e\x10\x61\x6a\x60\x0b\x65\x16\xf3\x00\x29\xc2\x76\x5a\x3a\x2a\xa5\xe8\xd1\xf9\x3a\x5e\xce\x3c\xf4\xe1\xe3\x87\x1c\xc9\x19\xf8\x8a\x24\xd0\x0e\x1d\x09\xc0\x0a\xe2\x00\xc5\x58\x81\x56\xab\x43\x27\xcb\xf9\xdc\x71\x3d\xdf\x9b\x3e\x39\x0b\x6f\xfc\xf4\x3d\x57\x84\xb7\x94\x08\x90\x47\x8a\x7b\x7c\xf5\x40\x78\x0d\x4c\x05\x68\x83\x45\xf4\x82\xc5\xe0\xf6\xd3\xa7\xeb\x1a\xf2\xfb\x7c\xfa\x34\x9e\xff\x40\x7f\x38\x3f\xd0\xa0\xa0\xe9\xfa\xea\x1a\x39\xee\xb7\xa9\xeb\x7c\x9e\x32\xc6\x1f\xbf\x94\xfb\xf9\x7d\x3c\x5f\x38\xde\x67\x8a\x15\x61\xa3\xdf\xfe\x75\xb3\xa7\x69\xc4\x99\xd2\xa7\xb8\x6c\xf4\x12\x6b\x4c\xae\xcd\xb9\x3f\xfa\x2f\xb6\x4d\x0f\xd0\x04\x62\x92\x25\x0a\xde\x54\x7e\xb8\xf1\xc4\x73\xe6\x68\xe1\x78\x28\x53\xab\x07\x34\x79\x9e\xcd\xf4\x17\xf5\x83\x1f\x12\x66\x79\x4d\x1a\xbf\xcb\x80\x55\xce\x49\xdc\x2b\xc2\x13\xb2\x16\x58\x11\xde\x18\x68\x16\xc0\x10\xbd\x01\x21\x09\x67\x26\x78\x46\x23\x8b\x69\x03\x6f\x64\x29\x97\x0b\x90\x19\x55\x01\xca\x4d\xb0\x97\xf4\x85\x8f\x88\x53\x0a\x91\x3e\x2c\x56\x4a\x90\x30\x53\xd0\x22\xff\x34\x6a\x19\xae\x4a\xd1\xc9\x74\x73\xd0\x29\xdd\x77\x74\xfb\x60\x81\x36\x98\x66\x60\x85\x76\xdd\x7f\x93\xf0\xae\xe2\xc2\x49\x78\x57\xf3\xe2\xaa\x33\x56\xf7\x77\x73\xb4\x99\xde\xf8\x68\xb9\xc5\x57\xd8\x75\xb2\x46\x8e\x6f\x6d\x87\x34\x0b\x29\x89\xfc\x57\xd8\x05\x28\xa4\x3c\xb4\xa4\x82\x6c\xb0\x82\x13\xe2\x73\xa4\xf6\x90\xc8\x14\x4b\xb9\xe5\x22\xee\xc4\x66\xa9\xd4\x9e\xd2\x42\x25\x40\xb9\xd7\xde\x7f\xbc\xfe\x3f\xb3\x26\x20\x26\x02\x22\xd5\x89\xb5\x52\xc9\xb0\x96\x0a\xd8\xf8\x98\x12\x2c\x8f\xc2\xfd\xa3\x45\x4c\xc0\x60\x7b\x11\x54\x65\xef\x68\xdd\x1e\x52\xd7\x89\x32\x79\x74\xa1\x5b\x5e\x85\xc6\x4b\xef\xd9\x9f\xba\x93\xb9\xf3\xe4\xb8\x9e\xc9\x9f\x0d\x3c\xb5\x4f\x8d\xb5\x4a\x4a\x11\x45\x7f\x4e\xa6\x0d\x62\x90\x91\x20\xa9\xca\x2f\xcb\xc3\xfe\xee\x3b\xed\xaf\x5a\x99\xaa\x1d\x05\x5f\xbe\x00\x14\x17\xa8\x79\x9b\x7f\xa4\xb8\x51\x5b\xaf\x9c\xab\xae\xb8\x48\xf0\x51\xc9\xf8\x50\x2f\x18\x4d\xe6\x8b\x76\x8d\x35\xae\xa9\x82\xb7\xec\x4c\x35\xbd\x21\xb0\xf5\x23\x9e\xe9\xe2\xab\x41\x5e\xaf\x8d\xf4\xdb\xa5\x3b\xfd\x73\xe9\xe4\x2f\xf7\xf6\x1d\x04\x3d\xf3\xee\x94\xcb\x36\xa9\xc0\xc0\x4a\x8f\x2e\x9c\xc0\xee\x39\x68\xb6\xb6\x7c\xb8\x66\x88\x84\xc7\x64\xb5\xf3\x8b\xd6\xc6\xd4\xb9\xb7\x0d\x38\xed\x07\x3e\x4e\x53\xc0\x02\xb3\x08\x0a\xe8\x5d\x53\x67\xc2\xb8\x48\x4c\x73\x42\x31\x5b\x67\x78\xbd\x47\x37\xad\x2b\x14\xad\x38\xc1\x4f\xf0\x94\xda\x12\xcd\x97\x4a\xfd\x4b\x84\x31\x88\xfd\x94\x4b\x62\xa2\xeb\xe8\x8b\x4b\x77\x31\xfd\xe6\x3a\x8f\x0d\x8b\xef\x1b\x30\x5d\x95\x4a\x85\x93\xb4\x6d\x07\x76\xa8\xfc\x3b\x6b\x5e\x70\x7f\x3b\xdd\xfc\x93\xec\x70\xe8\x71\xba\x25\x82\x8e\xe1\x48\x62\xdf\x38\x6b\xbd\x78\xcc\xdf\xd7\x14\x4a\xa3\x0f\xca\xff\x6f\x0e\x6b\xe7\x98\xc2\x73\x0a\xd4\xde\x8f\x6e\x7a\xd5\x2b\x09\x48\xb8\x82\x15\xa7\x94\x6f\x5b\xc4\x7d\x15\x7e\xb2\x64\xaa\xf5\x4f\x46\xcf\xaf\x4c\x28\x6a\xa0\xd3\xa3\x84\xcb\x25\xbe\xf5\x81\x9e\xf1\xab\xb7\xd5\xae\xce\xb7\xf0\xf5\x21\x40\x7e\x75\x77\xe7\xf6\x6c\x1f\x70\x39\x3e\x8c\xc5\x0f\x1e\xdf\x7f\xb6\x3b\x51\x6d\xd7\x66\xc7\xec\x35\x16\x67\x91\xe2\x86\x8a\xd3\x56\x21\x2c\xe4\x6f\xe7\x00\xf2\x05\x0b\x88\xfd\x4b\xb8\xcb\xb6\xb1\xe2\x6f\x50\x6e\xaf\x37\x76\xd1\x24\x77\x99\x3d\x58\x78\x63\x9d\xb3\xe3\xcd\x86\x79\xc3\xfd\xdd\x7f\x34\x6e\xd8\x6f\xac\x97\x83\x06\xbd\x39\xc2\x36\xa4\x99\xf7\x8a\xd8\x2a\xe7\x6c\x8a\xab\x75\x4e\x7d\x44\x86\xdf\x74\x42\x90\x01\x92\x09\xa6\xf4\x64\x2d\x74\x36\xc9\xb7\x99\x0a\x13\x86\x23\x45\x36\xcd\xf3\xe9\x3e\xd1\xde\xd2\xd1\x3b\x76\x86\x5a\x85\xe1\x04\xde\xdd\x1c\x5e\x1a\x66\x54\x57\x32\x7c\x1d\x16\x32\x8f\xf5\x75\x20\xc1\x84\xe6\x5b\x2a\x7e\x9d\x68\x9c\xd3\xbf\xfb\xd7\x82\xcb\x59\xb0\xa4\x65\x50\xfe\xdf\xab\x28\x94\x26\xce\xe2\x53\x61\x78\x90\x17\xee\x90\x3f\xf9\x27\xc3\xf1\xe4\x7d\xdf\xfa\xcc\x7f\x07\x00\x00\xff\xff\xbe\x79\x68\xa8\x10\x1b\x00\x00") + +func schema_sql() ([]byte, error) { + return bindata_read( + _schema_sql, + "schema.sql", + ) +} + +// Asset loads and returns the asset for the given name. +// It returns an error if the asset could not be found or +// could not be loaded. +func Asset(name string) ([]byte, error) { + cannonicalName := strings.Replace(name, "\\", "/", -1) + if f, ok := _bindata[cannonicalName]; ok { + return f() + } + return nil, fmt.Errorf("Asset %s not found", name) +} + +// AssetNames returns the names of the assets. +func AssetNames() []string { + names := make([]string, 0, len(_bindata)) + for name := range _bindata { + names = append(names, name) + } + return names +} + +// _bindata is a table, holding each asset generator, mapped to its name. +var _bindata = map[string]func() ([]byte, error){ + "schema.sql": schema_sql, +} + +// AssetDir returns the file names below a certain +// directory embedded in the file by go-bindata. +// For example if you run go-bindata on data/... and data contains the +// following hierarchy: +// data/ +// foo.txt +// img/ +// a.png +// b.png +// then AssetDir("data") would return []string{"foo.txt", "img"} +// AssetDir("data/img") would return []string{"a.png", "b.png"} +// AssetDir("foo.txt") and AssetDir("notexist") would return an error +// AssetDir("") will return []string{"data"}. +func AssetDir(name string) ([]string, error) { + node := _bintree + if len(name) != 0 { + cannonicalName := strings.Replace(name, "\\", "/", -1) + pathList := strings.Split(cannonicalName, "/") + for _, p := range pathList { + node = node.Children[p] + if node == nil { + return nil, fmt.Errorf("Asset %s not found", name) + } + } + } + if node.Func != nil { + return nil, fmt.Errorf("Asset %s not found", name) + } + rv := make([]string, 0, len(node.Children)) + for name := range node.Children { + rv = append(rv, name) + } + return rv, nil +} + +type _bintree_t struct { + Func func() ([]byte, error) + Children map[string]*_bintree_t +} + +var _bintree = &_bintree_t{nil, map[string]*_bintree_t{ + "schema.sql": &_bintree_t{schema_sql, map[string]*_bintree_t{}}, +}} diff --git a/collections.go b/collections.go index 0ccca2e..518cd2e 100644 --- a/collections.go +++ b/collections.go @@ -1,1438 +1,1417 @@ /* - * Copyright © 2018-2022 Musing Studio LLC. + * Copyright © 2018-2021 Musing Studio 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" "encoding/json" "fmt" "html/template" "math" "net/http" "net/url" "regexp" "strconv" "strings" "unicode" "github.com/gorilla/mux" - stripmd "github.com/writeas/go-strip-markdown/v2" "github.com/writeas/impart" "github.com/writeas/web-core/activitystreams" "github.com/writeas/web-core/auth" "github.com/writeas/web-core/bots" "github.com/writeas/web-core/i18n" "github.com/writeas/web-core/log" - "github.com/writeas/web-core/posts" + waposts "github.com/writeas/web-core/posts" "github.com/writefreely/writefreely/author" "github.com/writefreely/writefreely/config" "github.com/writefreely/writefreely/page" "github.com/writefreely/writefreely/spam" "golang.org/x/net/idna" ) const ( collAttrLetterReplyTo = "letter_reply_to" collMaxLengthTitle = 255 collMaxLengthDescription = 160 ) type ( // TODO: add Direction to db // TODO: add Language to db Collection struct { ID int64 `datastore:"id" json:"-"` Alias string `datastore:"alias" schema:"alias" json:"alias"` Title string `datastore:"title" schema:"title" json:"title"` Description string `datastore:"description" schema:"description" json:"description"` Direction string `schema:"dir" json:"dir,omitempty"` Language string `schema:"lang" json:"lang,omitempty"` StyleSheet string `datastore:"style_sheet" schema:"style_sheet" json:"style_sheet"` Script string `datastore:"script" schema:"script" json:"script,omitempty"` Signature string `datastore:"post_signature" schema:"signature" json:"-"` Public bool `datastore:"public" json:"public"` Visibility collVisibility `datastore:"private" json:"-"` Format string `datastore:"format" json:"format,omitempty"` Views int64 `json:"views"` OwnerID int64 `datastore:"owner_id" json:"-"` PublicOwner bool `datastore:"public_owner" json:"-"` URL string `json:"url,omitempty"` Monetization string `json:"monetization_pointer,omitempty"` Verification string `json:"verification_link"` db *datastore hostName string } CollectionObj struct { Collection TotalPosts int `json:"total_posts"` Owner *User `json:"owner,omitempty"` Posts *[]PublicPost `json:"posts,omitempty"` Format *CollectionFormat } DisplayCollection struct { *CollectionObj Prefix string NavSuffix string IsTopLevel bool CurrentPage int TotalPages int Silenced bool } CollectionNav struct { *Collection Path string SingleUser bool CanPost bool } SubmittedCollection struct { // Data used for updating a given collection ID int64 OwnerID uint64 // Form helpers PreferURL string `schema:"prefer_url" json:"prefer_url"` Privacy int `schema:"privacy" json:"privacy"` Pass string `schema:"password" json:"password"` MathJax bool `schema:"mathjax" json:"mathjax"` EmailSubs bool `schema:"email_subs" json:"email_subs"` Handle string `schema:"handle" json:"handle"` // Actual collection values updated in the DB Alias *string `schema:"alias" json:"alias"` Title *string `schema:"title" json:"title"` Description *string `schema:"description" json:"description"` StyleSheet *string `schema:"style_sheet" json:"style_sheet"` Script *string `schema:"script" json:"script"` Signature *string `schema:"signature" json:"signature"` Monetization *string `schema:"monetization_pointer" json:"monetization_pointer"` Verification *string `schema:"verification_link" json:"verification_link"` LetterReply *string `schema:"letter_reply" json:"letter_reply"` Visibility *int `schema:"visibility" json:"public"` Format *sql.NullString `schema:"format" json:"format"` } CollectionFormat struct { Format string } collectionReq struct { // Information about the collection request itself prefix, alias, domain string isCustomDomain bool // User-related fields isCollOwner bool isAuthorized bool } ) func (sc *SubmittedCollection) FediverseHandle() string { if sc.Handle == "" { return apCustomHandleDefault } return getSlug(sc.Handle, "") } // collVisibility represents the visibility level for the collection. type collVisibility int // Visibility levels. Values are bitmasks, stored in the database as // decimal numbers. If adding types, append them to this list. If removing, // replace the desired visibility with a new value. const CollUnlisted collVisibility = 0 const ( CollPublic collVisibility = 1 << iota CollPrivate CollProtected ) var collVisibilityStrings = map[string]collVisibility{ "unlisted": CollUnlisted, "public": CollPublic, "private": CollPrivate, "protected": CollProtected, } func defaultVisibility(cfg *config.Config) collVisibility { vis, ok := collVisibilityStrings[cfg.App.DefaultVisibility] if !ok { vis = CollUnlisted } return vis } func (cf *CollectionFormat) Ascending() bool { return cf.Format == "novel" } func (cf *CollectionFormat) ShowDates() bool { return cf.Format == "blog" } func (cf *CollectionFormat) PostsPerPage() int { if cf.Format == "novel" { return postsPerPage } return postsPerPage } // Valid returns whether or not a format value is valid. func (cf *CollectionFormat) Valid() bool { return cf.Format == "blog" || cf.Format == "novel" || cf.Format == "notebook" } // NewFormat creates a new CollectionFormat object from the Collection. func (c *Collection) NewFormat() *CollectionFormat { cf := &CollectionFormat{Format: c.Format} // Fill in default format if cf.Format == "" { cf.Format = "blog" } return cf } func (c *Collection) IsInstanceColl() bool { ur, _ := url.Parse(c.hostName) return c.Alias == ur.Host } func (c *Collection) IsUnlisted() bool { return c.Visibility == 0 } func (c *Collection) IsPrivate() bool { return c.Visibility&CollPrivate != 0 } func (c *Collection) IsProtected() bool { return c.Visibility&CollProtected != 0 } func (c *Collection) IsPublic() bool { return c.Visibility&CollPublic != 0 } func (c *Collection) FriendlyVisibility() string { if c.IsPrivate() { return "Private" } if c.IsPublic() { return "Public" } if c.IsProtected() { return "Password-protected" } return "Unlisted" } func (c *Collection) ShowFooterBranding() bool { // TODO: implement this setting return true } // CanonicalURL returns a fully-qualified URL to the collection. func (c *Collection) CanonicalURL() string { return c.RedirectingCanonicalURL(false) } func (c *Collection) DisplayCanonicalURL() string { us := c.CanonicalURL() u, err := url.Parse(us) if err != nil { return us } p := u.Path if p == "/" { p = "" } d := u.Hostname() d, _ = idna.ToUnicode(d) return d + p } // RedirectingCanonicalURL returns the fully-qualified canonical URL for the Collection, with a trailing slash. The // hostName field needs to be populated for this to work correctly. func (c *Collection) RedirectingCanonicalURL(isRedir bool) string { if c.hostName == "" { // If this is true, the human programmers screwed up. So ask for a bug report and fail, fail, fail log.Error("[PROGRAMMER ERROR] WARNING: Collection.hostName is empty! Federation and many other things will fail! If you're seeing this in the wild, please report this bug and let us know what you were doing just before this: https://github.com/writefreely/writefreely/issues/new?template=bug_report.md") } if isSingleUser { return c.hostName + "/" } return fmt.Sprintf("%s/%s/", c.hostName, c.Alias) } // PrevPageURL provides a full URL for the previous page of collection posts, // returning a /page/N result for pages >1 func (c *Collection) PrevPageURL(prefix, navSuffix string, n int, tl bool) string { u := "" if n == 2 { // Previous page is 1; no need for /page/ prefix if prefix == "" { u = navSuffix + "/" } // Else leave off trailing slash } else { u = fmt.Sprintf("%s/page/%d", navSuffix, n-1) } if tl { return u } return "/" + prefix + c.Alias + u } // NextPageURL provides a full URL for the next page of collection posts func (c *Collection) NextPageURL(prefix, navSuffix string, n int, tl bool) string { if tl { return fmt.Sprintf("%s/page/%d", navSuffix, n+1) } return fmt.Sprintf("/%s%s%s/page/%d", prefix, c.Alias, navSuffix, n+1) } func (c *Collection) DisplayTitle() string { if c.Title != "" { return c.Title } return c.Alias } func (c *Collection) StyleSheetDisplay() template.CSS { return template.CSS(c.StyleSheet) } // ForPublic modifies the Collection for public consumption, such as via // the API. func (c *Collection) ForPublic() { c.URL = c.CanonicalURL() } var isAvatarChar = regexp.MustCompile("[a-z0-9]").MatchString func (c *Collection) PersonObject(ids ...int64) *activitystreams.Person { accountRoot := c.FederatedAccount() p := activitystreams.NewPerson(accountRoot) p.URL = c.CanonicalURL() uname := c.Alias p.PreferredUsername = uname p.Name = c.DisplayTitle() p.Summary = c.Description if p.Name != "" { if av := c.AvatarURL(); av != "" { p.Icon = activitystreams.Image{ Type: "Image", MediaType: "image/png", URL: av, } } } collID := c.ID if len(ids) > 0 { collID = ids[0] } pub, priv := c.db.GetAPActorKeys(collID) if pub != nil { p.AddPubKey(pub) p.SetPrivKey(priv) } return p } func (c *Collection) AvatarURL() string { fl := string(unicode.ToLower([]rune(c.DisplayTitle())[0])) if !isAvatarChar(fl) { return "" } return c.hostName + "/img/avatars/" + fl + ".png" } func (c *Collection) FederatedAPIBase() string { return c.hostName + "/" } func (c *Collection) FederatedAccount() string { accountUser := c.Alias return c.FederatedAPIBase() + "api/collections/" + accountUser } func (c *Collection) RenderMathJax() bool { return c.db.CollectionHasAttribute(c.ID, "render_mathjax") } func (c *Collection) EmailSubsEnabled() bool { return c.db.CollectionHasAttribute(c.ID, "email_subs") } func (c *Collection) MonetizationURL() string { if c.Monetization == "" { return "" } return strings.Replace(c.Monetization, "$", "https://", 1) } -// DisplayDescription returns the description with rendered Markdown and HTML. -func (c *Collection) DisplayDescription() *template.HTML { - if c.Description == "" { - s := template.HTML("") - return &s - } - t := template.HTML(posts.ApplyBasicAccessibleMarkdown([]byte(c.Description))) - return &t -} - -// PlainDescription returns the description with all Markdown and HTML removed. -func (c *Collection) PlainDescription() string { - if c.Description == "" { - return "" - } - desc := stripHTMLWithoutEscaping(c.Description) - desc = stripmd.Strip(desc) - return desc -} - func (c CollectionPage) DisplayMonetization() string { return displayMonetization(c.Monetization, c.Alias) } func (c *DisplayCollection) Direction() string { if c.Language == "" { return "auto" } if i18n.LangIsRTL(c.Language) { return "rtl" } return "ltr" } func newCollection(app *App, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r) alias := r.FormValue("alias") title := r.FormValue("title") var missingParams, accessToken string var u *User c := struct { Alias string `json:"alias" schema:"alias"` Title string `json:"title" schema:"title"` Web bool `json:"web" schema:"web"` }{} if reqJSON { // Decode JSON request decoder := json.NewDecoder(r.Body) err := decoder.Decode(&c) if err != nil { log.Error("Couldn't parse post update JSON request: %v\n", err) return ErrBadJSON } } else { // TODO: move form parsing to formDecoder c.Alias = alias c.Title = title } if c.Alias == "" { if c.Title != "" { // If only a title was given, just use it to generate the alias. c.Alias = getSlug(c.Title, "") } else { missingParams += "`alias` " } } if c.Title == "" { missingParams += "`title` " } if missingParams != "" { return impart.HTTPError{http.StatusBadRequest, fmt.Sprintf("Parameter(s) %srequired.", missingParams)} } var userID int64 var err error if reqJSON && !c.Web { accessToken = r.Header.Get("Authorization") if accessToken == "" { return ErrNoAccessToken } userID = app.db.GetUserID(accessToken) if userID == -1 { return ErrBadAccessToken } } else { u = getUserSession(app, r) if u == nil { return ErrNotLoggedIn } userID = u.ID } silenced, err := app.db.IsUserSilenced(userID) if err != nil { log.Error("new collection: %v", err) return ErrInternalGeneral } if silenced { return ErrUserSilenced } if !author.IsValidUsername(app.cfg, c.Alias) { return impart.HTTPError{http.StatusPreconditionFailed, "Collection alias isn't valid."} } coll, err := app.db.CreateCollection(app.cfg, c.Alias, c.Title, userID) if err != nil { // TODO: handle this return err } res := &CollectionObj{Collection: *coll} if reqJSON { return impart.WriteSuccess(w, res, http.StatusCreated) } redirectTo := "/me/c/" // TODO: redirect to pad when necessary return impart.HTTPError{http.StatusFound, redirectTo} } func apiCheckCollectionPermissions(app *App, r *http.Request, c *Collection) (int64, error) { accessToken := r.Header.Get("Authorization") var userID int64 = -1 if accessToken != "" { userID = app.db.GetUserID(accessToken) } isCollOwner := userID == c.OwnerID if c.IsPrivate() && !isCollOwner { // Collection is private, but user isn't authenticated return -1, ErrCollectionNotFound } if c.IsProtected() { // TODO: check access token return -1, ErrCollectionUnauthorizedRead } return userID, nil } // fetchCollection handles the API endpoint for retrieving collection data. func fetchCollection(app *App, w http.ResponseWriter, r *http.Request) error { if IsActivityPubRequest(r) { return handleFetchCollectionActivities(app, w, r) } vars := mux.Vars(r) alias := vars["alias"] // TODO: move this logic into a common getCollection function // Get base Collection data c, err := app.db.GetCollection(alias) if err != nil { return err } c.hostName = app.cfg.App.Host // Redirect users who aren't requesting JSON reqJSON := IsJSON(r) if !reqJSON { return impart.HTTPError{http.StatusFound, c.CanonicalURL()} } // Check permissions userID, err := apiCheckCollectionPermissions(app, r, c) if err != nil { return err } isCollOwner := userID == c.OwnerID // Fetch extra data about the Collection res := &CollectionObj{Collection: *c} if c.PublicOwner { u, err := app.db.GetUserByID(res.OwnerID) if err != nil { // Log the error and just continue log.Error("Error getting user for collection: %v", err) } else { res.Owner = u } } // TODO: check status for silenced app.db.GetPostsCount(res, isCollOwner) // Strip non-public information res.Collection.ForPublic() return impart.WriteSuccess(w, res, http.StatusOK) } // fetchCollectionPosts handles an API endpoint for retrieving a collection's // posts. func fetchCollectionPosts(app *App, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) alias := vars["alias"] c, err := app.db.GetCollection(alias) if err != nil { return err } c.hostName = app.cfg.App.Host // Check permissions userID, err := apiCheckCollectionPermissions(app, r, c) if err != nil { return err } isCollOwner := userID == c.OwnerID // Get page page := 1 if p := r.FormValue("page"); p != "" { pInt, _ := strconv.Atoi(p) if pInt > 0 { page = pInt } } - ps, err := app.db.GetPosts(app.cfg, c, page, isCollOwner, false, false) + posts, err := app.db.GetPosts(app.cfg, c, page, isCollOwner, false, false) if err != nil { return err } - coll := &CollectionObj{Collection: *c, Posts: ps} + coll := &CollectionObj{Collection: *c, Posts: posts} app.db.GetPostsCount(coll, isCollOwner) // Strip non-public information coll.Collection.ForPublic() // Transform post bodies if needed if r.FormValue("body") == "html" { for _, p := range *coll.Posts { - p.Content = posts.ApplyMarkdown([]byte(p.Content)) + p.Content = waposts.ApplyMarkdown([]byte(p.Content)) } } return impart.WriteSuccess(w, coll, http.StatusOK) } type CollectionPage struct { page.StaticPage *DisplayCollection IsCustomDomain bool IsWelcome bool IsOwner bool IsCollLoggedIn bool Honeypot string IsSubscriber bool CanPin bool Username string Monetization string Flash template.HTML Collections *[]Collection PinnedPosts *[]PublicPost IsAdmin bool CanInvite bool // Helper field for Chorus mode CollAlias string } type TagCollectionPage struct { CollectionPage Tag string } func (tcp TagCollectionPage) PrevPageURL(prefix string, n int, tl bool) string { u := fmt.Sprintf("/tag:%s", tcp.Tag) if n > 2 { u += fmt.Sprintf("/page/%d", n-1) } if tl { return u } return "/" + prefix + tcp.Alias + u } func (tcp TagCollectionPage) NextPageURL(prefix string, n int, tl bool) string { if tl { return fmt.Sprintf("/tag:%s/page/%d", tcp.Tag, n+1) } return fmt.Sprintf("/%s%s/tag:%s/page/%d", prefix, tcp.Alias, tcp.Tag, n+1) } func NewCollectionObj(c *Collection) *CollectionObj { return &CollectionObj{ Collection: *c, Format: c.NewFormat(), } } func (c *CollectionObj) ScriptDisplay() template.JS { return template.JS(c.Script) } var jsSourceCommentReg = regexp.MustCompile("(?m)^// src:(.+)$") func (c *CollectionObj) ExternalScripts() []template.URL { scripts := []template.URL{} if c.Script == "" { return scripts } matches := jsSourceCommentReg.FindAllStringSubmatch(c.Script, -1) for _, m := range matches { scripts = append(scripts, template.URL(strings.TrimSpace(m[1]))) } return scripts } func (c *CollectionObj) CanShowScript() bool { return false } func processCollectionRequest(cr *collectionReq, vars map[string]string, w http.ResponseWriter, r *http.Request) error { cr.prefix = vars["prefix"] cr.alias = vars["collection"] // Normalize the URL, redirecting user to consistent post URL if cr.alias != strings.ToLower(cr.alias) { return impart.HTTPError{http.StatusMovedPermanently, fmt.Sprintf("/%s/", strings.ToLower(cr.alias))} } return nil } // processCollectionPermissions checks the permissions for the given // collectionReq, returning a Collection if access is granted; otherwise this // renders any necessary collection pages, for example, if requesting a custom // domain that doesn't yet have a collection associated, or if a collection // requires a password. In either case, this will return nil, nil -- thus both // values should ALWAYS be checked to determine whether or not to continue. func processCollectionPermissions(app *App, cr *collectionReq, u *User, w http.ResponseWriter, r *http.Request) (*Collection, error) { // Display collection if this is a collection var c *Collection var err error if app.cfg.App.SingleUser { c, err = app.db.GetCollectionByID(1) } else { c, err = app.db.GetCollection(cr.alias) } // TODO: verify we don't reveal the existence of a private collection with redirection if err != nil { if err, ok := err.(impart.HTTPError); ok { if err.Status == http.StatusNotFound { if cr.isCustomDomain { // User is on the site from a custom domain //tErr := pages["404-domain.tmpl"].ExecuteTemplate(w, "base", pageForHost(page.StaticPage{}, r)) //if tErr != nil { //log.Error("Unable to render 404-domain page: %v", err) //} return nil, nil } if len(cr.alias) >= minIDLen && len(cr.alias) <= maxIDLen { // Alias is within post ID range, so just be sure this isn't a post if app.db.PostIDExists(cr.alias) { // TODO: use StatusFound for vanity post URLs when we implement them return nil, impart.HTTPError{http.StatusMovedPermanently, "/" + cr.alias} } } // Redirect if necessary newAlias := app.db.GetCollectionRedirect(cr.alias) if newAlias != "" { return nil, impart.HTTPError{http.StatusFound, "/" + newAlias + "/"} } } } return nil, err } c.hostName = app.cfg.App.Host // Update CollectionRequest to reflect owner status cr.isCollOwner = u != nil && u.ID == c.OwnerID // Check permissions if !cr.isCollOwner { if c.IsPrivate() { return nil, ErrCollectionNotFound } else if c.IsProtected() { uname := "" if u != nil { uname = u.Username } // TODO: move this to all permission checks? suspended, err := app.db.IsUserSilenced(c.OwnerID) if err != nil { log.Error("process protected collection permissions: %v", err) return nil, err } if suspended { return nil, ErrCollectionNotFound } // See if we've authorized this collection cr.isAuthorized = isAuthorizedForCollection(app, c.Alias, r) if !cr.isAuthorized { p := struct { page.StaticPage *CollectionObj Username string Next string Flashes []template.HTML }{ StaticPage: pageForReq(app, r), CollectionObj: &CollectionObj{Collection: *c}, Username: uname, Next: r.FormValue("g"), Flashes: []template.HTML{}, } // Get owner information p.CollectionObj.Owner, err = app.db.GetUserByID(c.OwnerID) if err != nil { // Log the error and just continue log.Error("Error getting user for collection: %v", err) } flashes, _ := getSessionFlashes(app, w, r, nil) for _, flash := range flashes { p.Flashes = append(p.Flashes, template.HTML(flash)) } err = templates["password-collection"].ExecuteTemplate(w, "password-collection", p) if err != nil { log.Error("Unable to render password-collection: %v", err) return nil, err } return nil, nil } } } return c, nil } func checkUserForCollection(app *App, cr *collectionReq, r *http.Request, isPostReq bool) (*User, error) { u := getUserSession(app, r) return u, nil } func newDisplayCollection(c *Collection, cr *collectionReq, page int) *DisplayCollection { coll := &DisplayCollection{ CollectionObj: NewCollectionObj(c), CurrentPage: page, Prefix: cr.prefix, IsTopLevel: isSingleUser, } c.db.GetPostsCount(coll.CollectionObj, cr.isCollOwner) return coll } // getCollectionPage returns the collection page as an int. If the parsed page value is not // greater than 0 then the default value of 1 is returned. func getCollectionPage(vars map[string]string) int { if p, _ := strconv.Atoi(vars["page"]); p > 0 { return p } return 1 } // handleViewCollection displays the requested Collection func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) cr := &collectionReq{} err := processCollectionRequest(cr, vars, w, r) if err != nil { return err } u, err := checkUserForCollection(app, cr, r, false) if err != nil { return err } page := getCollectionPage(vars) c, err := processCollectionPermissions(app, cr, u, w, r) if c == nil || err != nil { return err } c.hostName = app.cfg.App.Host silenced, err := app.db.IsUserSilenced(c.OwnerID) if err != nil { log.Error("view collection: %v", err) return ErrInternalGeneral } // Serve ActivityStreams data now, if requested if IsActivityPubRequest(r) { ac := c.PersonObject() ac.Context = []interface{}{activitystreams.Namespace} setCacheControl(w, apCacheTime) return impart.RenderActivityJSON(w, ac, http.StatusOK) } // Fetch extra data about the Collection // TODO: refactor out this logic, shared in collection.go:fetchCollection() coll := newDisplayCollection(c, cr, page) coll.TotalPages = int(math.Ceil(float64(coll.TotalPosts) / float64(coll.Format.PostsPerPage()))) if coll.TotalPages > 0 && page > coll.TotalPages { redirURL := fmt.Sprintf("/page/%d", coll.TotalPages) if !app.cfg.App.SingleUser { redirURL = fmt.Sprintf("/%s%s%s", cr.prefix, coll.Alias, redirURL) } return impart.HTTPError{http.StatusFound, redirURL} } coll.Posts, _ = app.db.GetPosts(app.cfg, c, page, cr.isCollOwner, false, false) // Serve collection displayPage := CollectionPage{ DisplayCollection: coll, IsCollLoggedIn: cr.isAuthorized, StaticPage: pageForReq(app, r), IsCustomDomain: cr.isCustomDomain, IsWelcome: r.FormValue("greeting") != "", Honeypot: spam.HoneypotFieldName(), CollAlias: c.Alias, } flashes, _ := getSessionFlashes(app, w, r, nil) for _, f := range flashes { displayPage.Flash = template.HTML(f) } displayPage.IsAdmin = u != nil && u.IsAdmin() displayPage.CanInvite = canUserInvite(app.cfg, displayPage.IsAdmin) var owner *User if u != nil { displayPage.Username = u.Username displayPage.IsOwner = u.ID == coll.OwnerID displayPage.IsSubscriber = u.IsEmailSubscriber(app, coll.ID) if displayPage.IsOwner { // Add in needed information for users viewing their own collection owner = u displayPage.CanPin = true pubColls, err := app.db.GetPublishableCollections(owner, app.cfg.App.Host) if err != nil { log.Error("unable to fetch collections: %v", err) } displayPage.Collections = pubColls } } isOwner := owner != nil if !isOwner { // Current user doesn't own collection; retrieve owner information owner, err = app.db.GetUserByID(coll.OwnerID) if err != nil { // Log the error and just continue log.Error("Error getting user for collection: %v", err) } } if !isOwner && silenced { return ErrCollectionNotFound } displayPage.Silenced = isOwner && silenced displayPage.Owner = owner coll.Owner = displayPage.Owner // Add more data // TODO: fix this mess of collections inside collections displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner) displayPage.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer") collTmpl := "collection" if app.cfg.App.Chorus { collTmpl = "chorus-collection" } err = templates[collTmpl].ExecuteTemplate(w, "collection", displayPage) if err != nil { log.Error("Unable to render collection index: %v", err) } // Update collection view count go func() { // Don't update if owner is viewing the collection. if u != nil && u.ID == coll.OwnerID { return } // Only update for human views if r.Method == "HEAD" || bots.IsBot(r.UserAgent()) { return } _, err := app.db.Exec("UPDATE collections SET view_count = view_count + 1 WHERE id = ?", coll.ID) if err != nil { log.Error("Unable to update collections count: %v", err) } }() return err } func handleViewMention(app *App, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) handle := vars["handle"] remoteUser, err := app.db.GetProfilePageFromHandle(app, handle) if err != nil || remoteUser == "" { log.Error("Couldn't find user %s: %v", handle, err) return ErrRemoteUserNotFound } return impart.HTTPError{Status: http.StatusFound, Message: remoteUser} } func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) tag := vars["tag"] cr := &collectionReq{} err := processCollectionRequest(cr, vars, w, r) if err != nil { return err } u, err := checkUserForCollection(app, cr, r, false) if err != nil { return err } page := getCollectionPage(vars) c, err := processCollectionPermissions(app, cr, u, w, r) if c == nil || err != nil { return err } coll := newDisplayCollection(c, cr, page) taggedPostIDs, err := app.db.GetAllPostsTaggedIDs(c, tag, cr.isCollOwner) if err != nil { return err } ttlPosts := len(taggedPostIDs) pagePosts := coll.Format.PostsPerPage() coll.TotalPages = int(math.Ceil(float64(ttlPosts) / float64(pagePosts))) if coll.TotalPages > 0 && page > coll.TotalPages { redirURL := fmt.Sprintf("/page/%d", coll.TotalPages) if !app.cfg.App.SingleUser { redirURL = fmt.Sprintf("/%s%s%s", cr.prefix, coll.Alias, redirURL) } return impart.HTTPError{http.StatusFound, redirURL} } coll.Posts, _ = app.db.GetPostsTagged(app.cfg, c, tag, page, cr.isCollOwner) if coll.Posts != nil && len(*coll.Posts) == 0 { return ErrCollectionPageNotFound } // Serve collection displayPage := TagCollectionPage{ CollectionPage: CollectionPage{ DisplayCollection: coll, StaticPage: pageForReq(app, r), IsCustomDomain: cr.isCustomDomain, }, Tag: tag, } var owner *User if u != nil { displayPage.Username = u.Username displayPage.IsOwner = u.ID == coll.OwnerID if displayPage.IsOwner { // Add in needed information for users viewing their own collection owner = u displayPage.CanPin = true pubColls, err := app.db.GetPublishableCollections(owner, app.cfg.App.Host) if err != nil { log.Error("unable to fetch collections: %v", err) } displayPage.Collections = pubColls } } isOwner := owner != nil if !isOwner { // Current user doesn't own collection; retrieve owner information owner, err = app.db.GetUserByID(coll.OwnerID) if err != nil { // Log the error and just continue log.Error("Error getting user for collection: %v", err) } if owner.IsSilenced() { return ErrCollectionNotFound } } displayPage.Silenced = owner != nil && owner.IsSilenced() displayPage.Owner = owner coll.Owner = displayPage.Owner // Add more data // TODO: fix this mess of collections inside collections displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner) displayPage.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer") err = templates["collection-tags"].ExecuteTemplate(w, "collection-tags", displayPage) if err != nil { log.Error("Unable to render collection tag page: %v", err) } return nil } func handleViewCollectionLang(app *App, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) lang := vars["lang"] cr := &collectionReq{} err := processCollectionRequest(cr, vars, w, r) if err != nil { return err } u, err := checkUserForCollection(app, cr, r, false) if err != nil { return err } page := getCollectionPage(vars) c, err := processCollectionPermissions(app, cr, u, w, r) if c == nil || err != nil { return err } coll := newDisplayCollection(c, cr, page) coll.Language = lang coll.NavSuffix = fmt.Sprintf("/lang:%s", lang) ttlPosts, err := app.db.GetCollLangTotalPosts(coll.ID, lang) if err != nil { log.Error("Unable to getCollLangTotalPosts: %s", err) } pagePosts := coll.Format.PostsPerPage() coll.TotalPages = int(math.Ceil(float64(ttlPosts) / float64(pagePosts))) if coll.TotalPages > 0 && page > coll.TotalPages { redirURL := fmt.Sprintf("/lang:%s/page/%d", lang, coll.TotalPages) if !app.cfg.App.SingleUser { redirURL = fmt.Sprintf("/%s%s%s", cr.prefix, coll.Alias, redirURL) } return impart.HTTPError{http.StatusFound, redirURL} } coll.Posts, _ = app.db.GetLangPosts(app.cfg, c, lang, page, cr.isCollOwner) if err != nil { return ErrCollectionPageNotFound } // Serve collection displayPage := struct { CollectionPage Tag string }{ CollectionPage: CollectionPage{ DisplayCollection: coll, StaticPage: pageForReq(app, r), IsCustomDomain: cr.isCustomDomain, }, Tag: lang, } var owner *User if u != nil { displayPage.Username = u.Username displayPage.IsOwner = u.ID == coll.OwnerID if displayPage.IsOwner { // Add in needed information for users viewing their own collection owner = u displayPage.CanPin = true pubColls, err := app.db.GetPublishableCollections(owner, app.cfg.App.Host) if err != nil { log.Error("unable to fetch collections: %v", err) } displayPage.Collections = pubColls } } isOwner := owner != nil if !isOwner { // Current user doesn't own collection; retrieve owner information owner, err = app.db.GetUserByID(coll.OwnerID) if err != nil { // Log the error and just continue log.Error("Error getting user for collection: %v", err) } if owner.IsSilenced() { return ErrCollectionNotFound } } displayPage.Silenced = owner != nil && owner.IsSilenced() displayPage.Owner = owner coll.Owner = displayPage.Owner // Add more data // TODO: fix this mess of collections inside collections displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner) displayPage.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer") collTmpl := "collection" if app.cfg.App.Chorus { collTmpl = "chorus-collection" } err = templates[collTmpl].ExecuteTemplate(w, "collection", displayPage) if err != nil { log.Error("Unable to render collection lang page: %v", err) } return nil } func handleCollectionPostRedirect(app *App, w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) slug := vars["slug"] cr := &collectionReq{} err := processCollectionRequest(cr, vars, w, r) if err != nil { return err } // Normalize the URL, redirecting user to consistent post URL loc := fmt.Sprintf("/%s", slug) if !app.cfg.App.SingleUser { loc = fmt.Sprintf("/%s/%s", cr.alias, slug) } return impart.HTTPError{http.StatusFound, loc} } func existingCollection(app *App, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r) vars := mux.Vars(r) collAlias := vars["alias"] isWeb := r.FormValue("web") == "1" u := &User{} if reqJSON && !isWeb { // Ensure an access token was given accessToken := r.Header.Get("Authorization") u.ID = app.db.GetUserID(accessToken) if u.ID == -1 { return ErrBadAccessToken } } else { u = getUserSession(app, r) if u == nil { return ErrNotLoggedIn } } silenced, err := app.db.IsUserSilenced(u.ID) if err != nil { log.Error("existing collection: %v", err) return ErrInternalGeneral } if silenced { return ErrUserSilenced } if r.Method == "DELETE" { err := app.db.DeleteCollection(collAlias, u.ID) if err != nil { // TODO: if not HTTPError, report error to admin log.Error("Unable to delete collection: %s", err) return err } addSessionFlash(app, w, r, "Deleted your blog, "+collAlias+".", nil) return impart.HTTPError{Status: http.StatusNoContent} } c := SubmittedCollection{OwnerID: uint64(u.ID)} if reqJSON { // Decode JSON request decoder := json.NewDecoder(r.Body) err = decoder.Decode(&c) if err != nil { log.Error("Couldn't parse collection update JSON request: %v\n", err) return ErrBadJSON } } else { err = r.ParseForm() if err != nil { log.Error("Couldn't parse collection update form request: %v\n", err) return ErrBadFormData } err = app.formDecoder.Decode(&c, r.PostForm) if err != nil { log.Error("Couldn't decode collection update form request: %v\n", err) return ErrBadFormData } } err = app.db.UpdateCollection(app, &c, collAlias) if err != nil { if err, ok := err.(impart.HTTPError); ok { if reqJSON { return err } addSessionFlash(app, w, r, err.Message, nil) return impart.HTTPError{http.StatusFound, "/me/c/" + collAlias} } else { log.Error("Couldn't update collection: %v\n", err) return err } } if reqJSON { return impart.WriteSuccess(w, struct { }{}, http.StatusOK) } addSessionFlash(app, w, r, "Blog updated!", nil) return impart.HTTPError{http.StatusFound, "/me/c/" + collAlias} } // collectionAliasFromReq takes a request and returns the collection alias // if it can be ascertained, as well as whether or not the collection uses a // custom domain. func collectionAliasFromReq(r *http.Request) string { vars := mux.Vars(r) alias := vars["subdomain"] isSubdomain := alias != "" if !isSubdomain { // Fall back to write.as/{collection} since this isn't a custom domain alias = vars["collection"] } return alias } func handleWebCollectionUnlock(app *App, w http.ResponseWriter, r *http.Request) error { var readReq struct { Alias string `schema:"alias" json:"alias"` Pass string `schema:"password" json:"password"` Next string `schema:"to" json:"to"` } // Get params if impart.ReqJSON(r) { decoder := json.NewDecoder(r.Body) err := decoder.Decode(&readReq) if err != nil { log.Error("Couldn't parse readReq JSON request: %v\n", err) return ErrBadJSON } } else { err := r.ParseForm() if err != nil { log.Error("Couldn't parse readReq form request: %v\n", err) return ErrBadFormData } err = app.formDecoder.Decode(&readReq, r.PostForm) if err != nil { log.Error("Couldn't decode readReq form request: %v\n", err) return ErrBadFormData } } if readReq.Alias == "" { return impart.HTTPError{http.StatusBadRequest, "Need a collection `alias` to read."} } if readReq.Pass == "" { return impart.HTTPError{http.StatusBadRequest, "Please supply a password."} } var collHashedPass []byte err := app.db.QueryRow("SELECT password FROM collectionpasswords INNER JOIN collections ON id = collection_id WHERE alias = ?", readReq.Alias).Scan(&collHashedPass) if err != nil { if err == sql.ErrNoRows { log.Error("No collectionpassword found when trying to read collection %s", readReq.Alias) return impart.HTTPError{http.StatusInternalServerError, "Something went very wrong. The humans have been alerted."} } return err } if !auth.Authenticated(collHashedPass, []byte(readReq.Pass)) { return impart.HTTPError{http.StatusUnauthorized, "Incorrect password."} } // Success; set cookie session, err := app.sessionStore.Get(r, blogPassCookieName) if err == nil { session.Values[readReq.Alias] = true err = session.Save(r, w) if err != nil { log.Error("Didn't save unlocked blog '%s': %v", readReq.Alias, err) } } next := "/" + readReq.Next if !app.cfg.App.SingleUser { next = "/" + readReq.Alias + next } return impart.HTTPError{http.StatusFound, next} } func isAuthorizedForCollection(app *App, alias string, r *http.Request) bool { authd := false session, err := app.sessionStore.Get(r, blogPassCookieName) if err == nil { _, authd = session.Values[alias] } return authd } func logOutCollection(app *App, alias string, w http.ResponseWriter, r *http.Request) error { session, err := app.sessionStore.Get(r, blogPassCookieName) if err != nil { return err } // Remove this from map of blogs logged into delete(session.Values, alias) // If not auth'd with any blog, delete entire cookie if len(session.Values) == 0 { session.Options.MaxAge = -1 } return session.Save(r, w) } func handleLogOutCollection(app *App, w http.ResponseWriter, r *http.Request) error { alias := collectionAliasFromReq(r) var c *Collection var err error if app.cfg.App.SingleUser { c, err = app.db.GetCollectionByID(1) } else { c, err = app.db.GetCollection(alias) } if err != nil { return err } if !c.IsProtected() { // Invalid to log out of this collection return ErrCollectionPageNotFound } err = logOutCollection(app, c.Alias, w, r) if err != nil { addSessionFlash(app, w, r, "Logging out failed. Try clearing cookies for this site, instead.", nil) } return impart.HTTPError{http.StatusFound, c.CanonicalURL()} } diff --git a/errors.go b/errors.go index f0d3099..3db0f1f 100644 --- a/errors.go +++ b/errors.go @@ -1,62 +1,63 @@ /* * Copyright © 2018-2020 Musing Studio 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 ( "net/http" "github.com/writeas/impart" ) // Commonly returned HTTP errors var ( ErrBadFormData = impart.HTTPError{http.StatusBadRequest, "Expected valid form data."} ErrBadJSON = impart.HTTPError{http.StatusBadRequest, "Expected valid JSON object."} ErrBadJSONArray = impart.HTTPError{http.StatusBadRequest, "Expected valid JSON array."} ErrBadAccessToken = impart.HTTPError{http.StatusUnauthorized, "Invalid access token."} ErrNoAccessToken = impart.HTTPError{http.StatusBadRequest, "Authorization token required."} ErrNotLoggedIn = impart.HTTPError{http.StatusUnauthorized, "Not logged in."} ErrForbiddenCollection = impart.HTTPError{http.StatusForbidden, "You don't have permission to add to this collection."} ErrForbiddenEditPost = impart.HTTPError{http.StatusForbidden, "You don't have permission to update this post."} ErrUnauthorizedEditPost = impart.HTTPError{http.StatusUnauthorized, "Invalid editing credentials."} ErrUnauthorizedGeneral = impart.HTTPError{http.StatusUnauthorized, "You don't have permission to do that."} ErrBadRequestedType = impart.HTTPError{http.StatusNotAcceptable, "Bad requested Content-Type."} ErrCollectionUnauthorizedRead = impart.HTTPError{http.StatusUnauthorized, "You don't have permission to access this collection."} ErrNoPublishableContent = impart.HTTPError{http.StatusBadRequest, "Supply something to publish."} ErrInternalGeneral = impart.HTTPError{http.StatusInternalServerError, "The humans messed something up. They've been notified."} ErrInternalCookieSession = impart.HTTPError{http.StatusInternalServerError, "Could not get cookie session."} ErrUnavailable = impart.HTTPError{http.StatusServiceUnavailable, "Service temporarily unavailable due to high load."} ErrCollectionNotFound = impart.HTTPError{http.StatusNotFound, "Collection doesn't exist."} ErrCollectionGone = impart.HTTPError{http.StatusGone, "This blog was unpublished."} ErrCollectionPageNotFound = impart.HTTPError{http.StatusNotFound, "Collection page doesn't exist."} ErrPostNotFound = impart.HTTPError{Status: http.StatusNotFound, Message: "Post not found."} ErrPostBanned = impart.HTTPError{Status: http.StatusGone, Message: "Post removed."} ErrPostUnpublished = impart.HTTPError{Status: http.StatusGone, Message: "Post unpublished by author."} ErrPostFetchError = impart.HTTPError{Status: http.StatusInternalServerError, Message: "We encountered an error getting the post. The humans have been alerted."} - ErrUserNotFound = impart.HTTPError{http.StatusNotFound, "User doesn't exist."} + //ErrUserNotFound = impart.HTTPError{http.StatusNotFound, "User doesn't exist."} + ErrUserNotFound = impart.HTTPError{http.StatusNotFound, "L'utente non esiste, richiedi un invito per crearlo.
Se ne hai già uno, collegalo al tuo account LS da Account Settings, Linked Accounts.

User doesn't exist, request an invitation to create it..
If you already have one, link it to your LS account from Account Settings, Linked Accounts."} ErrRemoteUserNotFound = impart.HTTPError{http.StatusNotFound, "Remote user not found."} ErrUserNotFoundEmail = impart.HTTPError{http.StatusNotFound, "Please enter your username instead of your email address."} ErrUserSilenced = impart.HTTPError{http.StatusForbidden, "Account is silenced."} ErrDisabledPasswordAuth = impart.HTTPError{http.StatusForbidden, "Password authentication is disabled."} ) // Post operation errors var ( ErrPostNoUpdatableVals = impart.HTTPError{http.StatusBadRequest, "Supply some properties to update."} ) diff --git a/less/core.less b/less/core.less index a64056a..7114335 100644 --- a/less/core.less +++ b/less/core.less @@ -1,1640 +1,1643 @@ body { - font-family: @serifFont; + font-family: @sylexiadFont; 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.4em; } } 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; + font-family: @sylexiadFont; padding-top: 0.2em; padding-bottom: 0.2em; } } pre { line-height: 1.5; } .flash { text-align: center; margin-bottom: 4em; } } &#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; + font-family: @sylexiadFont; 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; } } } } img { &.paid { height: 0.86em; vertical-align: middle; margin-bottom: 0.1em; } } 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; + font-family: @sylexiadFont; 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; + font-family: @sylexiadFont; 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.5; } } textarea, input#title, pre, body#post article, body#collection article p { - &.norm, &.sans, &.wrap { + &.norm, &.sans, &.wrap, &.syl { line-height: 1.5; 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, input#title, 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; } + &.syl{ + font-family: @sylexiadFont; + } &.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; + font-family: @sylexiadFont; 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; } } } article table { border-spacing: 0; border-collapse: collapse; width: 100%; th { border-width: 1px 1px 2px 1px; border-style: solid; border-color: #ccc; } td { border-width: 0 1px 1px 1px; border-style: solid; border-color: #ccc; padding: .25rem .5rem; } } 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; background: white; } input, button, select.inputform, textarea.inputform, a.btn { padding: 0.5em; - font-family: @serifFont; + font-family: @sylexiadFont; 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; } } .btn.pager { border: 1px solid @lightNavBorder; font-size: .86em; padding: .5em 1em; white-space: nowrap; - font-family: @sansFont; + font-family: @sylexiadFont; &:hover { text-decoration: none; background: @lightNavBorder; } } .btn.cta.secondary, input[type=submit].secondary { background: transparent; color: @primary; &:hover { background-color: #f9f9f9; } } .btn.cta.disabled { background-color: desaturate(@primary, 100%) !important; border-color: desaturate(@primary, 100%) !important; } 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; &.toosmall { max-width: 25em; } &.tight { max-width: 30em; } &.snug { max-width: 40em; } .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.5; } 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.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 { padding-left: 0; margin-left: 0; h3 { margin-top: 0; font-weight: normal; } 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.5; .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; } &.danger { border-color: #856404; background-color: white; h3 { margin: 0 0 0.5em 0; font-size: 1em; font-weight: bold; color: black !important; } h3 + p, button { font-size: 0.86em; } } p { margin: 0; &+p { margin-top: 0.5em; } } p.dismiss { - font-family: @sansFont; + font-family: @sylexiadFont; 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; font-size: 0.86em; margin-left: 0.75rem; } } 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; + font-family: @sylexiadFont; &+ .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; + font-family: @sylexiadFont; 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-family: @sylexiadFont; 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 1em 0.5em 0.25em; nav#user-nav { nav > ul > li:first-child { img { top: -0.75em; } } } .right-side { padding-top: 0.5em; } } .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; + font-family: @sylexiadFont; } .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:not(.admin-actions) { 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; } article { .hidden { .opacity(1); } } } @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; } #emailsub { text-align: center; } p#emailsub { display: inline-block !important; width: 100%; font-style: italic; } #subscribe-btn { margin-left: 0.5em; } #org-nav { - font-family: @sansFont; + font-family: @sylexiadFont; font-size: 1.1em; color: #888; em, strong { color: #000; } &+h1 { margin-top: 0.5em; } a:link, a:visited, a:hover { color: @accent; } a:first-child { margin-right: 0.25em; } a.coll-name { font-weight: bold; margin-left: 0.25em; } } \ No newline at end of file diff --git a/less/fonts.less b/less/fonts.less index e4b2659..7568650 100644 --- a/less/fonts.less +++ b/less/fonts.less @@ -1,61 +1,89 @@ /* open-sans-regular - latin */ @font-face { font-family: 'Open Sans'; font-style: normal; font-weight: 400; src: url('/fonts/open-sans-v13-latin-regular.eot'); /* IE9 Compat Modes */ src: local('Open Sans'), local('OpenSans'), url('/fonts/open-sans-v13-latin-regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ url('/fonts/open-sans-v13-latin-regular.woff2') format('woff2'), /* Super Modern Browsers */ url('/fonts/open-sans-v13-latin-regular.woff') format('woff'), /* Modern Browsers */ url('/fonts/open-sans-v13-latin-regular.ttf') format('truetype'), /* Safari, Android, iOS */ url('/fonts/open-sans-v13-latin-regular.svg#OpenSans') format('svg'); /* Legacy iOS */ } /* open-sans-700 - latin */ @font-face { font-family: 'Open Sans'; font-style: normal; font-weight: 700; src: url('/fonts/open-sans-v13-latin-700.eot'); /* IE9 Compat Modes */ src: local('Open Sans Bold'), local('OpenSans-Bold'), url('/fonts/open-sans-v13-latin-700.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ url('/fonts/open-sans-v13-latin-700.woff2') format('woff2'), /* Super Modern Browsers */ url('/fonts/open-sans-v13-latin-700.woff') format('woff'), /* Modern Browsers */ url('/fonts/open-sans-v13-latin-700.ttf') format('truetype'), /* Safari, Android, iOS */ url('/fonts/open-sans-v13-latin-700.svg#OpenSans') format('svg'); /* Legacy iOS */ } /* lora-regular - latin */ @font-face { font-family: 'Lora'; font-style: normal; font-weight: 400; src: url('/fonts/Lora-Regular.eot'); /* IE9 Compat Modes */ src: local('Lora'), local('Lora-Regular'), url('/fonts/Lora-Regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ url('/fonts/Lora-Regular.woff2') format('woff2'), /* Super Modern Browsers */ url('/fonts/Lora-Regular.woff') format('woff'), /* Modern Browsers */ url('/fonts/Lora-Regular.ttf') format('truetype'); /* Safari, Android, iOS */ } /* lora-700 - latin */ @font-face { font-family: 'Lora'; font-style: normal; font-weight: 700; src: url('/fonts/Lora-Bold.eot'); /* IE9 Compat Modes */ src: local('Lora Bold'), local('Lora-Bold'), url('/fonts/Lora-Bold.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ url('/fonts/Lora-Bold.woff2') format('woff2'), /* Super Modern Browsers */ url('/fonts/Lora-Bold.woff') format('woff'), /* Modern Browsers */ url('/fonts/Lora-Bold.ttf') format('truetype'); /* Safari, Android, iOS */ } @font-face { font-family: 'Lora'; font-style: italic; font-weight: 400; src: url('/fonts/Lora-Italic.eot'); /* IE9 Compat Modes */ src: local('Lora Italic'), local('Lora-Italic'), url('/fonts/Lora-Italic.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ url('/fonts/Lora-Italic.woff2') format('woff2'), /* Super Modern Browsers */ url('/fonts/Lora-Italic.woff') format('woff'), /* Modern Browsers */ url('/fonts/Lora-Italic.ttf') format('truetype'); /* Safari, Android, iOS */ } +/* Sylexiad Sans Medium */ +@font-face { + font-family: 'Sylexiad'; + font-style: normal; + font-weight: 400; + font-display: block; + src: local('Sylexiad Sans Medium'), local('Sylexiad-Sans-Medium'), + url('/fonts/SylexiadSansMedium.woff') format('woff'), /* Modern Browsers */ + url('/fonts/SylexiadSansMedium.ttf') format('truetype'); /* Safari, Android, iOS */ +} +@font-face { + font-family: 'Sylexiad'; + font-style: italic; + font-weight: 400; + font-display: block; + src: local('Sylexiad Sans Medium Italic'), local('Sylexiad-Sans-Medium-Italic'), + url('/fonts/SylexiadSansMedium-Italic.woff') format('woff'), /* Modern Browsers */ + url('/fonts/SylexiadSansMedium-Italic.ttf') format('truetype'); /* Safari, Android, iOS */ +} +@font-face { + font-family: 'Sylexiad'; + font-style: bold; + font-weight: 700; + font-display: block; + src: local('Sylexiad Sans Medium Bold'), local('Sylexiad-Sans-Medium-Bold'), + url('/fonts/SylexiadSansMedium-Bold.woff') format('woff'), /* Modern Browsers */ + url('/fonts/SylexiadSansMedium-Bold.ttf') format('truetype'); /* Safari, Android, iOS */ +} \ No newline at end of file diff --git a/less/pad.less b/less/pad.less index 486e2ea..f44ae28 100644 --- a/less/pad.less +++ b/less/pad.less @@ -1,526 +1,536 @@ .dropdown-nav { font-family: @sansFont; line-height: 2em; span { margin: 0; } .material-icons { vertical-align: sub; } >ul>li { line-height: 1.8; bottom: -0.35em; } ul { display: inline; list-style:none; position:relative; margin:0; padding:0; ul { display:none; position:absolute; top:100%; left:0; background:#fff; padding:0; max-height: 30em; overflow-y: auto; overflow-x: hidden; border: 1px solid @lightNavBorder; .rounded(.25em); li { line-height: 1.8; display: block; min-width: 9em; max-width: 16em; } } a { display: block; color:#333; text-decoration:none; padding: 0 0.5em; margin: 0; overflow: hidden; white-space: -moz-nowrap; /* Mozilla, since 1999 */ white-space: -nowrap; /* Opera 4-6 */ white-space: -o-nowrap; /* Opera 7 */ white-space: nowrap; &:hover { text-decoration: none; } } li { display: inline-block; position: relative; margin: 0; padding: 0; &:hover { background: @lightNavHoverBG; } &:hover > ul, &.open > ul { display: block; } &.selected { a, a:hover { color: #888; } } &.current-user, &.menu-heading { font-weight: bold; padding: 0 .5em; color: #000; &:hover { background-color: transparent !important; } } &.menu-heading { color: #666; font-weight: normal; font-size: 0.8em; padding: 0.2em 0.8em; cursor: default; text-align: left; } hr { margin: 0.5em 0.75em; } } } } nav#manage { .dropdown-nav; ul ul li { min-width: 11em; img.ic-18dp { margin-top: -2px; } } } img.ic-18dp { width: 18px; height: 18px; vertical-align: middle; } img.ic-24dp { width: 24px; height: 24px; vertical-align: middle; } +img.ic-50dp { + width: 50px; + height: 50px; + vertical-align: middle; +} + body#pad, body#pad-sub { margin: 0; padding: 0; font-size: 100%; font-family: Lora, serif; header { height: 1.6em; } #tools { margin: 0 0 1em; padding: 1em 2em; -moz-transition-property: opacity; -webkit-transition-property: opacity; -o-transition-property: opacity; transition-property: opacity; .transition-duration(0.4s); &:hover { .opacity(1); .hidden { .opacity(1); } } .hidden { &#wc { position: relative; top: -0.15em; font-size: 0.9em; margin-left: 0.75em; } } h1 { display: inline-block; font-family: Lora, serif; margin: 0; font-size: 1.5em; a { color: white; } } nav { .dropdown-nav; } #clip { display: inline-block; margin-top: -0.35em; } #belt { float: right; a { padding: 1em 1.2em; vertical-align: middle; .opacity(.75); .transition-duration(0.2s); -moz-transition-property: opacity; -webkit-transition-property: opacity; -o-transition-property: opacity; transition-property: opacity; &:hover { .opacity(1); } &.disabled, &.disabled:hover { .opacity(.3); } img.ic-24dp { vertical-align: bottom; } .material-icons { vertical-align: middle; max-width: 24px; overflow: hidden; display: inline-block; } .material-icons, img.ic-24dp { &+ span { margin-left: .4em; height: 24px; vertical-align: bottom; } } } .tool:last-child a { padding-right: 0; } } .tool { display: inline-block; margin: 0; &#status { &.doing { font-style: italic; } } button { font-family: @sansFont; background-color: transparent; padding-top: 0.25rem; padding-bottom: 0.25rem; border: 0; } } } } body#pad-sub { .content-container { p { a:hover { text-decoration: underline; } &.status { text-align: center; font-size: 1.1em; &:first-child { margin-top: 1.5em; } } } } } body#pad { textarea, textarea:focus { border: 0; outline: 0; } textarea, #title { position: fixed !important; top: 3em; right: 0; bottom: 0; left: 0; width: 100%; height: auto; height: calc(~"100% - 3em - 1px"); padding: 1em 2em 2em; font-size: 1.2em; letter-spacing: 0.6px; box-sizing: border-box; resize: none; &.classy { font-family: Lora, serif; letter-spacing: 0.7px; } + &.syl { + font-family: @sylexiadFont; + line-height: 1.4; + } &.mono, &.code { padding-left: 1em; padding-right: 1em; white-space: -moz-pre; /* Mozilla, since 1999 */ white-space: -pre; /* Opera 4-6 */ white-space: -o-pre; /* Opera 7 */ white-space: pre; word-wrap: normal; } &.norm, &.sans, &.wrap { line-height: 1.4; } } #tools { position: fixed; top: 0; left: 0; right: 0; margin: 0; .opacity(.2); .mode-wp { font-family: serif; } .mode-typewriter { font-family: "Courier New", monospace; font-size: 1em; } } } .modal { display: none; position: absolute; z-index: 11; top: 3em; left: 50%; width: 30em; margin-left: -15em; padding: 1.5em 2em; .rounded(.25em); background: @lightNavBG; border: 1px solid @lightNavBorder; h2 { margin-top: 0; } input[type=text], input[type=email], input[type=password] { background: transparent; border: 0; border-bottom: 1px solid #ccc; -moz-transition-property: opacity; -webkit-transition-property: opacity; -o-transition-property: opacity; transition-property: opacity; .transition-duration(0.2s); .opacity(1); &:disabled { .opacity(.4); } } .body { line-height: 1.5; input[type=text].confirm { width: 100%; box-sizing: border-box; } } .short { text-align: center; } .form-hint { font-size: 0.78em; color: #888; } } #overlay { display: none; position: fixed; top: 0; right: 0; bottom: 0; left: 0; background: rgba(0, 0, 0, 0.4); z-index: 10; } body#pad .alert { position: fixed; bottom: 0.25em; left: 2em; right: 2em; font-size: 1.1em; &#edited-elsewhere { &.hidden { display: none; } a { font-weight: bold; } } } @media all and (max-height: 500px) { body#pad { textarea { top: 2.25em; padding-top: 0.25em; } &.classic { #editor { top: 5.25em; } #title { top: 3.5rem; } } #tools { padding-top: 0.5em; padding-bottom: 0.5em; } } } @media all and (min-width: 360px) { body#pad #tools .if-room.room-1, body#pad-sub #tools .tool.if-room.room-1, .if-room.room-1 { display: inline-block; } } @media all and (min-width: 425px) { body#pad #tools .if-room.room-2, body#pad-sub #tools .tool.if-room.room-2, .if-room.room-2 { display: inline-block; } } @media all and (min-width: 510px) { body#pad #tools .if-room.room-3, body#pad-sub #tools .tool.if-room.room-3, .if-room.room-3 { display: inline-block; } } @media all and (max-width: 650px) { body#pad #tools .tool.if-room, body#pad-sub #tools .tool.if-room, .if-room { display: none; } } @media all and (max-width: 600px) { .modal { margin-left: 0; width: auto; left: 0; right: 0; } #user-nav .tabs { display: block; text-align: center; margin: 0.5em 0 -2em; a:first-child { margin-left: 0; } } #target-name { max-width: 98px; display: inline-block; } } @media all and (min-width: 50em) { body#pad, body#pad.classic { textarea, #title { padding-left: 10%; padding-right: 10%; } .alert { left: 10%; right: 10%; } } } @media all and (min-width: 60em) { body#pad, body#pad.classic { textarea, #title { padding-left: 15%; padding-right: 15%; } .alert { left: 15%; right: 15%; } } } @media all and (min-width: 70em) { body#pad, body#pad.classic { textarea, #title { padding-left: 20%; padding-right: 20%; } .alert { left: 20%; right: 20%; } } } @media all and (min-width: 85em) { body#pad, body#pad.classic { textarea, #title { padding-left: 25%; padding-right: 25%; } .alert { left: 25%; right: 25%; } } } @media all and (min-width: 105em) { body#pad, body#pad.classic { textarea, #title { padding-left: 30%; padding-right: 30%; } .alert { left: 30%; right: 30%; } } } @media (pointer: coarse) { body#pad, body#pad-sub { #tools { .opacity(.8); .hidden { .opacity(.8); } } } } diff --git a/less/resources.less b/less/resources.less index c255166..000d7ab 100644 --- a/less/resources.less +++ b/less/resources.less @@ -1,13 +1,14 @@ @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; +@sylexiadFont: 'Sylexiad'; @monoFont: Hack, consolas, Menlo-Regular, Menlo, Monaco, 'ubuntu mono', monospace, monospace; @dangerCol: #e21d27; @errUrgentCol: #ecc63c; @proSelectedCol: #71D571; @textLinkColor: rgb(0, 0, 238); @accent: #767676; \ No newline at end of file diff --git a/pages/landing.tmpl b/pages/landing.tmpl index 2131b40..a0c3ce6 100644 --- a/pages/landing.tmpl +++ b/pages/landing.tmpl @@ -1,203 +1,204 @@ {{define "head"}} {{.SiteName}} {{end}} {{define "content"}}
{{ if .OpenRegistration }} {{template "oauth-buttons" .}} {{if not .DisablePasswordAuth}} {{if .Flashes}}
    {{range .Flashes}}
  • {{.}}
  • {{end}}
{{end}}
{{end}} {{ else }} -

Registration is currently closed.

-

You can always sign up on another instance.

+

Vuoi creare un tuo blog su LOG?

+

Scrivici, dicci la tua idea e richiedi un invito a log at livellosegreto.it

+

Oppure cerca un'altra istanza.

{{ end }}
{{if .Content}}
{{end}} {{ if .Content }}
{{.Content}}
{{ end }} {{end}} diff --git a/static/favicon.ico b/static/favicon.ico index 50c4fa5..d3aad97 100644 Binary files a/static/favicon.ico and b/static/favicon.ico differ diff --git a/static/fonts/SylexiadSansMedium-Bold.ttf b/static/fonts/SylexiadSansMedium-Bold.ttf new file mode 100644 index 0000000..b3459f4 Binary files /dev/null and b/static/fonts/SylexiadSansMedium-Bold.ttf differ diff --git a/static/fonts/SylexiadSansMedium-Bold.woff b/static/fonts/SylexiadSansMedium-Bold.woff new file mode 100644 index 0000000..47a4409 Binary files /dev/null and b/static/fonts/SylexiadSansMedium-Bold.woff differ diff --git a/static/fonts/SylexiadSansMedium-Italic.ttf b/static/fonts/SylexiadSansMedium-Italic.ttf new file mode 100644 index 0000000..647f33b Binary files /dev/null and b/static/fonts/SylexiadSansMedium-Italic.ttf differ diff --git a/static/fonts/SylexiadSansMedium-Italic.woff b/static/fonts/SylexiadSansMedium-Italic.woff new file mode 100644 index 0000000..969d859 Binary files /dev/null and b/static/fonts/SylexiadSansMedium-Italic.woff differ diff --git a/static/fonts/SylexiadSansMedium.ttf b/static/fonts/SylexiadSansMedium.ttf new file mode 100644 index 0000000..1cfec44 Binary files /dev/null and b/static/fonts/SylexiadSansMedium.ttf differ diff --git a/static/fonts/SylexiadSansMedium.woff b/static/fonts/SylexiadSansMedium.woff new file mode 100644 index 0000000..6c796cf Binary files /dev/null and b/static/fonts/SylexiadSansMedium.woff differ diff --git a/static/logo.webp b/static/logo.webp new file mode 100644 index 0000000..7f70aee Binary files /dev/null and b/static/logo.webp differ diff --git a/templates/base.tmpl b/templates/base.tmpl index 4440860..84cec71 100644 --- a/templates/base.tmpl +++ b/templates/base.tmpl @@ -1,95 +1,95 @@ {{define "base"}} {{ template "head" . }} {{if .CustomCSS}}{{end}}
{{ if .Chorus }} {{end}}
{{ template "content" . }}
{{ template "footer" . }} {{if not .JSDisabled}} {{else}} {{if .WebFonts}}{{end}} {{end}} {{end}} {{define "body-attrs"}}{{end}} diff --git a/templates/classic.tmpl b/templates/classic.tmpl index 58f82c7..0a644bc 100644 --- a/templates/classic.tmpl +++ b/templates/classic.tmpl @@ -1,402 +1,403 @@ {{define "pad"}} {{if .Editing}}Editing {{if .Post.Title}}{{.Post.Title}}{{else}}{{.Post.Id}}{{end}}{{else}}New Post{{end}} — {{.SiteName}} {{if .CustomCSS}}{{end}}
{{if not .SingleUser}}

{{end}}
{{if .Editing}}{{end}}
{{end}} diff --git a/templates/collection-post.tmpl b/templates/collection-post.tmpl index 54d5298..25b66c0 100644 --- a/templates/collection-post.tmpl +++ b/templates/collection-post.tmpl @@ -1,145 +1,146 @@ {{define "post"}} {{.PlainDisplayTitle}} {{localhtml "title dash" .Language.String}} {{.Collection.DisplayTitle}} {{if .CustomCSS}}{{end}} {{ if .IsFound }} {{if gt .Views 1}} {{end}} {{if gt (len .Images) 0}}{{else}}{{end}} {{range .Images}}{{else}}{{end}} {{ end }} {{template "collection-meta" .}} {{if .Collection.StyleSheet}}{{end}} {{if .Collection.RenderMathJax}} {{template "mathjax" . }} {{end}} {{template "highlighting" .}}

{{if .Silenced}} {{template "user-silenced"}} {{end}}
{{if .IsScheduled}}

Scheduled

{{end}}{{if .Title.String}}

{{.FormattedDisplayTitle}}

{{end}}{{if and $.Collection.Format.ShowDates (not .IsPinned) .IsFound}}{{end}}
{{.HTMLContent}}
{{ if .Collection.ShowFooterBranding }} - + + {{ end }} {{if .Collection.CanShowScript}} {{range .Collection.ExternalScripts}}{{end}} {{if .Collection.Script}}{{end}} {{end}} {{if and .Monetization (not .IsOwner)}} {{end}} {{end}} diff --git a/templates/collection.tmpl b/templates/collection.tmpl index 93a01c5..be808f8 100644 --- a/templates/collection.tmpl +++ b/templates/collection.tmpl @@ -1,261 +1,262 @@ {{define "collection"}} {{.DisplayTitle}}{{if not .SingleUser}} — {{.SiteName}}{{end}} {{if .CustomCSS}}{{end}} {{if gt .CurrentPage 1}}{{end}} {{if lt .CurrentPage .TotalPages}}{{end}} {{if not .IsPrivate}}{{end}} - + - + - + - + {{template "collection-meta" .}} {{if .StyleSheet}}{{end}} {{if .RenderMathJax}} {{template "mathjax" .}} {{end}} {{template "highlighting" . }} {{if or .IsOwner .SingleUser}} {{else if .IsCollLoggedIn}} {{end}}
{{if .Silenced}} {{template "user-silenced"}} {{end}}

{{if .Posts}}{{else}}write.as {{end}}{{.DisplayTitle}}

- {{if .Description}}

{{.DisplayDescription}}

{{end}} + {{if .Description}}

{{.Description}}

{{end}} {{/*if not .Public/*}} {{/*end*/}} {{if .PinnedPosts}} {{end}}
{{if .Posts}}
{{else}}
{{end}} {{if .IsWelcome}}

Welcome, {{.Username}}!

This is your new blog.

Start writing, or customize your blog.

Check out our writing guide to see what else you can do, and get in touch anytime with questions or feedback.

{{end}} {{if .Flash}}

{{.Flash}}

{{end}} {{template "posts" .}} {{if gt .TotalPages 1}}{{end}} {{if not .IsWelcome}}{{template "emailsubscribe" .}}{{end}} {{if .Posts}}
{{else}}{{end}} {{if .ShowFooterBranding }} {{ end }} {{if .CanShowScript}} {{range .ExternalScripts}}{{end}} {{if .Script}}{{end}} {{end}} {{end}} diff --git a/templates/include/footer.tmpl b/templates/include/footer.tmpl index 8e3dec9..0e6e02b 100644 --- a/templates/include/footer.tmpl +++ b/templates/include/footer.tmpl @@ -1,46 +1,47 @@ {{define "footer"}}
{{if or .SingleUser .WFModesty}} {{else}}
-

{{.SiteName}}

-
{{end}} {{end}} diff --git a/templates/pad.tmpl b/templates/pad.tmpl index 2580dce..49bf93f 100644 --- a/templates/pad.tmpl +++ b/templates/pad.tmpl @@ -1,421 +1,422 @@ {{define "pad"}} {{if .Editing}}Editing {{if .Post.Title}}{{.Post.Title}}{{else}}{{.Post.Id}}{{end}}{{else}}New Post{{end}} — {{.SiteName}} {{if .CustomCSS}}{{end}}
{{if not .SingleUser}}

{{end}}
{{if .Editing}}{{end}}
{{end}} diff --git a/templates/read.tmpl b/templates/read.tmpl index c032970..f7ea3d1 100644 --- a/templates/read.tmpl +++ b/templates/read.tmpl @@ -1,142 +1,144 @@ {{define "head"}}{{.SiteName}} Reader {{if gt .CurrentPage 1}}{{end}} {{if lt .CurrentPage .TotalPages}}{{end}} {{end}} {{define "body-attrs"}}id="collection"{{end}} {{define "content"}}

{{.ContentTitle}}

{{if .SelTopic}}#{{.SelTopic}} posts{{else}}{{.Content}}{{end}}

{{ if gt (len .Posts) 0 }}
- {{range .Posts}}
+ {{range .Posts}}
{{if .Title.String -}}

{{- if .IsPaid}}{{template "paid-badge" .}}{{end -}}

{{- else -}}

{{- if .IsPaid}}{{template "paid-badge" .}}{{end -}}

{{- end}}

{{if .Collection}}from {{.Collection.DisplayTitle}}{{else}}Anonymous{{end}}

{{if .Excerpt}}
{{.Excerpt}}
{{localstr "Read more..." .Language.String}}{{else}}
{{ if not .HTMLContent }}

{{.Content}}

{{ else }}{{.HTMLContent}}{{ end }}
 
{{localstr "Read more..." .Language.String}}{{end}}
{{end}}
{{ else }}

No posts here yet!

{{ end }} {{if gt .TotalPages 1}}{{end}}
{{end}}