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" |
version: "3" |
||||||
|
|
||||||
|
volumes: |
||||||
|
web-keys: |
||||||
|
db-data: |
||||||
|
|
||||||
|
networks: |
||||||
|
external_writefreely: |
||||||
|
internal_writefreely: |
||||||
|
internal: true |
||||||
|
|
||||||
services: |
services: |
||||||
web: |
writefreely-web: |
||||||
build: . |
container_name: "writefreely-web" |
||||||
|
image: "writeas/writefreely:latest" |
||||||
|
|
||||||
volumes: |
volumes: |
||||||
- "web-data:/go/src/app" |
- "web-keys:/go/keys" |
||||||
- "./config.ini.example:/go/src/app/config.ini" |
- "./config.ini:/go/config.ini" |
||||||
|
|
||||||
|
networks: |
||||||
|
- "internal_writefreely" |
||||||
|
- "external_writefreely" |
||||||
|
|
||||||
ports: |
ports: |
||||||
- "8080:8080" |
- "8080:8080" |
||||||
networks: |
|
||||||
- writefreely |
|
||||||
depends_on: |
depends_on: |
||||||
- db |
- "writefreely-db" |
||||||
|
|
||||||
restart: unless-stopped |
restart: unless-stopped |
||||||
db: |
|
||||||
|
writefreely-db: |
||||||
|
container_name: "writefreely-db" |
||||||
image: "mariadb:latest" |
image: "mariadb:latest" |
||||||
|
|
||||||
volumes: |
volumes: |
||||||
- "./schema.sql:/tmp/schema.sql" |
- "db-data:/var/lib/mysql/data" |
||||||
- db-data:/var/lib/mysql/data |
|
||||||
networks: |
networks: |
||||||
- writefreely |
- "internal_writefreely" |
||||||
|
|
||||||
environment: |
environment: |
||||||
- MYSQL_DATABASE=writefreely |
- MYSQL_DATABASE=writefreely |
||||||
- MYSQL_ROOT_PASSWORD=changeme |
- MYSQL_ROOT_PASSWORD=changeme |
||||||
restart: unless-stopped |
|
||||||
|
|
||||||
volumes: |
restart: unless-stopped |
||||||
web-data: |
|
||||||
db-data: |
|
||||||
|
|
||||||
networks: |
|
||||||
writefreely: |
|
||||||
|
@ -1,66 +1,49 @@ |
|||||||
module github.com/writeas/writefreely |
module github.com/writefreely/writefreely |
||||||
|
|
||||||
require ( |
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/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/dustin/go-humanize v1.0.0 |
||||||
github.com/fatih/color v1.7.0 |
github.com/fatih/color v1.10.0 |
||||||
github.com/go-fed/httpsig v0.1.1-0.20190924171022-f4c36041199d // indirect |
github.com/go-sql-driver/mysql v1.6.0 |
||||||
github.com/go-sql-driver/mysql v1.4.1 |
|
||||||
github.com/go-test/deep v1.0.1 // indirect |
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/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect |
||||||
github.com/gorilla/feeds v1.1.0 |
github.com/gorilla/feeds v1.1.1 |
||||||
github.com/gorilla/mux v1.7.0 |
github.com/gorilla/mux v1.8.0 |
||||||
github.com/gorilla/schema v1.0.2 |
github.com/gorilla/schema v1.2.0 |
||||||
github.com/gorilla/sessions v1.2.0 |
github.com/gorilla/sessions v1.2.0 |
||||||
github.com/guregu/null v3.4.0+incompatible |
github.com/guregu/null v3.5.0+incompatible |
||||||
github.com/hashicorp/go-multierror v1.0.0 |
github.com/hashicorp/go-multierror v1.1.1 |
||||||
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2 |
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2 |
||||||
github.com/jtolds/gls v4.2.1+incompatible // indirect |
github.com/jtolds/gls v4.2.1+incompatible // indirect |
||||||
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec |
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec |
||||||
github.com/lunixbochs/vtclean v1.0.0 // indirect |
github.com/lunixbochs/vtclean v1.0.0 // indirect |
||||||
github.com/manifoldco/promptui v0.3.2 |
github.com/manifoldco/promptui v0.8.0 |
||||||
github.com/mattn/go-colorable v0.1.0 // indirect |
github.com/mattn/go-sqlite3 v1.14.6 |
||||||
github.com/mattn/go-sqlite3 v1.10.0 |
github.com/microcosm-cc/bluemonday v1.0.5 |
||||||
github.com/microcosm-cc/bluemonday v1.0.2 |
github.com/mitchellh/go-wordwrap v1.0.1 |
||||||
github.com/mitchellh/go-wordwrap v1.0.0 |
|
||||||
github.com/nicksnyder/go-i18n v1.10.0 // indirect |
|
||||||
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d |
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/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/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect |
||||||
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 // indirect |
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 // indirect |
||||||
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c // 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/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-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/httpsig v1.0.0 |
||||||
github.com/writeas/impart v1.1.1-0.20191230230525-d3c45ced010d |
github.com/writeas/impart v1.1.1 |
||||||
github.com/writeas/import v0.2.0 |
github.com/writeas/import v0.2.1 |
||||||
github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219 |
github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219 |
||||||
github.com/writeas/nerds v1.0.0 |
github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320 |
||||||
github.com/writeas/saturday v1.7.1 |
|
||||||
github.com/writeas/slug v1.2.0 |
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 |
github.com/writefreely/go-nodeinfo v1.2.0 |
||||||
golang.org/x/crypto v0.0.0-20190208162236-193df9c0f06f |
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 |
||||||
golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1 // indirect |
golang.org/x/net v0.0.0-20200707034311-ab3426394381 // indirect |
||||||
golang.org/x/net v0.0.0-20190206173232-65e2d4e15006 // indirect |
gopkg.in/ini.v1 v1.62.0 |
||||||
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 |
|
||||||
) |
) |
||||||
|
|
||||||
go 1.13 |
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