mirror of https://github.com/writeas/writefreely
commit
3b58d77e67
@ -0,0 +1,7 @@ |
||||
version: 2 |
||||
updates: |
||||
- package-ecosystem: "gomod" # See documentation for possible values |
||||
directory: "/" # Location of package manifests |
||||
open-pull-requests-limit: 50 |
||||
schedule: |
||||
interval: "monthly" |
@ -1,3 +0,0 @@ |
||||
[submodule "static/js/mathjax"] |
||||
path = static/js/mathjax |
||||
url = https://github.com/mathjax/MathJax.git |
@ -0,0 +1,60 @@ |
||||
/* |
||||
* Copyright © 2020-2021 A Bunch Tell LLC. |
||||
* |
||||
* This file is part of WriteFreely. |
||||
* |
||||
* WriteFreely is free software: you can redistribute it and/or modify |
||||
* it under the terms of the GNU Affero General Public License, included |
||||
* in the LICENSE file in this source code package. |
||||
*/ |
||||
|
||||
package main |
||||
|
||||
import ( |
||||
"github.com/urfave/cli/v2" |
||||
"github.com/writefreely/writefreely" |
||||
) |
||||
|
||||
var ( |
||||
cmdConfig cli.Command = cli.Command{ |
||||
Name: "config", |
||||
Usage: "config management tools", |
||||
Subcommands: []*cli.Command{ |
||||
&cmdConfigGenerate, |
||||
&cmdConfigInteractive, |
||||
}, |
||||
} |
||||
|
||||
cmdConfigGenerate cli.Command = cli.Command{ |
||||
Name: "generate", |
||||
Aliases: []string{"gen"}, |
||||
Usage: "Generate a basic configuration", |
||||
Action: genConfigAction, |
||||
} |
||||
|
||||
cmdConfigInteractive cli.Command = cli.Command{ |
||||
Name: "start", |
||||
Usage: "Interactive configuration process", |
||||
Action: interactiveConfigAction, |
||||
Flags: []cli.Flag{ |
||||
&cli.StringFlag{ |
||||
Name: "sections", |
||||
Value: "server db app", |
||||
Usage: "Which sections of the configuration to go through\n" + |
||||
"valid values of sections flag are any combination of 'server', 'db' and 'app' \n" + |
||||
"example: writefreely config start --sections \"db app\"", |
||||
}, |
||||
}, |
||||
} |
||||
) |
||||
|
||||
func genConfigAction(c *cli.Context) error { |
||||
app := writefreely.NewApp(c.String("c")) |
||||
return writefreely.CreateConfig(app) |
||||
} |
||||
|
||||
func interactiveConfigAction(c *cli.Context) error { |
||||
app := writefreely.NewApp(c.String("c")) |
||||
writefreely.DoConfig(app, c.String("sections")) |
||||
return nil |
||||
} |
@ -0,0 +1,49 @@ |
||||
/* |
||||
* Copyright © 2020-2021 A Bunch Tell LLC. |
||||
* |
||||
* This file is part of WriteFreely. |
||||
* |
||||
* WriteFreely is free software: you can redistribute it and/or modify |
||||
* it under the terms of the GNU Affero General Public License, included |
||||
* in the LICENSE file in this source code package. |
||||
*/ |
||||
|
||||
package main |
||||
|
||||
import ( |
||||
"github.com/urfave/cli/v2" |
||||
"github.com/writefreely/writefreely" |
||||
) |
||||
|
||||
var ( |
||||
cmdDB cli.Command = cli.Command{ |
||||
Name: "db", |
||||
Usage: "db management tools", |
||||
Subcommands: []*cli.Command{ |
||||
&cmdDBInit, |
||||
&cmdDBMigrate, |
||||
}, |
||||
} |
||||
|
||||
cmdDBInit cli.Command = cli.Command{ |
||||
Name: "init", |
||||
Usage: "Initialize Database", |
||||
Action: initDBAction, |
||||
} |
||||
|
||||
cmdDBMigrate cli.Command = cli.Command{ |
||||
Name: "migrate", |
||||
Usage: "Migrate Database", |
||||
Action: migrateDBAction, |
||||
} |
||||
) |
||||
|
||||
func initDBAction(c *cli.Context) error { |
||||
app := writefreely.NewApp(c.String("c")) |
||||
return writefreely.CreateSchema(app) |
||||
} |
||||
|
||||
func migrateDBAction(c *cli.Context) error { |
||||
app := writefreely.NewApp(c.String("c")) |
||||
return writefreely.Migrate(app) |
||||
} |
@ -0,0 +1,38 @@ |
||||
/* |
||||
* Copyright © 2020-2021 A Bunch Tell LLC. |
||||
* |
||||
* This file is part of WriteFreely. |
||||
* |
||||
* WriteFreely is free software: you can redistribute it and/or modify |
||||
* it under the terms of the GNU Affero General Public License, included |
||||
* in the LICENSE file in this source code package. |
||||
*/ |
||||
|
||||
package main |
||||
|
||||
import ( |
||||
"github.com/urfave/cli/v2" |
||||
"github.com/writefreely/writefreely" |
||||
) |
||||
|
||||
var ( |
||||
cmdKeys cli.Command = cli.Command{ |
||||
Name: "keys", |
||||
Usage: "key management tools", |
||||
Subcommands: []*cli.Command{ |
||||
&cmdGenerateKeys, |
||||
}, |
||||
} |
||||
|
||||
cmdGenerateKeys cli.Command = cli.Command{ |
||||
Name: "generate", |
||||
Aliases: []string{"gen"}, |
||||
Usage: "Generate encryption and authentication keys", |
||||
Action: genKeysAction, |
||||
} |
||||
) |
||||
|
||||
func genKeysAction(c *cli.Context) error { |
||||
app := writefreely.NewApp(c.String("c")) |
||||
return writefreely.GenerateKeyFiles(app) |
||||
} |
@ -0,0 +1,96 @@ |
||||
/* |
||||
* Copyright © 2020-2021 A Bunch Tell LLC. |
||||
* |
||||
* This file is part of WriteFreely. |
||||
* |
||||
* WriteFreely is free software: you can redistribute it and/or modify |
||||
* it under the terms of the GNU Affero General Public License, included |
||||
* in the LICENSE file in this source code package. |
||||
*/ |
||||
|
||||
package main |
||||
|
||||
import ( |
||||
"fmt" |
||||
|
||||
"github.com/urfave/cli/v2" |
||||
"github.com/writefreely/writefreely" |
||||
) |
||||
|
||||
var ( |
||||
cmdUser cli.Command = cli.Command{ |
||||
Name: "user", |
||||
Usage: "user management tools", |
||||
Subcommands: []*cli.Command{ |
||||
&cmdAddUser, |
||||
&cmdDelUser, |
||||
&cmdResetPass, |
||||
// TODO: possibly add a user list command
|
||||
}, |
||||
} |
||||
|
||||
cmdAddUser cli.Command = cli.Command{ |
||||
Name: "create", |
||||
Usage: "Add new user", |
||||
Aliases: []string{"a", "add"}, |
||||
Flags: []cli.Flag{ |
||||
&cli.BoolFlag{ |
||||
Name: "admin", |
||||
Value: false, |
||||
Usage: "Create admin user", |
||||
}, |
||||
}, |
||||
Action: addUserAction, |
||||
} |
||||
|
||||
cmdDelUser cli.Command = cli.Command{ |
||||
Name: "delete", |
||||
Usage: "Delete user", |
||||
Aliases: []string{"del", "d"}, |
||||
Action: delUserAction, |
||||
} |
||||
|
||||
cmdResetPass cli.Command = cli.Command{ |
||||
Name: "reset-pass", |
||||
Usage: "Reset user's password", |
||||
Aliases: []string{"resetpass", "reset"}, |
||||
Action: resetPassAction, |
||||
} |
||||
) |
||||
|
||||
func addUserAction(c *cli.Context) error { |
||||
credentials := "" |
||||
if c.NArg() > 0 { |
||||
credentials = c.Args().Get(0) |
||||
} else { |
||||
return fmt.Errorf("No user passed. Example: writefreely user add [USER]:[PASSWORD]") |
||||
} |
||||
username, password, err := parseCredentials(credentials) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
app := writefreely.NewApp(c.String("c")) |
||||
return writefreely.CreateUser(app, username, password, c.Bool("admin")) |
||||
} |
||||
|
||||
func delUserAction(c *cli.Context) error { |
||||
username := "" |
||||
if c.NArg() > 0 { |
||||
username = c.Args().Get(0) |
||||
} else { |
||||
return fmt.Errorf("No user passed. Example: writefreely user delete [USER]") |
||||
} |
||||
app := writefreely.NewApp(c.String("c")) |
||||
return writefreely.DoDeleteAccount(app, username) |
||||
} |
||||
|
||||
func resetPassAction(c *cli.Context) error { |
||||
username := "" |
||||
if c.NArg() > 0 { |
||||
username = c.Args().Get(0) |
||||
} else { |
||||
return fmt.Errorf("No user passed. Example: writefreely user reset-pass [USER]") |
||||
} |
||||
app := writefreely.NewApp(c.String("c")) |
||||
return writefreely.ResetPassword(app, username) |
||||
} |
@ -0,0 +1,48 @@ |
||||
/* |
||||
* Copyright © 2020-2021 A Bunch Tell LLC. |
||||
* |
||||
* This file is part of WriteFreely. |
||||
* |
||||
* WriteFreely is free software: you can redistribute it and/or modify |
||||
* it under the terms of the GNU Affero General Public License, included |
||||
* in the LICENSE file in this source code package. |
||||
*/ |
||||
|
||||
package main |
||||
|
||||
import ( |
||||
"github.com/gorilla/mux" |
||||
"github.com/urfave/cli/v2" |
||||
"github.com/writeas/web-core/log" |
||||
"github.com/writefreely/writefreely" |
||||
) |
||||
|
||||
var ( |
||||
cmdServe cli.Command = cli.Command{ |
||||
Name: "serve", |
||||
Aliases: []string{"web"}, |
||||
Usage: "Run web application", |
||||
Action: serveAction, |
||||
} |
||||
) |
||||
|
||||
func serveAction(c *cli.Context) error { |
||||
// Initialize the application
|
||||
app := writefreely.NewApp(c.String("c")) |
||||
var err error |
||||
log.Info("Starting %s...", writefreely.FormatVersion()) |
||||
app, err = writefreely.Initialize(app, c.Bool("debug")) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
// Set app routes
|
||||
r := mux.NewRouter() |
||||
writefreely.InitRoutes(app, r) |
||||
app.InitStaticRoutes(r) |
||||
|
||||
// Serve the application
|
||||
writefreely.Serve(app, r) |
||||
|
||||
return nil |
||||
} |
@ -1,26 +0,0 @@ |
||||
[server] |
||||
hidden_host = |
||||
port = 8080 |
||||
|
||||
[database] |
||||
type = mysql |
||||
username = root |
||||
password = changeme |
||||
database = writefreely |
||||
host = db |
||||
port = 3306 |
||||
|
||||
[app] |
||||
site_name = WriteFreely Example Blog! |
||||
host = http://localhost:8080 |
||||
theme = write |
||||
disable_js = false |
||||
webfonts = true |
||||
single_user = true |
||||
open_registration = false |
||||
min_username_len = 3 |
||||
max_blogs = 1 |
||||
federation = true |
||||
public_stats = true |
||||
private = false |
||||
|
@ -1,32 +1,47 @@ |
||||
version: "3" |
||||
|
||||
volumes: |
||||
web-keys: |
||||
db-data: |
||||
|
||||
networks: |
||||
external_writefreely: |
||||
internal_writefreely: |
||||
internal: true |
||||
|
||||
services: |
||||
web: |
||||
build: . |
||||
writefreely-web: |
||||
container_name: "writefreely-web" |
||||
image: "writeas/writefreely:latest" |
||||
|
||||
volumes: |
||||
- "web-data:/go/src/app" |
||||
- "./config.ini.example:/go/src/app/config.ini" |
||||
- "web-keys:/go/keys" |
||||
- "./config.ini:/go/config.ini" |
||||
|
||||
networks: |
||||
- "internal_writefreely" |
||||
- "external_writefreely" |
||||
|
||||
ports: |
||||
- "8080:8080" |
||||
networks: |
||||
- writefreely |
||||
|
||||
depends_on: |
||||
- db |
||||
- "writefreely-db" |
||||
|
||||
restart: unless-stopped |
||||
db: |
||||
|
||||
writefreely-db: |
||||
container_name: "writefreely-db" |
||||
image: "mariadb:latest" |
||||
|
||||
volumes: |
||||
- "./schema.sql:/tmp/schema.sql" |
||||
- db-data:/var/lib/mysql/data |
||||
- "db-data:/var/lib/mysql/data" |
||||
|
||||
networks: |
||||
- writefreely |
||||
- "internal_writefreely" |
||||
|
||||
environment: |
||||
- MYSQL_DATABASE=writefreely |
||||
- MYSQL_ROOT_PASSWORD=changeme |
||||
restart: unless-stopped |
||||
|
||||
volumes: |
||||
web-data: |
||||
db-data: |
||||
|
||||
networks: |
||||
writefreely: |
||||
restart: unless-stopped |
||||
|
@ -1,66 +1,49 @@ |
||||
module github.com/writeas/writefreely |
||||
module github.com/writefreely/writefreely |
||||
|
||||
require ( |
||||
github.com/BurntSushi/toml v0.3.1 // indirect |
||||
github.com/alecthomas/gometalinter v3.0.0+incompatible // indirect |
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf // indirect |
||||
github.com/captncraig/cors v0.0.0-20180620154129-376d45073b49 // indirect |
||||
github.com/clbanning/mxj v1.8.4 // indirect |
||||
github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9 // indirect |
||||
github.com/dustin/go-humanize v1.0.0 |
||||
github.com/fatih/color v1.7.0 |
||||
github.com/go-fed/httpsig v0.1.1-0.20190924171022-f4c36041199d // indirect |
||||
github.com/go-sql-driver/mysql v1.4.1 |
||||
github.com/fatih/color v1.10.0 |
||||
github.com/go-sql-driver/mysql v1.6.0 |
||||
github.com/go-test/deep v1.0.1 // indirect |
||||
github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1 // indirect |
||||
github.com/gologme/log v0.0.0-20181207131047-4e5d8ccb38e8 // indirect |
||||
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect |
||||
github.com/gorilla/feeds v1.1.0 |
||||
github.com/gorilla/mux v1.7.0 |
||||
github.com/gorilla/schema v1.0.2 |
||||
github.com/gorilla/feeds v1.1.1 |
||||
github.com/gorilla/mux v1.8.0 |
||||
github.com/gorilla/schema v1.2.0 |
||||
github.com/gorilla/sessions v1.2.0 |
||||
github.com/guregu/null v3.4.0+incompatible |
||||
github.com/hashicorp/go-multierror v1.0.0 |
||||
github.com/guregu/null v3.5.0+incompatible |
||||
github.com/hashicorp/go-multierror v1.1.1 |
||||
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2 |
||||
github.com/jtolds/gls v4.2.1+incompatible // indirect |
||||
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec |
||||
github.com/lunixbochs/vtclean v1.0.0 // indirect |
||||
github.com/manifoldco/promptui v0.3.2 |
||||
github.com/mattn/go-colorable v0.1.0 // indirect |
||||
github.com/mattn/go-sqlite3 v1.10.0 |
||||
github.com/microcosm-cc/bluemonday v1.0.2 |
||||
github.com/mitchellh/go-wordwrap v1.0.0 |
||||
github.com/nicksnyder/go-i18n v1.10.0 // indirect |
||||
github.com/manifoldco/promptui v0.8.0 |
||||
github.com/mattn/go-sqlite3 v1.14.6 |
||||
github.com/microcosm-cc/bluemonday v1.0.5 |
||||
github.com/mitchellh/go-wordwrap v1.0.1 |
||||
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d |
||||
github.com/pelletier/go-toml v1.2.0 // indirect |
||||
github.com/pkg/errors v0.8.1 // indirect |
||||
github.com/prologic/go-gopher v0.0.0-20200721020712-3e11dcff0469 |
||||
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect |
||||
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 // indirect |
||||
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c // indirect |
||||
github.com/stretchr/testify v1.3.0 |
||||
github.com/stretchr/testify v1.7.0 |
||||
github.com/urfave/cli/v2 v2.3.0 |
||||
github.com/writeas/activity v0.1.2 |
||||
github.com/writeas/activityserve v0.0.0-20191115095800-dd6d19cc8b89 |
||||
github.com/writeas/activityserve v0.0.0-20200409150223-d7ab3eaa4481 |
||||
github.com/writeas/go-strip-markdown v2.0.1+incompatible |
||||
github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2 |
||||
github.com/writeas/go-webfinger v1.1.0 |
||||
github.com/writeas/httpsig v1.0.0 |
||||
github.com/writeas/impart v1.1.1-0.20191230230525-d3c45ced010d |
||||
github.com/writeas/import v0.2.0 |
||||
github.com/writeas/impart v1.1.1 |
||||
github.com/writeas/import v0.2.1 |
||||
github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219 |
||||
github.com/writeas/nerds v1.0.0 |
||||
github.com/writeas/saturday v1.7.1 |
||||
github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320 |
||||
github.com/writeas/slug v1.2.0 |
||||
github.com/writeas/web-core v1.2.0 |
||||
github.com/writeas/web-core v1.3.1-0.20210330164422-95a3a717ed8f |
||||
github.com/writefreely/go-nodeinfo v1.2.0 |
||||
golang.org/x/crypto v0.0.0-20190208162236-193df9c0f06f |
||||
golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1 // indirect |
||||
golang.org/x/net v0.0.0-20190206173232-65e2d4e15006 // indirect |
||||
golang.org/x/sys v0.0.0-20190209173611-3b5209105503 // indirect |
||||
golang.org/x/tools v0.0.0-20190208222737-3744606dbb67 // indirect |
||||
google.golang.org/appengine v1.4.0 // indirect |
||||
gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c // indirect |
||||
gopkg.in/ini.v1 v1.41.0 |
||||
gopkg.in/yaml.v2 v2.2.2 // indirect |
||||
src.techknowlogick.com/xgo v0.0.0-20200129005940-d0fae26e014b // indirect |
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 |
||||
golang.org/x/net v0.0.0-20200707034311-ab3426394381 // indirect |
||||
gopkg.in/ini.v1 v1.62.0 |
||||
) |
||||
|
||||
go 1.13 |
||||
|
@ -0,0 +1,156 @@ |
||||
/* |
||||
* Copyright © 2020 A Bunch Tell LLC. |
||||
* |
||||
* This file is part of WriteFreely. |
||||
* |
||||
* WriteFreely is free software: you can redistribute it and/or modify |
||||
* it under the terms of the GNU Affero General Public License, included |
||||
* in the LICENSE file in this source code package. |
||||
*/ |
||||
|
||||
package writefreely |
||||
|
||||
import ( |
||||
"bytes" |
||||
"fmt" |
||||
"io" |
||||
"regexp" |
||||
"strings" |
||||
|
||||
"github.com/prologic/go-gopher" |
||||
"github.com/writeas/web-core/log" |
||||
) |
||||
|
||||
func initGopher(apper Apper) { |
||||
handler := NewWFHandler(apper) |
||||
|
||||
gopher.HandleFunc("/", handler.Gopher(handleGopher)) |
||||
log.Info("Serving on gopher://localhost:%d", apper.App().Config().Server.GopherPort) |
||||
gopher.ListenAndServe(fmt.Sprintf(":%d", apper.App().Config().Server.GopherPort), nil) |
||||
} |
||||
|
||||
// Utility function to strip the URL from the hostname provided by app.cfg.App.Host
|
||||
func stripHostProtocol(app *App) string { |
||||
return string(regexp.MustCompile("^.*://").ReplaceAll([]byte(app.cfg.App.Host), []byte(""))) |
||||
} |
||||
|
||||
func handleGopher(app *App, w gopher.ResponseWriter, r *gopher.Request) error { |
||||
parts := strings.Split(r.Selector, "/") |
||||
if app.cfg.App.SingleUser { |
||||
if parts[1] != "" { |
||||
return handleGopherCollectionPost(app, w, r) |
||||
} |
||||
return handleGopherCollection(app, w, r) |
||||
} |
||||
|
||||
// Show all public collections (a gopher Reader view, essentially)
|
||||
if len(parts) == 3 { |
||||
return handleGopherCollection(app, w, r) |
||||
} |
||||
|
||||
w.WriteInfo(fmt.Sprintf("Welcome to %s", app.cfg.App.SiteName)) |
||||
|
||||
colls, err := app.db.GetPublicCollections(app.cfg.App.Host) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
for _, c := range *colls { |
||||
w.WriteItem(&gopher.Item{ |
||||
Host: stripHostProtocol(app), |
||||
Port: app.cfg.Server.GopherPort, |
||||
Type: gopher.DIRECTORY, |
||||
Description: c.DisplayTitle(), |
||||
Selector: "/" + c.Alias + "/", |
||||
}) |
||||
} |
||||
return w.End() |
||||
} |
||||
|
||||
func handleGopherCollection(app *App, w gopher.ResponseWriter, r *gopher.Request) error { |
||||
var collAlias, slug string |
||||
var c *Collection |
||||
var err error |
||||
var baseSel = "/" |
||||
|
||||
parts := strings.Split(r.Selector, "/") |
||||
if app.cfg.App.SingleUser { |
||||
// sanity check
|
||||
slug = parts[1] |
||||
if slug != "" { |
||||
return handleGopherCollectionPost(app, w, r) |
||||
} |
||||
|
||||
c, err = app.db.GetCollectionByID(1) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
} else { |
||||
collAlias = parts[1] |
||||
slug = parts[2] |
||||
if slug != "" { |
||||
return handleGopherCollectionPost(app, w, r) |
||||
} |
||||
|
||||
c, err = app.db.GetCollection(collAlias) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
baseSel = "/" + c.Alias + "/" |
||||
} |
||||
c.hostName = app.cfg.App.Host |
||||
|
||||
posts, err := app.db.GetPosts(app.cfg, c, 0, false, false, false) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
for _, p := range *posts { |
||||
w.WriteItem(&gopher.Item{ |
||||
Port: app.cfg.Server.GopherPort, |
||||
Host: stripHostProtocol(app), |
||||
Type: gopher.FILE, |
||||
Description: p.CreatedDate() + " - " + p.DisplayTitle(), |
||||
Selector: baseSel + p.Slug.String, |
||||
}) |
||||
} |
||||
return w.End() |
||||
} |
||||
|
||||
func handleGopherCollectionPost(app *App, w gopher.ResponseWriter, r *gopher.Request) error { |
||||
var collAlias, slug string |
||||
var c *Collection |
||||
var err error |
||||
|
||||
parts := strings.Split(r.Selector, "/") |
||||
if app.cfg.App.SingleUser { |
||||
slug = parts[1] |
||||
c, err = app.db.GetCollectionByID(1) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
} else { |
||||
collAlias = parts[1] |
||||
slug = parts[2] |
||||
c, err = app.db.GetCollection(collAlias) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
} |
||||
c.hostName = app.cfg.App.Host |
||||
|
||||
p, err := app.db.GetPost(slug, c.ID) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
b := bytes.Buffer{} |
||||
if p.Title.String != "" { |
||||
b.WriteString(p.Title.String + "\n") |
||||
} |
||||
b.WriteString(p.DisplayDate + "\n\n") |
||||
b.WriteString(p.Content) |
||||
io.Copy(w, &b) |
||||
|
||||
return w.End() |
||||
} |
@ -0,0 +1,91 @@ |
||||
/* |
||||
* Copyright © 2020 A Bunch Tell LLC. |
||||
* |
||||
* This file is part of WriteFreely. |
||||
* |
||||
* WriteFreely is free software: you can redistribute it and/or modify |
||||
* it under the terms of the GNU Affero General Public License, included |
||||
* in the LICENSE file in this source code package. |
||||
*/ |
||||
|
||||
.row.signinbtns { |
||||
justify-content: center; |
||||
font-size: 1em; |
||||
margin-top: 2em; |
||||
margin-bottom: 1em; |
||||
flex-wrap: wrap; |
||||
|
||||
.loginbtn { |
||||
height: 40px; |
||||
margin: 0.5em; |
||||
|
||||
&.btn { |
||||
box-sizing: border-box; |
||||
font-size: 17px; |
||||
white-space: nowrap; |
||||
|
||||
img { |
||||
height: 1.5em; |
||||
vertical-align: middle; |
||||
} |
||||
} |
||||
|
||||
&#writeas-login, &#slack-login { |
||||
img { |
||||
margin-top: -0.2em; |
||||
} |
||||
} |
||||
|
||||
&#gitlab-login { |
||||
background-color: #fc6d26; |
||||
border-color: #fc6d26; |
||||
&:hover { |
||||
background-color: darken(#fc6d26, 5%); |
||||
border-color: darken(#fc6d26, 5%); |
||||
} |
||||
} |
||||
|
||||
&#gitea-login { |
||||
background-color: #2ecc71; |
||||
border-color: #2ecc71; |
||||
&:hover { |
||||
background-color: #2cc26b; |
||||
border-color: #2cc26b; |
||||
} |
||||
} |
||||
|
||||
&#slack-login, &#gitlab-login, &#gitea-login, &#generic-oauth-login { |
||||
font-size: 0.86em; |
||||
font-family: @sansFont; |
||||
} |
||||
|
||||
&#slack-login, &#generic-oauth-login { |
||||
color: @lightTextColor; |
||||
background-color: @lightNavBG; |
||||
border-color: @lightNavBorder; |
||||
&:hover { |
||||
background-color: @lightNavHoverBG; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
.or { |
||||
text-align: center; |
||||
margin-bottom: 3.5em; |
||||
|
||||
p { |
||||
display: inline-block; |
||||
background-color: white; |
||||
padding: 0 1em; |
||||
} |
||||
|
||||
hr { |
||||
margin-top: -1.6em; |
||||
margin-bottom: 0; |
||||
} |
||||
|
||||
hr.short { |
||||
max-width: 30rem; |
||||
} |
||||
} |
@ -0,0 +1,450 @@ |
||||
body#pad.classic { |
||||
header { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
} |
||||
#editor { |
||||
top: 4em; |
||||
} |
||||
#title { |
||||
top: 4.25rem; |
||||
bottom: unset; |
||||
height: auto; |
||||
font-weight: bold; |
||||
font-size: 2em; |
||||
padding-top: 0; |
||||
padding-bottom: 0; |
||||
border: 0; |
||||
} |
||||
#tools { |
||||
#belt { |
||||
float: none; |
||||
} |
||||
} |
||||
#target { |
||||
ul { |
||||
a { |
||||
padding: 0 0.5em !important; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
.ProseMirror { |
||||
position: relative; |
||||
height: calc(~"100% - 1.6em"); |
||||
overflow-y: auto; |
||||
box-sizing: border-box; |
||||
-moz-box-sizing: border-box; |
||||
font-size: 1.2em; |
||||
word-wrap: break-word; |
||||
white-space: pre-wrap; |
||||
-webkit-font-variant-ligatures: none; |
||||
font-variant-ligatures: none; |
||||
padding: 0.5em 0; |
||||
line-height: 1.5; |
||||
outline: none; |
||||
} |
||||
|
||||
.ProseMirror pre { |
||||
white-space: pre-wrap; |
||||
} |
||||
|
||||
.ProseMirror li { |
||||
position: relative; |
||||
} |
||||
|
||||
.ProseMirror-hideselection *::selection { |
||||
background: transparent; |
||||
} |
||||
|
||||
.ProseMirror-hideselection *::-moz-selection { |
||||
background: transparent; |
||||
} |
||||
|
||||
.ProseMirror-hideselection { |
||||
caret-color: transparent; |
||||
} |
||||
|
||||
.ProseMirror-selectednode { |
||||
outline: 2px solid #8cf; |
||||
} |
||||
|
||||
/* Make sure li selections wrap around markers */ |
||||
|
||||
li.ProseMirror-selectednode { |
||||
outline: none; |
||||
} |
||||
|
||||
li.ProseMirror-selectednode:after { |
||||
content: ""; |
||||
position: absolute; |
||||
left: -32px; |
||||
right: -2px; |
||||
top: -2px; |
||||
bottom: -2px; |
||||
border: 2px solid #8cf; |
||||
pointer-events: none; |
||||
} |
||||
|
||||
.ProseMirror-textblock-dropdown { |
||||
min-width: 3em; |
||||
} |
||||
|
||||
.ProseMirror-menu { |
||||
margin: 0 -4px; |
||||
line-height: 1; |
||||
} |
||||
|
||||
.ProseMirror-tooltip .ProseMirror-menu { |
||||
width: -webkit-fit-content; |
||||
width: fit-content; |
||||
white-space: pre; |
||||
} |
||||
|
||||
.ProseMirror-menuitem { |
||||
margin-right: 3px; |
||||
display: inline-block; |
||||
div { |
||||
cursor: pointer; |
||||
} |
||||
} |
||||
|
||||
.ProseMirror-menuseparator { |
||||
border-right: 1px solid #ddd; |
||||
margin-right: 3px; |
||||
} |
||||
|
||||
.ProseMirror-menu-dropdown, .ProseMirror-menu-dropdown-menu { |
||||
font-size: 90%; |
||||
white-space: nowrap; |
||||
} |
||||
|
||||
.ProseMirror-menu-dropdown { |
||||
vertical-align: 1px; |
||||
cursor: pointer; |
||||
position: relative; |
||||
padding-right: 15px; |
||||
} |
||||
|
||||
.ProseMirror-menu-dropdown-wrap { |
||||
padding: 1px 0 1px 4px; |
||||
display: inline-block; |
||||
position: relative; |
||||
} |
||||
|
||||
.ProseMirror-menu-dropdown:after { |
||||
content: ""; |
||||
border-left: 4px solid transparent; |
||||
border-right: 4px solid transparent; |
||||
border-top: 4px solid currentColor; |
||||
opacity: .6; |
||||
position: absolute; |
||||
right: 4px; |
||||
top: calc(50% - 2px); |
||||
} |
||||
|
||||
.ProseMirror-menu-dropdown-menu, .ProseMirror-menu-submenu { |
||||
position: absolute; |
||||
background: white; |
||||
color: #666; |
||||
border: 1px solid #aaa; |
||||
padding: 2px; |
||||
} |
||||
|
||||
.ProseMirror-menu-dropdown-menu { |
||||
z-index: 15; |
||||
min-width: 6em; |
||||
} |
||||
|
||||
.ProseMirror-menu-dropdown-item { |
||||
cursor: pointer; |
||||
padding: 2px 8px 2px 4px; |
||||
} |
||||
|
||||
.ProseMirror-menu-dropdown-item:hover { |
||||
background: #f2f2f2; |
||||
} |
||||
|
||||
.ProseMirror-menu-submenu-wrap { |
||||
position: relative; |
||||
margin-right: -4px; |
||||
} |
||||
|
||||
.ProseMirror-menu-submenu-label:after { |
||||
content: ""; |
||||
border-top: 4px solid transparent; |
||||
border-bottom: 4px solid transparent; |
||||
border-left: 4px solid currentColor; |
||||
opacity: .6; |
||||
position: absolute; |
||||
right: 4px; |
||||
top: calc(50% - 4px); |
||||
} |
||||
|
||||
.ProseMirror-menu-submenu { |
||||
display: none; |
||||
min-width: 4em; |
||||
left: 100%; |
||||
top: -3px; |
||||
} |
||||
|
||||
.ProseMirror-menu-active { |
||||
background: #eee; |
||||
border-radius: 4px; |
||||
} |
||||
|
||||
.ProseMirror-menu-active { |
||||
background: #eee; |
||||
border-radius: 4px; |
||||
} |
||||
|
||||
.ProseMirror-menu-disabled { |
||||
opacity: .3; |
||||
} |
||||
|
||||
.ProseMirror-menu-submenu-wrap:hover .ProseMirror-menu-submenu, .ProseMirror-menu-submenu-wrap-active .ProseMirror-menu-submenu { |
||||
display: block; |
||||
} |
||||
|
||||
.ProseMirror-menubar { |
||||
position: relative; |
||||
min-height: 1em; |
||||
color: #666; |
||||
padding: 0.5em; |
||||
top: 0; |
||||
left: 0; |
||||
right: 0; |
||||
background: rgba(255, 255, 255, 0.8); |
||||
z-index: 10; |
||||
-moz-box-sizing: border-box; |
||||
box-sizing: border-box; |
||||
overflow: visible; |
||||
} |
||||
|
||||
.ProseMirror-icon { |
||||
display: inline-block; |
||||
line-height: .8; |
||||
vertical-align: -2px; /* Compensate for padding */ |
||||
padding: 2px 8px; |
||||
cursor: pointer; |
||||
} |
||||
|
||||
.ProseMirror-menu-disabled.ProseMirror-icon { |
||||
cursor: default; |
||||
} |
||||
|
||||
.ProseMirror-icon svg { |
||||
fill: currentColor; |
||||
height: 1em; |
||||
} |
||||
|
||||
.ProseMirror-icon span { |
||||
vertical-align: text-top; |
||||
} |
||||
|
||||
.ProseMirror-gapcursor { |
||||
display: none; |
||||
pointer-events: none; |
||||
position: absolute; |
||||
} |
||||
|
||||
.ProseMirror-gapcursor:after { |
||||
content: ""; |
||||
display: block; |
||||
position: absolute; |
||||
top: -2px; |
||||
width: 20px; |
||||
border-top: 1px solid black; |
||||
animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite; |
||||
} |
||||
|
||||
@keyframes ProseMirror-cursor-blink { |
||||
to { |
||||
visibility: hidden; |
||||
} |
||||
} |
||||
|
||||
.ProseMirror-focused .ProseMirror-gapcursor { |
||||
display: block; |
||||
} |
||||
|
||||
/* Add space around the hr to make clicking it easier */ |
||||
|
||||
.ProseMirror-example-setup-style hr { |
||||
padding: 2px 10px; |
||||
border: none; |
||||
margin: 1em 0; |
||||
} |
||||
|
||||
.ProseMirror-example-setup-style hr:after { |
||||
content: ""; |
||||
display: block; |
||||
height: 1px; |
||||
background-color: silver; |
||||
line-height: 2px; |
||||
} |
||||
|
||||
.ProseMirror ul, .ProseMirror ol { |
||||
padding-left: 30px; |
||||
} |
||||
|
||||
.ProseMirror blockquote { |
||||
padding-left: 1em; |
||||
border-left: 3px solid #eee; |
||||
margin-left: 0; |
||||
margin-right: 0; |
||||
} |
||||
|
||||
.ProseMirror-example-setup-style img { |
||||
cursor: default; |
||||
} |
||||
|
||||
.ProseMirror-prompt { |
||||
background: white; |
||||
padding: 1em; |
||||
border: 1px solid silver; |
||||
position: fixed; |
||||
border-radius: 0.25em; |
||||
z-index: 11; |
||||
box-shadow: -.5px 2px 5px rgba(0, 0, 0, .2); |
||||
} |
||||
|
||||
.ProseMirror-prompt h5 { |
||||
margin: 0 0 0.75em; |
||||
font-family: @sansFont; |
||||
font-size: 100%; |
||||
color: #444; |
||||
} |
||||
|
||||
.ProseMirror-prompt input[type="text"], |
||||
.ProseMirror-prompt textarea { |
||||
background: #eee; |
||||
border: none; |
||||
outline: none; |
||||
} |
||||
|
||||
.ProseMirror-prompt input[type="text"] { |
||||
margin: 0.25em 0; |
||||
} |
||||
|
||||
.ProseMirror-prompt-close { |
||||
position: absolute; |
||||
left: 2px; |
||||
top: 1px; |
||||
color: #666; |
||||
border: none; |
||||
background: transparent; |
||||
padding: 0; |
||||
} |
||||
|
||||
.ProseMirror-prompt-close:after { |
||||
content: "✕"; |
||||
font-size: 12px; |
||||
} |
||||
|
||||
.ProseMirror-invalid { |
||||
background: #ffc; |
||||
border: 1px solid #cc7; |
||||
border-radius: 4px; |
||||
padding: 5px 10px; |
||||
position: absolute; |
||||
min-width: 10em; |
||||
} |
||||
|
||||
.ProseMirror-prompt-buttons { |
||||
margin-top: 5px; |
||||
display: none; |
||||
} |
||||
|
||||
#editor, .editor { |
||||
position: fixed; |
||||
top: 0; |
||||
right: 0; |
||||
bottom: 0; |
||||
left: 0; |
||||
color: black; |
||||
background-clip: padding-box; |
||||
padding: 5px 0; |
||||
margin: 4em auto 23px auto; |
||||
} |
||||
|
||||
.ProseMirror p:first-child, |
||||
.ProseMirror h1:first-child, |
||||
.ProseMirror h2:first-child, |
||||
.ProseMirror h3:first-child, |
||||
.ProseMirror h4:first-child, |
||||
.ProseMirror h5:first-child, |
||||
.ProseMirror h6:first-child { |
||||
margin-top: 10px; |
||||
} |
||||
|
||||
.ProseMirror p { |
||||
margin-bottom: 1em; |
||||
} |
||||
|
||||
textarea { |
||||
width: 100%; |
||||
height: 123px; |
||||
border: 1px solid silver; |
||||
box-sizing: border-box; |
||||
-moz-box-sizing: border-box; |
||||
padding: 3px 10px; |
||||
border: none; |
||||
outline: none; |
||||
font-family: inherit; |
||||
font-size: inherit; |
||||
} |
||||
|
||||
.ProseMirror-menubar-wrapper { |
||||
height: 100%; |
||||
box-sizing: border-box; |
||||
} |
||||
|
||||
.ProseMirror-menubar-wrapper, #markdown textarea { |
||||
display: block; |
||||
margin-bottom: 4px; |
||||
} |
||||
|
||||
.editorreadmore { |
||||
color: @textLinkColor; |
||||
text-decoration: underline; |
||||
text-align: center; |
||||
width: 100%; |
||||
} |
||||
|
||||
@media all and (min-width: 50em) { |
||||
#editor { |
||||
margin-left: 10%; |
||||
margin-right: 10%; |
||||
} |
||||
} |
||||
|
||||
@media all and (min-width: 60em) { |
||||
#editor { |
||||
margin-left: 15%; |
||||
margin-right: 15%; |
||||
} |
||||
} |
||||
|
||||
@media all and (min-width: 70em) { |
||||
#editor { |
||||
margin-left: 20%; |
||||
margin-right: 20%; |
||||
} |
||||
} |
||||
|
||||
@media all and (min-width: 85em) { |
||||
#editor { |
||||
margin-left: 25%; |
||||
margin-right: 25%; |
||||
} |
||||
} |
||||
|
||||
@media all and (min-width: 105em) { |
||||
#editor { |
||||
margin-left: 30%; |
||||
margin-right: 30%; |
||||
} |
||||
} |
@ -0,0 +1,4 @@ |
||||
@import "prose-editor"; |
||||
@import "pad-theme"; |
||||
@import "resources"; |
||||
@import "lib/elements"; |
@ -0,0 +1,13 @@ |
||||
@primary: rgb(114, 120, 191); |
||||
@secondary: rgb(114, 191, 133); |
||||
@subheaders: #444; |
||||
@headerTextColor: black; |
||||
@sansFont: 'Open Sans', 'Segoe UI', Tahoma, Arial, sans-serif; |
||||
@serifFont: Lora, 'Palatino Linotype', 'Book Antiqua', 'New York', 'DejaVu serif', serif; |
||||
@monoFont: Hack, consolas, Menlo-Regular, Menlo, Monaco, 'ubuntu mono', monospace, monospace; |
||||
@dangerCol: #e21d27; |
||||
@errUrgentCol: #ecc63c; |
||||
@proSelectedCol: #71D571; |
||||
@textLinkColor: rgb(0, 0, 238); |
||||
|
||||
@accent: #767676; |
@ -0,0 +1,33 @@ |
||||
/* |
||||
* Copyright © 2020 A Bunch Tell LLC. |
||||
* |
||||
* This file is part of WriteFreely. |
||||
* |
||||
* WriteFreely is free software: you can redistribute it and/or modify |
||||
* it under the terms of the GNU Affero General Public License, included |
||||
* in the LICENSE file in this source code package. |
||||
*/ |
||||
|
||||
package migrations |
||||
|
||||
func supportPostSignatures(db *datastore) error { |
||||
t, err := db.Begin() |
||||
if err != nil { |
||||
t.Rollback() |
||||
return err |
||||
} |
||||
|
||||
_, err = t.Exec(`ALTER TABLE collections ADD COLUMN post_signature ` + db.typeText() + db.collateMultiByte() + ` NULL` + db.after("script")) |
||||
if err != nil { |
||||
t.Rollback() |
||||
return err |
||||
} |
||||
|
||||
err = t.Commit() |
||||
if err != nil { |
||||
t.Rollback() |
||||
return err |
||||
} |
||||
|
||||
return nil |
||||
} |
@ -0,0 +1,46 @@ |
||||
/* |
||||
* Copyright © 2020-2021 A Bunch Tell LLC. |
||||
* |
||||
* This file is part of WriteFreely. |
||||
* |
||||
* WriteFreely is free software: you can redistribute it and/or modify |
||||
* it under the terms of the GNU Affero General Public License, included |
||||
* in the LICENSE file in this source code package. |
||||
*/ |
||||
|
||||
package migrations |
||||
|
||||
import ( |
||||
"context" |
||||
"database/sql" |
||||
|
||||
wf_db "github.com/writefreely/writefreely/db" |
||||
) |
||||
|
||||
func oauthAttach(db *datastore) error { |
||||
dialect := wf_db.DialectMySQL |
||||
if db.driverName == driverSQLite { |
||||
dialect = wf_db.DialectSQLite |
||||
} |
||||
return wf_db.RunTransactionWithOptions(context.Background(), db.DB, &sql.TxOptions{}, func(ctx context.Context, tx *sql.Tx) error { |
||||
builders := []wf_db.SQLBuilder{ |
||||
dialect. |
||||
AlterTable("oauth_client_states"). |
||||
AddColumn(dialect. |
||||
Column( |
||||
"attach_user_id", |
||||
wf_db.ColumnTypeInteger, |
||||
wf_db.OptionalInt{Set: true, Value: 24}).SetNullable(true)), |
||||
} |
||||
for _, builder := range builders { |
||||
query, err := builder.ToSQL() |
||||
if err != nil { |
||||
return err |
||||
} |
||||
if _, err := tx.ExecContext(ctx, query); err != nil { |
||||
return err |
||||
} |
||||
} |
||||
return nil |
||||
}) |
||||
} |
@ -0,0 +1,45 @@ |
||||
/* |
||||
* Copyright © 2020-2021 A Bunch Tell LLC. |
||||
* |
||||
* This file is part of WriteFreely. |
||||
* |
||||
* WriteFreely is free software: you can redistribute it and/or modify |
||||
* it under the terms of the GNU Affero General Public License, included |
||||
* in the LICENSE file in this source code package. |
||||
*/ |
||||
|
||||
package migrations |
||||
|
||||
import ( |
||||
"context" |
||||
"database/sql" |
||||
|
||||
wf_db "github.com/writefreely/writefreely/db" |
||||
) |
||||
|
||||
func oauthInvites(db *datastore) error { |
||||
dialect := wf_db.DialectMySQL |
||||
if db.driverName == driverSQLite { |
||||
dialect = wf_db.DialectSQLite |
||||
} |
||||
return wf_db.RunTransactionWithOptions(context.Background(), db.DB, &sql.TxOptions{}, func(ctx context.Context, tx *sql.Tx) error { |
||||
builders := []wf_db.SQLBuilder{ |
||||
dialect. |
||||
AlterTable("oauth_client_states"). |
||||
AddColumn(dialect.Column("invite_code", wf_db.ColumnTypeChar, wf_db.OptionalInt{ |
||||
Set: true, |
||||
Value: 6, |
||||
}).SetNullable(true)), |
||||
} |
||||
for _, builder := range builders { |
||||
query, err := builder.ToSQL() |
||||
if err != nil { |
||||
return err |
||||
} |
||||
if _, err := tx.ExecContext(ctx, query); err != nil { |
||||
return err |
||||
} |
||||
} |
||||
return nil |
||||
}) |
||||
} |
@ -0,0 +1,37 @@ |
||||
/* |
||||
* Copyright © 2020 A Bunch Tell LLC. |
||||
* |
||||
* This file is part of WriteFreely. |
||||
* |
||||
* WriteFreely is free software: you can redistribute it and/or modify |
||||
* it under the terms of the GNU Affero General Public License, included |
||||
* in the LICENSE file in this source code package. |
||||
*/ |
||||
|
||||
package migrations |
||||
|
||||
func optimizeDrafts(db *datastore) error { |
||||
t, err := db.Begin() |
||||
if err != nil { |
||||
t.Rollback() |
||||
return err |
||||
} |
||||
|
||||
if db.driverName == driverSQLite { |
||||
_, err = t.Exec(`CREATE INDEX key_owner_post_id ON posts (owner_id, id)`) |
||||
} else { |
||||
_, err = t.Exec(`ALTER TABLE posts ADD INDEX(owner_id, id)`) |
||||
} |
||||
if err != nil { |
||||
t.Rollback() |
||||
return err |
||||
} |
||||
|
||||
err = t.Commit() |
||||
if err != nil { |
||||
t.Rollback() |
||||
return err |
||||
} |
||||
|
||||
return nil |
||||
} |
@ -0,0 +1,126 @@ |
||||
package writefreely |
||||
|
||||
import ( |
||||
"context" |
||||
"errors" |
||||
"net/http" |
||||
"net/url" |
||||
"strings" |
||||
) |
||||
|
||||
type genericOauthClient struct { |
||||
ClientID string |
||||
ClientSecret string |
||||
AuthLocation string |
||||
ExchangeLocation string |
||||
InspectLocation string |
||||
CallbackLocation string |
||||
Scope string |
||||
MapUserID string |
||||
MapUsername string |
||||
MapDisplayName string |
||||
MapEmail string |
||||
HttpClient HttpClient |
||||
} |
||||
|
||||
var _ oauthClient = genericOauthClient{} |
||||
|
||||
const ( |
||||
genericOauthDisplayName = "OAuth" |
||||
) |
||||
|
||||
func (c genericOauthClient) GetProvider() string { |
||||
return "generic" |
||||
} |
||||
|
||||
func (c genericOauthClient) GetClientID() string { |
||||
return c.ClientID |
||||
} |
||||
|
||||
func (c genericOauthClient) GetCallbackLocation() string { |
||||
return c.CallbackLocation |
||||
} |
||||
|
||||
func (c genericOauthClient) buildLoginURL(state string) (string, error) { |
||||
u, err := url.Parse(c.AuthLocation) |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
q := u.Query() |
||||
q.Set("client_id", c.ClientID) |
||||
q.Set("redirect_uri", c.CallbackLocation) |
||||
q.Set("response_type", "code") |
||||
q.Set("state", state) |
||||
q.Set("scope", c.Scope) |
||||
u.RawQuery = q.Encode() |
||||
return u.String(), nil |
||||
} |
||||
|
||||
func (c genericOauthClient) exchangeOauthCode(ctx context.Context, code string) (*TokenResponse, error) { |
||||
form := url.Values{} |
||||
form.Add("grant_type", "authorization_code") |
||||
form.Add("redirect_uri", c.CallbackLocation) |
||||
form.Add("scope", c.Scope) |
||||
form.Add("code", code) |
||||
req, err := http.NewRequest("POST", c.ExchangeLocation, strings.NewReader(form.Encode())) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
req.WithContext(ctx) |
||||
req.Header.Set("User-Agent", ServerUserAgent("")) |
||||
req.Header.Set("Accept", "application/json") |
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
||||
req.SetBasicAuth(c.ClientID, c.ClientSecret) |
||||
|
||||
resp, err := c.HttpClient.Do(req) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
if resp.StatusCode != http.StatusOK { |
||||
return nil, errors.New("unable to exchange code for access token") |
||||
} |
||||
|
||||
var tokenResponse TokenResponse |
||||
if err := limitedJsonUnmarshal(resp.Body, tokenRequestMaxLen, &tokenResponse); err != nil { |
||||
return nil, err |
||||
} |
||||
if tokenResponse.Error != "" { |
||||
return nil, errors.New(tokenResponse.Error) |
||||
} |
||||
return &tokenResponse, nil |
||||
} |
||||
|
||||
func (c genericOauthClient) inspectOauthAccessToken(ctx context.Context, accessToken string) (*InspectResponse, error) { |
||||
req, err := http.NewRequest("GET", c.InspectLocation, nil) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
req.WithContext(ctx) |
||||
req.Header.Set("User-Agent", ServerUserAgent("")) |
||||
req.Header.Set("Accept", "application/json") |
||||
req.Header.Set("Authorization", "Bearer "+accessToken) |
||||
|
||||
resp, err := c.HttpClient.Do(req) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
if resp.StatusCode != http.StatusOK { |
||||
return nil, errors.New("unable to inspect access token") |
||||
} |
||||
|
||||
// since we don't know what the JSON from the server will look like, we create a
|
||||
// generic interface and then map manually to values set in the config
|
||||
var genericInterface map[string]interface{} |
||||
if err := limitedJsonUnmarshal(resp.Body, infoRequestMaxLen, &genericInterface); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
// map each relevant field in inspectResponse to the mapped field from the config
|
||||
var inspectResponse InspectResponse |
||||
inspectResponse.UserID, _ = genericInterface[c.MapUserID].(string) |
||||
inspectResponse.Username, _ = genericInterface[c.MapUsername].(string) |
||||
inspectResponse.DisplayName, _ = genericInterface[c.MapDisplayName].(string) |
||||
inspectResponse.Email, _ = genericInterface[c.MapEmail].(string) |
||||
|
||||
return &inspectResponse, nil |
||||
} |
@ -0,0 +1,114 @@ |
||||
package writefreely |
||||
|
||||
import ( |
||||
"context" |
||||
"errors" |
||||
"net/http" |
||||
"net/url" |
||||
"strings" |
||||
) |
||||
|
||||
type giteaOauthClient struct { |
||||
ClientID string |
||||
ClientSecret string |
||||
AuthLocation string |
||||
ExchangeLocation string |
||||
InspectLocation string |
||||
CallbackLocation string |
||||
HttpClient HttpClient |
||||
} |
||||
|
||||
var _ oauthClient = giteaOauthClient{} |
||||
|
||||
const ( |
||||
giteaDisplayName = "Gitea" |
||||
) |
||||
|
||||
func (c giteaOauthClient) GetProvider() string { |
||||
return "gitea" |
||||
} |
||||
|
||||
func (c giteaOauthClient) GetClientID() string { |
||||
return c.ClientID |
||||
} |
||||
|
||||
func (c giteaOauthClient) GetCallbackLocation() string { |
||||
return c.CallbackLocation |
||||
} |
||||
|
||||
func (c giteaOauthClient) buildLoginURL(state string) (string, error) { |
||||
u, err := url.Parse(c.AuthLocation) |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
q := u.Query() |
||||
q.Set("client_id", c.ClientID) |
||||
q.Set("redirect_uri", c.CallbackLocation) |
||||
q.Set("response_type", "code") |
||||
q.Set("state", state) |
||||
// q.Set("scope", "read_user")
|
||||
u.RawQuery = q.Encode() |
||||
return u.String(), nil |
||||
} |
||||
|
||||
func (c giteaOauthClient) exchangeOauthCode(ctx context.Context, code string) (*TokenResponse, error) { |
||||
form := url.Values{} |
||||
form.Add("grant_type", "authorization_code") |
||||
form.Add("redirect_uri", c.CallbackLocation) |
||||
// form.Add("scope", "read_user")
|
||||
form.Add("code", code) |
||||
req, err := http.NewRequest("POST", c.ExchangeLocation, strings.NewReader(form.Encode())) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
req.WithContext(ctx) |
||||
req.Header.Set("User-Agent", ServerUserAgent("")) |
||||
req.Header.Set("Accept", "application/json") |
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
||||
req.SetBasicAuth(c.ClientID, c.ClientSecret) |
||||
|
||||
resp, err := c.HttpClient.Do(req) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
if resp.StatusCode != http.StatusOK { |
||||
return nil, errors.New("unable to exchange code for access token") |
||||
} |
||||
|
||||
var tokenResponse TokenResponse |
||||
if err := limitedJsonUnmarshal(resp.Body, tokenRequestMaxLen, &tokenResponse); err != nil { |
||||
return nil, err |
||||
} |
||||
if tokenResponse.Error != "" { |
||||
return nil, errors.New(tokenResponse.Error) |
||||
} |
||||
return &tokenResponse, nil |
||||
} |
||||
|
||||
func (c giteaOauthClient) inspectOauthAccessToken(ctx context.Context, accessToken string) (*InspectResponse, error) { |
||||
req, err := http.NewRequest("GET", c.InspectLocation, nil) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
req.WithContext(ctx) |
||||
req.Header.Set("User-Agent", ServerUserAgent("")) |
||||
req.Header.Set("Accept", "application/json") |
||||
req.Header.Set("Authorization", "Bearer "+accessToken) |
||||
|
||||
resp, err := c.HttpClient.Do(req) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
if resp.StatusCode != http.StatusOK { |
||||
return nil, errors.New("unable to inspect access token") |
||||
} |
||||
|
||||
var inspectResponse InspectResponse |
||||
if err := limitedJsonUnmarshal(resp.Body, infoRequestMaxLen, &inspectResponse); err != nil { |
||||
return nil, err |
||||
} |
||||
if inspectResponse.Error != "" { |
||||
return nil, errors.New(inspectResponse.Error) |
||||
} |
||||
return &inspectResponse, nil |
||||
} |
@ -0,0 +1,115 @@ |
||||
package writefreely |
||||
|
||||
import ( |
||||
"context" |
||||
"errors" |
||||
"net/http" |
||||
"net/url" |
||||
"strings" |
||||
) |
||||
|
||||
type gitlabOauthClient struct { |
||||
ClientID string |
||||
ClientSecret string |
||||
AuthLocation string |
||||
ExchangeLocation string |
||||
InspectLocation string |
||||
CallbackLocation string |
||||
HttpClient HttpClient |
||||
} |
||||
|
||||
var _ oauthClient = gitlabOauthClient{} |
||||
|
||||
const ( |
||||
gitlabHost = "https://gitlab.com" |
||||
gitlabDisplayName = "GitLab" |
||||
) |
||||
|
||||
func (c gitlabOauthClient) GetProvider() string { |
||||
return "gitlab" |
||||
} |
||||
|
||||
func (c gitlabOauthClient) GetClientID() string { |
||||
return c.ClientID |
||||
} |
||||
|
||||
func (c gitlabOauthClient) GetCallbackLocation() string { |
||||
return c.CallbackLocation |
||||
} |
||||
|
||||
func (c gitlabOauthClient) buildLoginURL(state string) (string, error) { |
||||
u, err := url.Parse(c.AuthLocation) |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
q := u.Query() |
||||
q.Set("client_id", c.ClientID) |
||||
q.Set("redirect_uri", c.CallbackLocation) |
||||
q.Set("response_type", "code") |
||||
q.Set("state", state) |
||||
q.Set("scope", "read_user") |
||||
u.RawQuery = q.Encode() |
||||
return u.String(), nil |
||||
} |
||||
|
||||
func (c gitlabOauthClient) exchangeOauthCode(ctx context.Context, code string) (*TokenResponse, error) { |
||||
form := url.Values{} |
||||
form.Add("grant_type", "authorization_code") |
||||
form.Add("redirect_uri", c.CallbackLocation) |
||||
form.Add("scope", "read_user") |
||||
form.Add("code", code) |
||||
req, err := http.NewRequest("POST", c.ExchangeLocation, strings.NewReader(form.Encode())) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
req.WithContext(ctx) |
||||
req.Header.Set("User-Agent", ServerUserAgent("")) |
||||
req.Header.Set("Accept", "application/json") |
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
||||
req.SetBasicAuth(c.ClientID, c.ClientSecret) |
||||
|
||||
resp, err := c.HttpClient.Do(req) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
if resp.StatusCode != http.StatusOK { |
||||
return nil, errors.New("unable to exchange code for access token") |
||||
} |
||||
|
||||
var tokenResponse TokenResponse |
||||
if err := limitedJsonUnmarshal(resp.Body, tokenRequestMaxLen, &tokenResponse); err != nil { |
||||
return nil, err |
||||
} |
||||
if tokenResponse.Error != "" { |
||||
return nil, errors.New(tokenResponse.Error) |
||||
} |
||||
return &tokenResponse, nil |
||||
} |
||||
|
||||
func (c gitlabOauthClient) inspectOauthAccessToken(ctx context.Context, accessToken string) (*InspectResponse, error) { |
||||
req, err := http.NewRequest("GET", c.InspectLocation, nil) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
req.WithContext(ctx) |
||||
req.Header.Set("User-Agent", ServerUserAgent("")) |
||||
req.Header.Set("Accept", "application/json") |
||||
req.Header.Set("Authorization", "Bearer "+accessToken) |
||||
|
||||
resp, err := c.HttpClient.Do(req) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
if resp.StatusCode != http.StatusOK { |
||||
return nil, errors.New("unable to inspect access token") |
||||
} |
||||
|
||||
var inspectResponse InspectResponse |
||||
if err := limitedJsonUnmarshal(resp.Body, infoRequestMaxLen, &inspectResponse); err != nil { |
||||
return nil, err |
||||
} |
||||
if inspectResponse.Error != "" { |
||||
return nil, errors.New(inspectResponse.Error) |
||||
} |
||||
return &inspectResponse, nil |
||||
} |
@ -0,0 +1,7 @@ |
||||
{{define "head"}}<title>Temporarily Unavailable — {{.SiteMetaName}}</title>{{end}} |
||||
{{define "content"}} |
||||
<div class="error-page"> |
||||
<p class="msg">The words aren't coming to me. 🗅</p> |
||||
<p>We couldn't serve this page due to high server load. This should only be temporary.</p> |
||||
</div> |
||||
{{end}} |
@ -0,0 +1,45 @@ |
||||
/* |
||||
* Copyright © 2020-2021 A Bunch Tell LLC. |
||||
* |
||||
* This file is part of WriteFreely. |
||||
* |
||||
* WriteFreely is free software: you can redistribute it and/or modify |
||||
* it under the terms of the GNU Affero General Public License, included |
||||
* in the LICENSE file in this source code package. |
||||
*/ |
||||
|
||||
package writefreely_test |
||||
|
||||
import ( |
||||
"testing" |
||||
|
||||
"github.com/guregu/null/zero" |
||||
"github.com/stretchr/testify/assert" |
||||
"github.com/writefreely/writefreely" |
||||
) |
||||
|
||||
func TestPostSummary(t *testing.T) { |
||||
testCases := map[string]struct { |
||||
given writefreely.Post |
||||
expected string |
||||
}{ |
||||
"no special chars": {givenPost("Content."), "Content."}, |
||||
"HTML content": {givenPost("Content <p>with a</p> paragraph."), "Content with a paragraph."}, |
||||
"content with escaped char": {givenPost("Content's all OK."), "Content's all OK."}, |
||||
"multiline content": {givenPost(`Content |
||||
in |
||||
multiple |
||||
lines.`), "Content in multiple lines."}, |
||||
} |
||||
|
||||
for name, test := range testCases { |
||||
t.Run(name, func(t *testing.T) { |
||||
actual := test.given.Summary() |
||||
assert.Equal(t, test.expected, actual) |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func givenPost(content string) writefreely.Post { |
||||
return writefreely.Post{Title: zero.StringFrom("Title"), Content: content} |
||||
} |
@ -0,0 +1,8 @@ |
||||
module.exports = { |
||||
"presets": [ |
||||
["@babel/env", { |
||||
"modules": false |
||||
}] |
||||
], |
||||
"plugins": ["@babel/plugin-syntax-dynamic-import"] |
||||
} |
@ -0,0 +1,4 @@ |
||||
{ |
||||
"tabWidth": 2, |
||||
"useTabs": false |
||||
} |
@ -0,0 +1,3 @@ |
||||
all : |
||||
npm install
|
||||
npm run-script build
|
@ -0,0 +1,7 @@ |
||||
# Building |
||||
|
||||
* Run `npm install` to download dependencies. |
||||
* Run `npm run-script build` to build a production script in `../static/js/` or run |
||||
`npm run develop` to build and watch for changes. You can use `prose.html` |
||||
to test your development changes. |
||||
* Manually copy the file `prose.bundle.js` to `static/js/`. _To be automated_ |
@ -0,0 +1,57 @@ |
||||
import { MarkdownParser } from "prosemirror-markdown"; |
||||
import markdownit from "markdown-it"; |
||||
|
||||
import { writeFreelySchema } from "./schema"; |
||||
|
||||
export const writeAsMarkdownParser = new MarkdownParser( |
||||
writeFreelySchema, |
||||
markdownit("commonmark", { html: true }), |
||||
{ |
||||
// blockquote: { block: "blockquote" },
|
||||
paragraph: { block: "paragraph" }, |
||||
list_item: { block: "list_item" }, |
||||
bullet_list: { block: "bullet_list" }, |
||||
ordered_list: { |
||||
block: "ordered_list", |
||||
getAttrs: (tok) => ({ order: +tok.attrGet("start") || 1 }), |
||||
}, |
||||
heading: { |
||||
block: "heading", |
||||
getAttrs: (tok) => ({ level: +tok.tag.slice(1) }), |
||||
}, |
||||
code_block: { block: "code_block", noCloseToken: true }, |
||||
fence: { |
||||
block: "code_block", |
||||
getAttrs: (tok) => ({ params: tok.info || "" }), |
||||
noCloseToken: true, |
||||
}, |
||||
// hr: { node: "horizontal_rule" },
|
||||
image: { |
||||
node: "image", |
||||
getAttrs: (tok) => ({ |
||||
src: tok.attrGet("src"), |
||||
title: tok.attrGet("title") || null, |
||||
alt: tok.children?.[0].content || null, |
||||
}), |
||||
}, |
||||
hardbreak: { node: "hard_break" }, |
||||
|
||||
em: { mark: "em" }, |
||||
strong: { mark: "strong" }, |
||||
link: { |
||||
mark: "link", |
||||
getAttrs: (tok) => ({ |
||||
href: tok.attrGet("href"), |
||||
title: tok.attrGet("title") || null, |
||||
}), |
||||
}, |
||||
code_inline: { mark: "code", noCloseToken: true }, |
||||
html_block: { |
||||
node: "readmore", |
||||
getAttrs(token) { |
||||
// TODO: Give different attributes depending on the token content
|
||||
return {}; |
||||
}, |
||||
}, |
||||
} |
||||
); |
@ -0,0 +1,123 @@ |
||||
import { MarkdownSerializer } from "prosemirror-markdown"; |
||||
|
||||
function backticksFor(node, side) { |
||||
const ticks = /`+/g; |
||||
let m; |
||||
let len = 0; |
||||
if (node.isText) |
||||
while ((m = ticks.exec(node.text))) len = Math.max(len, m[0].length); |
||||
let result = len > 0 && side > 0 ? " `" : "`"; |
||||
for (let i = 0; i < len; i++) result += "`"; |
||||
if (len > 0 && side < 0) result += " "; |
||||
return result; |
||||
} |
||||
|
||||
function isPlainURL(link, parent, index, side) { |
||||
if (link.attrs.title || !/^\w+:/.test(link.attrs.href)) return false; |
||||
const content = parent.child(index + (side < 0 ? -1 : 0)); |
||||
if ( |
||||
!content.isText || |
||||
content.text != link.attrs.href || |
||||
content.marks[content.marks.length - 1] != link |
||||
) |
||||
return false; |
||||
if (index == (side < 0 ? 1 : parent.childCount - 1)) return true; |
||||
const next = parent.child(index + (side < 0 ? -2 : 1)); |
||||
return !link.isInSet(next.marks); |
||||
} |
||||
|
||||
export const writeAsMarkdownSerializer = new MarkdownSerializer( |
||||
{ |
||||
readmore(state, node) { |
||||
state.write("<!--more-->\n"); |
||||
state.closeBlock(node); |
||||
}, |
||||
// blockquote(state, node) {
|
||||
// state.wrapBlock("> ", undefined, node, () => state.renderContent(node));
|
||||
// },
|
||||
code_block(state, node) { |
||||
state.write(`\`\`\`${node.attrs.params || ""}\n`); |
||||
state.text(node.textContent, false); |
||||
state.ensureNewLine(); |
||||
state.write("```"); |
||||
state.closeBlock(node); |
||||
}, |
||||
heading(state, node) { |
||||
state.write(`${state.repeat("#", node.attrs.level)} `); |
||||
state.renderInline(node); |
||||
state.closeBlock(node); |
||||
}, |
||||
bullet_list(state, node) { |
||||
state.renderList(node, " ", () => `${node.attrs.bullet || "*"} `); |
||||
}, |
||||
ordered_list(state, node) { |
||||
const start = node.attrs.order || 1; |
||||
const maxW = String(start + node.childCount - 1).length; |
||||
const space = state.repeat(" ", maxW + 2); |
||||
state.renderList(node, space, (i) => { |
||||
const nStr = String(start + i); |
||||
return `${state.repeat(" ", maxW - nStr.length) + nStr}. `; |
||||
}); |
||||
}, |
||||
list_item(state, node) { |
||||
state.renderContent(node); |
||||
}, |
||||
paragraph(state, node) { |
||||
state.renderInline(node); |
||||
state.closeBlock(node); |
||||
}, |
||||
|
||||
image(state, node) { |
||||
state.write( |
||||
`![${state.esc(node.attrs.alt || "")}](${state.esc(node.attrs.src)}${ |
||||
node.attrs.title ? ` ${state.quote(node.attrs.title)}` : "" |
||||
})` |
||||
); |
||||
}, |
||||
hard_break(state, node, parent, index) { |
||||
for (let i = index + 1; i < parent.childCount; i += 1) |
||||
if (parent.child(i).type !== node.type) { |
||||
state.write("\\\n"); |
||||
return; |
||||
} |
||||
}, |
||||
text(state, node) { |
||||
state.text(node.text || ""); |
||||
}, |
||||
}, |
||||
{ |
||||
em: { |
||||
open: "*", |
||||
close: "*", |
||||
mixable: true, |
||||
expelEnclosingWhitespace: true, |
||||
}, |
||||
strong: { |
||||
open: "**", |
||||
close: "**", |
||||
mixable: true, |
||||
expelEnclosingWhitespace: true, |
||||
}, |
||||
link: { |
||||
open(_state, mark, parent, index) { |
||||
return isPlainURL(mark, parent, index, 1) ? "<" : "["; |
||||
}, |
||||
close(state, mark, parent, index) { |
||||
return isPlainURL(mark, parent, index, -1) |
||||
? ">" |
||||
: `](${state.esc(mark.attrs.href)}${ |
||||
mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : "" |
||||
})`;
|
||||
}, |
||||
}, |
||||
code: { |
||||
open(_state, _mark, parent, index) { |
||||
return backticksFor(parent.child(index), -1); |
||||
}, |
||||
close(_state, _mark, parent, index) { |
||||
return backticksFor(parent.child(index - 1), 1); |
||||
}, |
||||
escape: false, |
||||
}, |
||||
} |
||||
); |
@ -0,0 +1,32 @@ |
||||
import { MenuItem } from "prosemirror-menu"; |
||||
import { buildMenuItems } from "prosemirror-example-setup"; |
||||
|
||||
import { writeFreelySchema } from "./schema"; |
||||
|
||||
function canInsert(state, nodeType, attrs) { |
||||
let $from = state.selection.$from; |
||||
for (let d = $from.depth; d >= 0; d--) { |
||||
let index = $from.index(d); |
||||
if ($from.node(d).canReplaceWith(index, index, nodeType, attrs)) |
||||
return true; |
||||
} |
||||
return false; |
||||
} |
||||
|
||||
const ReadMoreItem = new MenuItem({ |
||||
label: "Read more", |
||||
select: (state) => canInsert(state, writeFreelySchema.nodes.readmore), |
||||
run(state, dispatch) { |
||||
dispatch( |
||||
state.tr.replaceSelectionWith(writeFreelySchema.nodes.readmore.create()) |
||||
); |
||||
}, |
||||
}); |
||||
|
||||
export const getMenu = () => { |
||||
const menuContent = [ |
||||
...buildMenuItems(writeFreelySchema).fullMenu, |
||||
[ReadMoreItem], |
||||
]; |
||||
return menuContent; |
||||
}; |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,32 @@ |
||||
{ |
||||
"name": "prose", |
||||
"version": "1.0.0", |
||||
"description": "", |
||||
"main": "prose.js", |
||||
"dependencies": { |
||||
"babel-core": "^6.26.3", |
||||
"babel-preset-es2015": "^6.24.1", |
||||
"markdown-it": "^12.0.4", |
||||
"prosemirror-example-setup": "^1.1.2", |
||||
"prosemirror-keymap": "^1.1.4", |
||||
"prosemirror-markdown": "github:VV-EE/prosemirror-markdown", |
||||
"prosemirror-model": "^1.9.1", |
||||
"prosemirror-state": "^1.3.2", |
||||
"prosemirror-view": "^1.14.2", |
||||
"webpack": "^4.42.0", |
||||
"webpack-cli": "^3.3.11" |
||||
}, |
||||
"devDependencies": { |
||||
"@babel/core": "^7.8.7", |
||||
"@babel/preset-env": "^7.9.0", |
||||
"babel-loader": "^8.0.6", |
||||
"prettier": "^2.2.1" |
||||
}, |
||||
"scripts": { |
||||
"develop": "webpack --mode development --watch", |
||||
"build": "webpack --mode production" |
||||
}, |
||||
"keywords": [], |
||||
"author": "", |
||||
"license": "ISC" |
||||
} |
@ -0,0 +1,14 @@ |
||||
<link rel="stylesheet" href="../static/css/prose.css" /> |
||||
<div id="editor" style="margin-bottom: 0"></div> |
||||
<!-- <div style="text-align: center"> --> |
||||
<!-- <label style="border-right: 1px solid silver"> --> |
||||
<!-- Markdown <input type=radio name=inputformat value=markdown> </label> --> |
||||
<!-- <label> <input type=radio name=inputformat value=prosemirror checked> WYSIWYM</label> --> |
||||
<!-- </div> --> |
||||
|
||||
<div style="display: none"> |
||||
<textarea id="content"> |
||||
This is a comment written in [Markdown](http://commonmark.org). *You* may know the syntax for inserting a link, but does your whole audience? So you can give people the **choice** to use a more familiar, discoverable interface.</textarea |
||||
> |
||||
</div> |
||||
<script src="dist/prose.bundle.js"></script> |
@ -0,0 +1,118 @@ |
||||
// class MarkdownView {
|
||||
// constructor(target, content) {
|
||||
// this.textarea = target.appendChild(document.createElement("textarea"))
|
||||
// this.textarea.value = content
|
||||
// }
|
||||
|
||||
// get content() { return this.textarea.value }
|
||||
// focus() { this.textarea.focus() }
|
||||
// destroy() { this.textarea.remove() }
|
||||
// }
|
||||
|
||||
import { EditorView } from "prosemirror-view"; |
||||
import { EditorState, TextSelection } from "prosemirror-state"; |
||||
import { exampleSetup } from "prosemirror-example-setup"; |
||||
import { keymap } from "prosemirror-keymap"; |
||||
|
||||
import { writeAsMarkdownParser } from "./markdownParser"; |
||||
import { writeAsMarkdownSerializer } from "./markdownSerializer"; |
||||
import { writeFreelySchema } from "./schema"; |
||||
import { getMenu } from "./menu"; |
||||
|
||||
let $title = document.querySelector("#title"); |
||||
let $content = document.querySelector("#content"); |
||||
|
||||
// Bugs:
|
||||
// 1. When there's just an empty line and a hard break is inserted with shift-enter then two enters are inserted
|
||||
// which do not show up in the markdown ( maybe bc. they are training enters )
|
||||
|
||||
class ProseMirrorView { |
||||
constructor(target, content) { |
||||
let typingTimer; |
||||
let localDraft = localStorage.getItem(window.draftKey); |
||||
if (localDraft != null) { |
||||
content = localDraft; |
||||
} |
||||
if (content.indexOf("# ") === 0) { |
||||
let eol = content.indexOf("\n"); |
||||
let title = content.substring("# ".length, eol); |
||||
content = content.substring(eol + "\n\n".length); |
||||
$title.value = title; |
||||
} |
||||
|
||||
const doc = writeAsMarkdownParser.parse( |
||||
// Replace all "solo" \n's with \\\n for correct markdown parsing
|
||||
// Can't use lookahead or lookbehind because it's not supported on Safari
|
||||
content.replace(/([^]{0,1})(\n)([^]{0,1})/g, (match, p1, p2, p3) => { |
||||
return p1 !== "\n" && p3 !== "\n" ? p1 + "\\\n" + p3 : match; |
||||
}) |
||||
); |
||||
|
||||
this.view = new EditorView(target, { |
||||
state: EditorState.create({ |
||||
doc, |
||||
plugins: [ |
||||
keymap({ |
||||
"Mod-Enter": () => { |
||||
document.getElementById("publish").click(); |
||||
return true; |
||||
}, |
||||
"Mod-k": () => { |
||||
const linkButton = document.querySelector( |
||||
".ProseMirror-icon[title='Add or remove link']" |
||||
); |
||||
linkButton.dispatchEvent(new Event("mousedown")); |
||||
return true; |
||||
}, |
||||
}), |
||||
...exampleSetup({ |
||||
schema: writeFreelySchema, |
||||
menuContent: getMenu(), |
||||
}), |
||||
], |
||||
}), |
||||
dispatchTransaction(transaction) { |
||||
let newState = this.state.apply(transaction); |
||||
const newContent = writeAsMarkdownSerializer |
||||
.serialize(newState.doc) |
||||
// Replace all \\\ns ( not followed by a \n ) with \n
|
||||
.replace(/(\\\n)(\n{0,1})/g, (match, p1, p2) => |
||||
p2 !== "\n" ? "\n" + p2 : match |
||||
); |
||||
$content.value = newContent; |
||||
let draft = ""; |
||||
if ($title.value != null && $title.value !== "") { |
||||
draft = "# " + $title.value + "\n\n"; |
||||
} |
||||
draft += newContent; |
||||
clearTimeout(typingTimer); |
||||
typingTimer = setTimeout(doneTyping, doneTypingInterval); |
||||
this.updateState(newState); |
||||
}, |
||||
}); |
||||
// Editor is focused to the last position. This is a workaround for a bug:
|
||||
// 1. 1 type something in an existing entry
|
||||
// 2. reload - works fine, the draft is reloaded
|
||||
// 3. reload again - the draft is somehow removed from localStorage and the original content is loaded
|
||||
// When the editor is focused the content is re-saved to localStorage
|
||||
|
||||
// This is also useful for editing, so it's not a bad thing even
|
||||
const lastPosition = this.view.state.doc.content.size; |
||||
const selection = TextSelection.create(this.view.state.doc, lastPosition); |
||||
this.view.dispatch(this.view.state.tr.setSelection(selection)); |
||||
this.view.focus(); |
||||
} |
||||
|
||||
get content() { |
||||
return defaultMarkdownSerializer.serialize(this.view.state.doc); |
||||
} |
||||
focus() { |
||||
this.view.focus(); |
||||
} |
||||
destroy() { |
||||
this.view.destroy(); |
||||
} |
||||
} |
||||
|
||||
let place = document.querySelector("#editor"); |
||||
let view = new ProseMirrorView(place, $content.value); |
@ -0,0 +1,21 @@ |
||||
import { schema } from "prosemirror-markdown"; |
||||
import { Schema } from "prosemirror-model"; |
||||
|
||||
export const writeFreelySchema = new Schema({ |
||||
nodes: schema.spec.nodes |
||||
.remove("blockquote") |
||||
.remove("horizontal_rule") |
||||
.addToEnd("readmore", { |
||||
inline: false, |
||||
content: "", |
||||
group: "block", |
||||
draggable: true, |
||||
toDOM: (node) => [ |
||||
"div", |
||||
{ class: "editorreadmore" }, |
||||
"Read more...", |
||||
], |
||||
parseDOM: [{ tag: "div.editorreadmore" }], |
||||
}), |
||||
marks: schema.spec.marks, |
||||
}); |
@ -0,0 +1,25 @@ |
||||
const path = require('path') |
||||
|
||||
module.exports = { |
||||
entry: { |
||||
entry: __dirname + '/prose.js' |
||||
}, |
||||
output: { |
||||
filename: 'prose.bundle.js', |
||||
path: path.resolve('..', 'static', 'js'), |
||||
}, |
||||
module: { |
||||
rules: [ |
||||
{ |
||||
test: /\.js$/, |
||||
exclude: /(nodue_modules|bower_components)/, |
||||
use: { |
||||
loader: 'babel-loader', |
||||
options: { |
||||
presets: ['@babel/preset-env'] |
||||
} |
||||
} |
||||
} |
||||
] |
||||
} |
||||
} |
@ -0,0 +1,38 @@ |
||||
package writefreely |
||||
|
||||
import ( |
||||
"net/http" |
||||
"net/http/httptest" |
||||
"strings" |
||||
"testing" |
||||
|
||||
"github.com/gorilla/mux" |
||||
) |
||||
|
||||
func TestCacheControlForStaticFiles(t *testing.T) { |
||||
app := NewApp("testdata/config.ini") |
||||
if err := app.LoadConfig(); err != nil { |
||||
t.Fatalf("Could not create an app; %v", err) |
||||
} |
||||
router := mux.NewRouter() |
||||
app.InitStaticRoutes(router) |
||||
|
||||
rec := httptest.NewRecorder() |
||||
req := httptest.NewRequest("GET", "/style.css", nil) |
||||
router.ServeHTTP(rec, req) |
||||
if code := rec.Result().StatusCode; code != http.StatusOK { |
||||
t.Fatalf("Could not get /style.css, got HTTP status %d", code) |
||||
} |
||||
actual := rec.Result().Header.Get("Cache-Control") |
||||
|
||||
expectedDirectives := []string{ |
||||
"public", |
||||
"max-age", |
||||
"immutable", |
||||
} |
||||
for _, expected := range expectedDirectives { |
||||
if !strings.Contains(actual, expected) { |
||||
t.Errorf("Expected Cache-Control header to contain '%s', but was '%s'", expected, actual) |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,37 @@ |
||||
#!/bin/bash |
||||
# |
||||
# Copyright © 2020 A Bunch Tell LLC. |
||||
# |
||||
# This file is part of WriteFreely. |
||||
# |
||||
# WriteFreely is free software: you can redistribute it and/or modify |
||||
# it under the terms of the GNU Affero General Public License, included |
||||
# in the LICENSE file in this source code package. |
||||
# |
||||
############################################################################### |
||||
# |
||||
# WriteFreely CSS invalidation script |
||||
# |
||||
# usage: ./invalidate-css.sh <build-directory> |
||||
# |
||||
# This script provides an automated way to invalidate stylesheets cached in the |
||||
# browser. It uses the last git commit hashes of the most frequently modified |
||||
# LESS files in the project and appends them to the stylesheet `href` in all |
||||
# template files. |
||||
# |
||||
# This is designed to be used when building a WriteFreely release. |
||||
# |
||||
############################################################################### |
||||
|
||||
# Get parent build directory from first argument |
||||
buildDir=$1 |
||||
|
||||
# Get short hash of each primary LESS file's last commit |
||||
cssHash=$(git log -n 1 --pretty=format:%h -- less/core.less) |
||||
cssNewHash=$(git log -n 1 --pretty=format:%h -- less/new-core.less) |
||||
cssPadHash=$(git log -n 1 --pretty=format:%h -- less/pad.less) |
||||
|
||||
echo "Adding write.css version ($cssHash $cssNewHash $cssPadHash) to .tmpl files..." |
||||
cd "$buildDir/templates" || exit 1 |
||||
find . -type f -name "*.tmpl" -print0 | xargs -0 sed -i "s/write.css/write.css?${cssHash}${cssNewHash}${cssPadHash}/g" |
||||
find . -type f -name "*.tmpl" -print0 | xargs -0 sed -i "s/{{.Theme}}.css/{{.Theme}}.css?${cssHash}${cssNewHash}${cssPadHash}/g" |
@ -0,0 +1,315 @@ |
||||
// Copyright 2018 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Package semver implements comparison of semantic version strings.
|
||||
// In this package, semantic version strings must begin with a leading "v",
|
||||
// as in "v1.0.0".
|
||||
//
|
||||
// The general form of a semantic version string accepted by this package is
|
||||
//
|
||||
// vMAJOR[.MINOR[.PATCH[-PRERELEASE][+BUILD]]]
|
||||
//
|
||||
// where square brackets indicate optional parts of the syntax;
|
||||
// MAJOR, MINOR, and PATCH are decimal integers without extra leading zeros;
|
||||
// PRERELEASE and BUILD are each a series of non-empty dot-separated identifiers
|
||||
// using only alphanumeric characters and hyphens; and
|
||||
// all-numeric PRERELEASE identifiers must not have leading zeros.
|
||||
//
|
||||
// This package follows Semantic Versioning 2.0.0 (see semver.org)
|
||||
// with two exceptions. First, it requires the "v" prefix. Second, it recognizes
|
||||
// vMAJOR and vMAJOR.MINOR (with no prerelease or build suffixes)
|
||||
// as shorthands for vMAJOR.0.0 and vMAJOR.MINOR.0.
|
||||
|
||||
// Package writefreely
|
||||
// copied from
|
||||
// https://github.com/golang/tools/blob/master/internal/semver/semver.go
|
||||
// slight modifications made
|
||||
package writefreely |
||||
|
||||
// parsed returns the parsed form of a semantic version string.
|
||||
type parsed struct { |
||||
major string |
||||
minor string |
||||
patch string |
||||
short string |
||||
prerelease string |
||||
build string |
||||
err string |
||||
} |
||||
|
||||
// IsValid reports whether v is a valid semantic version string.
|
||||
func IsValid(v string) bool { |
||||
_, ok := semParse(v) |
||||
return ok |
||||
} |
||||
|
||||
// CompareSemver returns an integer comparing two versions according to
|
||||
// according to semantic version precedence.
|
||||
// The result will be 0 if v == w, -1 if v < w, or +1 if v > w.
|
||||
//
|
||||
// An invalid semantic version string is considered less than a valid one.
|
||||
// All invalid semantic version strings compare equal to each other.
|
||||
func CompareSemver(v, w string) int { |
||||
pv, ok1 := semParse(v) |
||||
pw, ok2 := semParse(w) |
||||
if !ok1 && !ok2 { |
||||
return 0 |
||||
} |
||||
if !ok1 { |
||||
return -1 |
||||
} |
||||
if !ok2 { |
||||
return +1 |
||||
} |
||||
if c := compareInt(pv.major, pw.major); c != 0 { |
||||
return c |
||||
} |
||||
if c := compareInt(pv.minor, pw.minor); c != 0 { |
||||
return c |
||||
} |
||||
if c := compareInt(pv.patch, pw.patch); c != 0 { |
||||
return c |
||||
} |
||||
return comparePrerelease(pv.prerelease, pw.prerelease) |
||||
} |
||||
|
||||
func semParse(v string) (p parsed, ok bool) { |
||||
if v == "" || v[0] != 'v' { |
||||
p.err = "missing v prefix" |
||||
return |
||||
} |
||||
p.major, v, ok = parseInt(v[1:]) |
||||
if !ok { |
||||
p.err = "bad major version" |
||||
return |
||||
} |
||||
if v == "" { |
||||
p.minor = "0" |
||||
p.patch = "0" |
||||
p.short = ".0.0" |
||||
return |
||||
} |
||||
if v[0] != '.' { |
||||
p.err = "bad minor prefix" |
||||
ok = false |
||||
return |
||||
} |
||||
p.minor, v, ok = parseInt(v[1:]) |
||||
if !ok { |
||||
p.err = "bad minor version" |
||||
return |
||||
} |
||||
if v == "" { |
||||
p.patch = "0" |
||||
p.short = ".0" |
||||
return |
||||
} |
||||
if v[0] != '.' { |
||||
p.err = "bad patch prefix" |
||||
ok = false |
||||
return |
||||
} |
||||
p.patch, v, ok = parseInt(v[1:]) |
||||
if !ok { |
||||
p.err = "bad patch version" |
||||
return |
||||
} |
||||
if len(v) > 0 && v[0] == '-' { |
||||
p.prerelease, v, ok = parsePrerelease(v) |
||||
if !ok { |
||||
p.err = "bad prerelease" |
||||
return |
||||
} |
||||
} |
||||
if len(v) > 0 && v[0] == '+' { |
||||
p.build, v, ok = parseBuild(v) |
||||
if !ok { |
||||
p.err = "bad build" |
||||
return |
||||
} |
||||
} |
||||
if v != "" { |
||||
p.err = "junk on end" |
||||
ok = false |
||||
return |
||||
} |
||||
ok = true |
||||
return |
||||
} |
||||
|
||||
func parseInt(v string) (t, rest string, ok bool) { |
||||
if v == "" { |
||||
return |
||||
} |
||||
if v[0] < '0' || '9' < v[0] { |
||||
return |
||||
} |
||||
i := 1 |
||||
for i < len(v) && '0' <= v[i] && v[i] <= '9' { |
||||
i++ |
||||
} |
||||
if v[0] == '0' && i != 1 { |
||||
return |
||||
} |
||||
return v[:i], v[i:], true |
||||
} |
||||
|
||||
func parsePrerelease(v string) (t, rest string, ok bool) { |
||||
// "A pre-release version MAY be denoted by appending a hyphen and
|
||||
// a series of dot separated identifiers immediately following the patch version.
|
||||
// Identifiers MUST comprise only ASCII alphanumerics and hyphen [0-9A-Za-z-].
|
||||
// Identifiers MUST NOT be empty. Numeric identifiers MUST NOT include leading zeroes."
|
||||
if v == "" || v[0] != '-' { |
||||
return |
||||
} |
||||
i := 1 |
||||
start := 1 |
||||
for i < len(v) && v[i] != '+' { |
||||
if !isIdentChar(v[i]) && v[i] != '.' { |
||||
return |
||||
} |
||||
if v[i] == '.' { |
||||
if start == i || isBadNum(v[start:i]) { |
||||
return |
||||
} |
||||
start = i + 1 |
||||
} |
||||
i++ |
||||
} |
||||
if start == i || isBadNum(v[start:i]) { |
||||
return |
||||
} |
||||
return v[:i], v[i:], true |
||||
} |
||||
|
||||
func parseBuild(v string) (t, rest string, ok bool) { |
||||
if v == "" || v[0] != '+' { |
||||
return |
||||
} |
||||
i := 1 |
||||
start := 1 |
||||
for i < len(v) { |
||||
if !isIdentChar(v[i]) { |
||||
return |
||||
} |
||||
if v[i] == '.' { |
||||
if start == i { |
||||
return |
||||
} |
||||
start = i + 1 |
||||
} |
||||
i++ |
||||
} |
||||
if start == i { |
||||
return |
||||
} |
||||
return v[:i], v[i:], true |
||||
} |
||||
|
||||
func isIdentChar(c byte) bool { |
||||
return 'A' <= c && c <= 'Z' || 'a' <= c && c <= 'z' || '0' <= c && c <= '9' || c == '-' |
||||
} |
||||
|
||||
func isBadNum(v string) bool { |
||||
i := 0 |
||||
for i < len(v) && '0' <= v[i] && v[i] <= '9' { |
||||
i++ |
||||
} |
||||
return i == len(v) && i > 1 && v[0] == '0' |
||||
} |
||||
|
||||
func isNum(v string) bool { |
||||
i := 0 |
||||
for i < len(v) && '0' <= v[i] && v[i] <= '9' { |
||||
i++ |
||||
} |
||||
return i == len(v) |
||||
} |
||||
|
||||
func compareInt(x, y string) int { |
||||
if x == y { |
||||
return 0 |
||||
} |
||||
if len(x) < len(y) { |
||||
return -1 |
||||
} |
||||
if len(x) > len(y) { |
||||
return +1 |
||||
} |
||||
if x < y { |
||||
return -1 |
||||
} else { |
||||
return +1 |
||||
} |
||||
} |
||||
|
||||
func comparePrerelease(x, y string) int { |
||||
// "When major, minor, and patch are equal, a pre-release version has
|
||||
// lower precedence than a normal version.
|
||||
// Example: 1.0.0-alpha < 1.0.0.
|
||||
// Precedence for two pre-release versions with the same major, minor,
|
||||
// and patch version MUST be determined by comparing each dot separated
|
||||
// identifier from left to right until a difference is found as follows:
|
||||
// identifiers consisting of only digits are compared numerically and
|
||||
// identifiers with letters or hyphens are compared lexically in ASCII
|
||||
// sort order. Numeric identifiers always have lower precedence than
|
||||
// non-numeric identifiers. A larger set of pre-release fields has a
|
||||
// higher precedence than a smaller set, if all of the preceding
|
||||
// identifiers are equal.
|
||||
// Example: 1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta <
|
||||
// 1.0.0-beta < 1.0.0-beta.2 < 1.0.0-beta.11 < 1.0.0-rc.1 < 1.0.0."
|
||||
if x == y { |
||||
return 0 |
||||
} |
||||
if x == "" { |
||||
return +1 |
||||
} |
||||
if y == "" { |
||||
return -1 |
||||
} |
||||
for x != "" && y != "" { |
||||
x = x[1:] // skip - or .
|
||||
y = y[1:] // skip - or .
|
||||
var dx, dy string |
||||
dx, x = nextIdent(x) |
||||
dy, y = nextIdent(y) |
||||
if dx != dy { |
||||
ix := isNum(dx) |
||||
iy := isNum(dy) |
||||
if ix != iy { |
||||
if ix { |
||||
return -1 |
||||
} else { |
||||
return +1 |
||||
} |
||||
} |
||||
if ix { |
||||
if len(dx) < len(dy) { |
||||
return -1 |
||||
} |
||||
if len(dx) > len(dy) { |
||||
return +1 |
||||
} |
||||
} |
||||
if dx < dy { |
||||
return -1 |
||||
} else { |
||||
return +1 |
||||
} |
||||
} |
||||
} |
||||
if x == "" { |
||||
return -1 |
||||
} else { |
||||
return +1 |
||||
} |
||||
} |
||||
|
||||
func nextIdent(x string) (dx, rest string) { |
||||
i := 0 |
||||
for i < len(x) && x[i] != '.' { |
||||
i++ |
||||
} |
||||
return x[:i], x[i:] |
||||
} |
After Width: | Height: | Size: 4.6 KiB |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue