10 Commits

Author SHA1 Message Date
Matt Baer
3576ab15d1 Create issue templates 2018-11-12 20:12:31 -05:00
Matt Baer
78953f27f0 Fix badge 2018-11-12 13:40:38 -05:00
Matt Baer
002d0e6309 Bump version to 0.2 2018-11-11 18:37:30 -05:00
Matt Baer
b8ce944b5c Add IRC badge in README 2018-11-11 18:37:13 -05:00
Matt Baer
7bc873580c Move key generation to app from keys.sh
This eliminates an external dependency needed for install, and ensures
the app can run on Windows.
2018-11-11 17:52:24 -05:00
Matt Baer
96c197453d Fix key loading on Windows + move paths into vars
This uses filepath.Join() to make sure they always load correctly
2018-11-11 17:25:34 -05:00
Matt Baer
561568343a Use avatar as blog link social media image 2018-11-11 15:34:26 -05:00
Matt Baer
c996ae1cad Add To and CC on Create activities
Part of #8
2018-11-11 13:11:01 -05:00
Matt Baer
393f6d6834 Add ID on Accept activities
Part of #8
2018-11-11 13:10:39 -05:00
Matt Baer
bbed72ff6b Remove unneeded followers column from remoteusers
To migrate:

  ALTER TABLE `remoteusers` DROP `followers`;
2018-11-11 12:43:24 -05:00
20 changed files with 386 additions and 187 deletions

24
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,24 @@
---
name: Bug report
about: Let us know what went wrong.
---
### Describe the bug
Explain what the bug is, in as much detail as possible...
### Steps to reproduce (if necessary)
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. ...
### Expected behavior
What should've happened?
### Application configuration
- **Single mode or Multi-user mode?**
- **Open registration?** [yes/no]
- **Federation enabled?** [yes/no]
**Version or last commit**:

View File

@@ -0,0 +1,9 @@
---
name: Feature request
about: Suggest an idea for this project
---
# PLEASE DON'T SUBMIT FEATURE REQUESTS HERE #
Instead, post them to our forums: https://discuss.write.as/c/feedback/feature-requests

View File

@@ -1,9 +1,26 @@
# Contributing to WriteFreely (lupine's fork)
# Contributing to WriteFreely
This is a fork of https://github.com/writeas/writefreely
Welcome! We're glad you're interested in contributing to the WriteFreely project.
You probably want to contribute there instead of here.
To start, we'd suggest checking out [our Phabricator board](https://phabricator.write.as/tag/write_freely/) to see where the project is at and where it's going. You can also [join the WriteFreely forums](https://discuss.write.as/c/writefreely) to start talking about what you'd like to do or see.
Note that I haven't signed their CLA, which means they won't accept my code. So
that's why this is a fork. I'm not doing this to be a pain, I just don't want to
run a mysql server on the limited hardware I have at home.
## Asking Questions
The best place to get answers to your questions is on [our forums](https://discuss.write.as/c/writefreely). You can quickly log in using your GitHub account and ask the community about anything. We're also there to answer your questions and discuss potential changes or features.
## Submitting Bugs
Please use the [GitHub issue tracker](https://github.com/writeas/writefreely/issues/new) to report any bugs you encounter. We're very responsive there and try to keep open issues to a minimum, so you can help by:
* **Only reporting bugs in the issue tracker**
* Providing as much information as possible to replicate the issue, including server logs around the incident
* Including the `[app]` section of your configuration, if related
* Breaking issues into smaller pieces if they're larger or have many parts
## Contributing code
We gladly welcome development help, regardless of coding experience. We can also use help [translating the app](https://poeditor.com/join/project/TIZ6HFRFdE) and documenting it!
**Before writing or submitting any code**, please sign our [contributor's agreement](https://phabricator.write.as/L1) so we can accept your contributions. It is substantially similar to the _Apache Individual Contributor License Agreement_. If you'd like to know about the rationale behind this requirement, you can [read more about that here](https://phabricator.write.as/w/writefreely/cla/).
Once you've done that, please feel free to [submit a pull request](https://github.com/writeas/writefreely/pulls) for any small improvements. For larger projects, please [join our development discussions](https://discuss.write.as/c/writefreely) or [get in touch](https://write.as/contact) so we can talk about what you'd like to work on.

View File

@@ -20,8 +20,8 @@ run:
deps :
$(GOGET) -v ./...
install :
./keys.sh
install : build
cmd/writefreely/$(BINARY_NAME) --gen-keys
cd less/; $(MAKE) install $(MFLAGS)
ui : force_look

View File

@@ -1,10 +1,33 @@
# WriteFreely (lupine's fork)
 
<p align="center">
<a href="https://writefreely.org"><img src="https://writefreely.org/img/writefreely.svg" width="350px" alt="Write Freely" /></a>
</p>
<hr />
<p align="center">
<a href="https://github.com/writeas/writefreely/releases/">
<img src="https://img.shields.io/github/release/writeas/writefreely.svg" alt="Latest release" />
</a>
<a href="https://goreportcard.com/report/github.com/writeas/writefreely">
<img src="https://goreportcard.com/badge/github.com/writeas/writefreely" alt="Go Report Card" />
</a>
<a href="https://travis-ci.org/writeas/writefreely">
<img src="https://travis-ci.org/writeas/writefreely.svg" alt="Build status" />
</a>
<a href="http://webchat.freenode.net/?channels=writefreely">
<img alt="#writefreely on freenode" src="https://img.shields.io/badge/freenode-%23writefreely-blue.svg" />
</a>
</p>
&nbsp;
WriteFreely is a beautifully pared-down blogging platform that's simple on the surface, yet powerful underneath.
It's designed to be flexible and share your writing widely, so it's built around plain text and can publish to the _fediverse_ via ActivityPub. It's easy to install and lightweight.
See [CONTRIBUTING.md](contributing.md) for more information on why it's forked
**[Start a blog on our instance](https://write.as/new/blog/federated)**
[Try the editor](https://write.as/new)
[Find another instance](https://writefreely.org/instances)
## Features
@@ -18,23 +41,22 @@ See [CONTRIBUTING.md](contributing.md) for more information on why it's forked
> **Note** this is currently alpha software. We're quickly moving out of this v0.x stage, but while we're in it, there are no guarantees that this is ready for production use.
First, build writefreely.
First, download the [latest release](https://github.com/writeas/writefreely/releases/latest) for your OS. It includes everything you need to start your blog.
Now change into the directory, and do the following steps:
Now extract the files from the archive, change into the directory, and do the following steps:
```bash
# 1) Start an SQLite3 database
sqlite3 writefreely.sqlite3
# 1) Log into MySQL and run:
# CREATE DATABASE writefreely;
#
# 2) Import the schema with:
.read schema.sql
mysql -u YOURUSERNAME -p writefreely < schema.sql
# 3) Configure your blog
./writefreely --config
# 4) Generate data encryption keys (especially for production)
./keys.sh
# 4) Generate data encryption keys
./writefreely --gen-keys
# 5) Run
./writefreely
@@ -60,9 +82,7 @@ Ready to hack on your site? Here's a quick overview.
go get github.com/writeas/writefreely/cmd/writefreely
```
Create your database, import the schema, and configure your site [as shown above](#quick-start).
Now generate the CSS:
Create your database, import the schema, and configure your site [as shown above](#quick-start). Then generate the remaining files you'll need:
```bash
make install # Generates encryption keys; installs LESS compiler

View File

@@ -7,6 +7,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"github.com/go-sql-driver/mysql"
"github.com/gorilla/mux"
"github.com/writeas/activity/streams"
"github.com/writeas/httpsig"
@@ -254,6 +255,16 @@ func handleFetchCollectionInbox(app *app, w http.ResponseWriter, r *http.Request
b, _ := json.Marshal(m)
log.Info("Follow: %s", b)
_, followID := f.GetId()
if followID == nil {
log.Error("Didn't resolve follow ID")
} else {
acceptID, err := url.Parse(followID.String() + "-accept")
if err != nil {
log.Error("Couldn't parse generated Accept URL '%s': %v", followID.String()+"#accept", err)
}
a.SetId(acceptID)
}
a.AppendObject(f.Raw())
_, to = f.GetActor(0)
obj := f.Raw().GetObjectIRI(0)
@@ -343,10 +354,12 @@ func handleFetchCollectionInbox(app *app, w http.ResponseWriter, r *http.Request
// Add follower locally, since it wasn't found before
res, err := t.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox) VALUES (?, ?, ?)", fullActor.ID, fullActor.Inbox, fullActor.Endpoints.SharedInbox)
if err != nil {
if isConstraintError(err) {
t.Rollback()
log.Error("Couldn't add new remoteuser in DB: %v\n", err)
return
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
if mysqlErr.Number != mySQLErrDuplicateKey {
t.Rollback()
log.Error("Couldn't add new remoteuser in DB: %v\n", err)
return
}
} else {
t.Rollback()
log.Error("Couldn't add new remoteuser in DB: %v\n", err)
@@ -364,10 +377,12 @@ func handleFetchCollectionInbox(app *app, w http.ResponseWriter, r *http.Request
// Add in key
_, err = t.Exec("INSERT INTO remoteuserkeys (id, remote_user_id, public_key) VALUES (?, ?, ?)", fullActor.PublicKey.ID, followerID, fullActor.PublicKey.PublicKeyPEM)
if err != nil {
if isConstraintError(err) {
t.Rollback()
log.Error("Couldn't add follower keys in DB: %v\n", err)
return
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
if mysqlErr.Number != mySQLErrDuplicateKey {
t.Rollback()
log.Error("Couldn't add follower keys in DB: %v\n", err)
return
}
} else {
t.Rollback()
log.Error("Couldn't add follower keys in DB: %v\n", err)
@@ -377,12 +392,14 @@ func handleFetchCollectionInbox(app *app, w http.ResponseWriter, r *http.Request
}
// Add follow
_, err = t.Exec("INSERT INTO remotefollows (collection_id, remote_user_id, created) VALUES (?, ?, "+sqlNow+")", c.ID, followerID)
_, err = t.Exec("INSERT INTO remotefollows (collection_id, remote_user_id, created) VALUES (?, ?, NOW())", c.ID, followerID)
if err != nil {
if isConstraintError(err) {
t.Rollback()
log.Error("Couldn't add follower in DB: %v\n", err)
return
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
if mysqlErr.Number != mySQLErrDuplicateKey {
t.Rollback()
log.Error("Couldn't add follower in DB: %v\n", err)
return
}
} else {
t.Rollback()
log.Error("Couldn't add follower in DB: %v\n", err)
@@ -573,6 +590,8 @@ func federatePost(app *app, p *PublicPost, collID int64, isUpdate bool) error {
activity = activitystreams.NewUpdateActivity(na)
} else {
activity = activitystreams.NewCreateActivity(na)
activity.To = na.To
activity.CC = na.CC
}
err = makeActivityPost(actor, si, activity)
if err != nil {

42
app.go
View File

@@ -4,7 +4,7 @@ import (
"database/sql"
"flag"
"fmt"
_ "github.com/mattn/go-sqlite3"
_ "github.com/go-sql-driver/mysql"
"html/template"
"net/http"
"os"
@@ -29,7 +29,8 @@ const (
serverSoftware = "WriteFreely"
softwareURL = "https://writefreely.org"
softwareVer = "0.1"
softwareVer = "0.2"
)
var (
@@ -124,6 +125,7 @@ func Serve() {
debugPtr := flag.Bool("debug", false, "Enables debug logging.")
createConfig := flag.Bool("create-config", false, "Creates a basic configuration and exits")
doConfig := flag.Bool("config", false, "Run the configuration process")
genKeys := flag.Bool("gen-keys", false, "Generate encryption and authentication keys")
flag.Parse()
debugging = *debugPtr
@@ -167,6 +169,23 @@ func Serve() {
log.Info("Done!")
}
os.Exit(0)
} else if *genKeys {
errStatus := 0
err := generateKey(emailKeyPath)
if err != nil {
errStatus = 1
}
err = generateKey(cookieAuthKeyPath)
if err != nil {
errStatus = 1
}
err = generateKey(cookieKeyPath)
if err != nil {
errStatus = 1
}
os.Exit(errStatus)
}
log.Info("Initializing...")
@@ -203,10 +222,16 @@ func Serve() {
app.formDecoder.RegisterConverter(sql.NullFloat64{}, converter.ConvertSQLNullFloat64)
// Check database configuration
if app.cfg.Database.Database == "" {
log.Error("Database filename not set.")
if app.cfg.Database.User == "" || app.cfg.Database.Password == "" {
log.Error("Database user or password not set.")
os.Exit(1)
}
if app.cfg.Database.Host == "" {
app.cfg.Database.Host = "localhost"
}
if app.cfg.Database.Database == "" {
app.cfg.Database.Database = "writeas"
}
connectToDatabase(app)
defer shutdown(app)
@@ -243,7 +268,7 @@ func Serve() {
http.Handle("/", r)
log.Info("Serving on http://localhost:%d\n", app.cfg.Server.Port)
log.Info("---")
err = http.ListenAndServe(fmt.Sprintf("localhost:%d", app.cfg.Server.Port), nil)
err = http.ListenAndServe(fmt.Sprintf(":%d", app.cfg.Server.Port), nil)
if err != nil {
log.Error("Unable to start: %v", err)
os.Exit(1)
@@ -252,12 +277,7 @@ func Serve() {
func connectToDatabase(app *app) {
log.Info("Connecting to database...")
if app.cfg.Database.Type != "sqlite3" {
log.Error("This fork of writefreely only supports sqlite3 databases")
os.Exit(1)
}
db, err := sql.Open("sqlite3", app.cfg.Database.Database)
db, err := sql.Open("mysql", fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true", app.cfg.Database.User, app.cfg.Database.Password, app.cfg.Database.Host, app.cfg.Database.Port, app.cfg.Database.Database))
if err != nil {
log.Error("%s", err)
os.Exit(1)

View File

@@ -264,12 +264,11 @@ func (c *Collection) PersonObject(ids ...int64) *activitystreams.Person {
p.Name = c.DisplayTitle()
p.Summary = c.Description
if p.Name != "" {
fl := string(unicode.ToLower([]rune(p.Name)[0]))
if isLowerLetter(fl) {
if av := c.AvatarURL(); av != "" {
p.Icon = activitystreams.Image{
Type: "Image",
MediaType: "image/png",
URL: hostName + "/img/avatars/" + fl + ".png",
URL: av,
}
}
}
@@ -287,6 +286,14 @@ func (c *Collection) PersonObject(ids ...int64) *activitystreams.Person {
return p
}
func (c *Collection) AvatarURL() string {
fl := string(unicode.ToLower([]rune(c.DisplayTitle())[0]))
if !isLowerLetter(fl) {
return ""
}
return hostName + "/img/avatars/" + fl + ".png"
}
func (c *Collection) FederatedAPIBase() string {
return hostName + "/"
}

View File

@@ -18,7 +18,11 @@ type (
DatabaseCfg struct {
Type string `ini:"type"`
User string `ini:"username"`
Password string `ini:"password"`
Database string `ini:"database"`
Host string `ini:"host"`
Port int `ini:"port"`
}
AppCfg struct {
@@ -55,8 +59,9 @@ func New() *Config {
Port: 8080,
},
Database: DatabaseCfg{
Type: "sqlite3",
Database: "writefreely.sqlite3",
Type: "mysql",
Host: "localhost",
Port: 3306,
},
App: AppCfg{
Host: "http://localhost:8080",

View File

@@ -65,7 +65,30 @@ func Configure() (*SetupData, error) {
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Database filename",
Label: "Username",
Validate: validateNonEmpty,
Default: data.Config.Database.User,
}
data.Config.Database.User, err = prompt.Run()
if err != nil {
return data, err
}
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Password",
Validate: validateNonEmpty,
Default: data.Config.Database.Password,
Mask: '*',
}
data.Config.Database.Password, err = prompt.Run()
if err != nil {
return data, err
}
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Database name",
Validate: validateNonEmpty,
Default: data.Config.Database.Database,
}
@@ -74,6 +97,29 @@ func Configure() (*SetupData, error) {
return data, err
}
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Host",
Validate: validateNonEmpty,
Default: data.Config.Database.Host,
}
data.Config.Database.Host, err = prompt.Run()
if err != nil {
return data, err
}
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Port",
Validate: validatePort,
Default: fmt.Sprintf("%d", data.Config.Database.Port),
}
dbPort, err := prompt.Run()
if err != nil {
return data, err
}
data.Config.Database.Port, _ = strconv.Atoi(dbPort) // Ignore error, as we've already validated number
fmt.Println()
title(" App setup ")
fmt.Println()

View File

@@ -7,7 +7,7 @@ import (
"strings"
"time"
"github.com/mattn/go-sqlite3"
"github.com/go-sql-driver/mysql"
"github.com/guregu/null"
"github.com/guregu/null/zero"
uuid "github.com/nu7hatch/gouuid"
@@ -22,19 +22,10 @@ import (
"github.com/writeas/writefreely/author"
)
var (
sqlNow = `datetime('now')`
sqlExpires = `(expires IS NULL OR expires > `+sqlNow+`)`
const (
mySQLErrDuplicateKey = 1062
)
func isConstraintError(err error) bool {
if sqliteErr, ok := err.(*sqlite3.Error); ok {
return sqliteErr.Code == sqlite3.ErrConstraint
}
return false
}
type writestore interface {
CreateUser(*User, string) error
UpdateUserEmail(keys *keychain, userID int64, email string) error
@@ -119,11 +110,13 @@ func (db *datastore) CreateUser(u *User, collectionTitle string) error {
// 1. Add to `users` table
// NOTE: Assumes User's Password is already hashed!
res, err := t.Exec("INSERT INTO users (username, password, email, created) VALUES (?, ?, ?, "+sqlNow+")", u.Username, u.HashedPass, u.Email)
res, err := t.Exec("INSERT INTO users (username, password, email, created) VALUES (?, ?, ?, NOW())", u.Username, u.HashedPass, u.Email)
if err != nil {
t.Rollback()
if isConstraintError(err) {
return impart.HTTPError{http.StatusConflict, "Username is already taken."}
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
if mysqlErr.Number == mySQLErrDuplicateKey {
return impart.HTTPError{http.StatusConflict, "Username is already taken."}
}
}
log.Error("Rolling back users INSERT: %v\n", err)
@@ -143,8 +136,10 @@ func (db *datastore) CreateUser(u *User, collectionTitle string) error {
res, err = t.Exec("INSERT INTO collections (alias, title, description, privacy, owner_id, view_count) VALUES (?, ?, ?, ?, ?, ?)", u.Username, collectionTitle, "", CollUnlisted, u.ID, 0)
if err != nil {
t.Rollback()
if isConstraintError(err) {
return impart.HTTPError{http.StatusConflict, "Username is already taken."}
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
if mysqlErr.Number == mySQLErrDuplicateKey {
return impart.HTTPError{http.StatusConflict, "Username is already taken."}
}
}
log.Error("Rolling back collections INSERT: %v\n", err)
return err
@@ -213,8 +208,10 @@ func (db *datastore) CreateCollection(alias, title string, userID int64) (*Colle
// All good, so create new collection
res, err := db.Exec("INSERT INTO collections (alias, title, description, privacy, owner_id, view_count) VALUES (?, ?, ?, ?, ?, ?)", alias, title, "", CollUnlisted, userID, 0)
if err != nil {
if isConstraintError(err) {
return nil, impart.HTTPError{http.StatusConflict, "Collection already exists."}
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
if mysqlErr.Number == mySQLErrDuplicateKey {
return nil, impart.HTTPError{http.StatusConflict, "Collection already exists."}
}
}
log.Error("Couldn't add to collections: %v\n", err)
return nil, err
@@ -332,7 +329,7 @@ func (db *datastore) GetUserNameFromToken(accessToken string) (string, error) {
var oneTime bool
var username string
err := db.QueryRow("SELECT username, one_time FROM accesstokens LEFT JOIN users ON user_id = id WHERE token = ? AND "+sqlExpires, t).Scan(&username, &oneTime)
err := db.QueryRow("SELECT username, one_time FROM accesstokens LEFT JOIN users ON user_id = id WHERE token = ? AND (expires IS NULL OR expires > NOW())", t).Scan(&username, &oneTime)
switch {
case err == sql.ErrNoRows:
return "", ErrBadAccessToken
@@ -357,7 +354,7 @@ func (db *datastore) GetUserDataFromToken(accessToken string) (int64, string, er
var userID int64
var oneTime bool
var username string
err := db.QueryRow("SELECT user_id, username, one_time FROM accesstokens LEFT JOIN users ON user_id = id WHERE token = ? AND "+sqlExpires, t).Scan(&userID, &username, &oneTime)
err := db.QueryRow("SELECT user_id, username, one_time FROM accesstokens LEFT JOIN users ON user_id = id WHERE token = ? AND (expires IS NULL OR expires > NOW())", t).Scan(&userID, &username, &oneTime)
switch {
case err == sql.ErrNoRows:
return 0, "", ErrBadAccessToken
@@ -396,7 +393,7 @@ func (db *datastore) GetUserIDPrivilege(accessToken string) (userID int64, sudo
}
var oneTime bool
err := db.QueryRow("SELECT user_id, sudo, one_time FROM accesstokens WHERE token = ? AND "+sqlExpires, t).Scan(&userID, &sudo, &oneTime)
err := db.QueryRow("SELECT user_id, sudo, one_time FROM accesstokens WHERE token = ? AND (expires IS NULL OR expires > NOW())", t).Scan(&userID, &sudo, &oneTime)
switch {
case err == sql.ErrNoRows:
return -1, false
@@ -428,7 +425,7 @@ func (db *datastore) DeleteToken(accessToken []byte) error {
// userID.
func (db *datastore) FetchLastAccessToken(userID int64) string {
var t []byte
err := db.QueryRow("SELECT token FROM accesstokens WHERE user_id = ? AND "+sqlExpires+" ORDER BY created DESC LIMIT 1", userID).Scan(&t)
err := db.QueryRow("SELECT token FROM accesstokens WHERE user_id = ? AND (expires IS NULL OR expires > NOW()) ORDER BY created DESC LIMIT 1", userID).Scan(&t)
switch {
case err == sql.ErrNoRows:
return ""
@@ -471,16 +468,12 @@ func (db *datastore) GetTemporaryOneTimeAccessToken(userID int64, validSecs int,
// Insert UUID to `accesstokens`
binTok := u[:]
now := time.Now()
expirationVal := interface{}("NULL")
expirationVal := "NULL"
if validSecs > 0 {
expirationVal = interface{}(now.Add(time.Duration(validSecs)*time.Second))
expirationVal = fmt.Sprintf("DATE_ADD(NOW(), INTERVAL %d SECOND)", validSecs)
}
_, err = db.Exec("INSERT INTO accesstokens (token, user_id, created, one_time, expires) VALUES (?, ?, ?, ?, ?)", string(binTok), userID, now, oneTime, expirationVal)
_, err = db.Exec("INSERT INTO accesstokens (token, user_id, created, one_time, expires) VALUES (?, ?, NOW(), ?, "+expirationVal+")", string(binTok), userID, oneTime)
if err != nil {
log.Error("Couldn't INSERT accesstoken: %v", err)
return "", err
@@ -564,16 +557,18 @@ func (db *datastore) CreatePost(userID, collID int64, post *SubmittedPost) (*Pos
}
}
_, err = db.Exec("INSERT INTO posts (id, slug, title, content, text_appearance, language, rtl, owner_id, collection_id, updated, view_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, "+sqlNow+", ?)", friendlyID, slug, post.Title, post.Content, appearance, post.Language, post.IsRTL, ownerID, ownerCollID, 0)
_, err = db.Exec("INSERT INTO posts (id, slug, title, content, text_appearance, language, rtl, owner_id, collection_id, updated, view_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), ?)", friendlyID, slug, post.Title, post.Content, appearance, post.Language, post.IsRTL, ownerID, ownerCollID, 0)
if err != nil {
if isConstraintError(err) {
// Duplicate entry error; try a new slug
// TODO: make this a little more robust
// TODO: reuse exact same db.Exec statement as above
slug = sql.NullString{id.GenSafeUniqueSlug(slug.String), true}
_, err = db.Exec("INSERT INTO posts (id, slug, title, content, text_appearance, language, rtl, owner_id, collection_id, updated, view_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, "+sqlNow+", ?)", friendlyID, slug, post.Title, post.Content, appearance, post.Language, post.IsRTL, ownerID, ownerCollID, 0)
if err != nil {
return nil, handleFailedPostInsert(fmt.Errorf("Retried slug generation, still failed: %v", err))
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
if mysqlErr.Number == mySQLErrDuplicateKey {
// Duplicate entry error; try a new slug
// TODO: make this a little more robust
// TODO: reuse exact same db.Exec statement as above
slug = sql.NullString{id.GenSafeUniqueSlug(slug.String), true}
_, err = db.Exec("INSERT INTO posts (id, slug, title, content, text_appearance, language, rtl, owner_id, collection_id, updated, view_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), ?)", friendlyID, slug, post.Title, post.Content, appearance, post.Language, post.IsRTL, ownerID, ownerCollID, 0)
if err != nil {
return nil, handleFailedPostInsert(fmt.Errorf("Retried slug generation, still failed: %v", err))
}
} else {
return nil, handleFailedPostInsert(err)
}
@@ -655,7 +650,7 @@ func (db *datastore) UpdateOwnedPost(post *AuthenticatedPost, userID int64) erro
return ErrPostNoUpdatableVals
}
queryUpdates += sep + "updated = " + sqlNow
queryUpdates += sep + "updated = NOW()"
res, err := db.Exec("UPDATE posts SET "+queryUpdates+" WHERE id = ? AND "+authCondition, params...)
if err != nil {
@@ -824,7 +819,7 @@ func (db *datastore) UpdateCollection(c *SubmittedCollection, alias string) erro
return nil
}
const postCols = "id, slug, text_appearance, language, rtl, owner_id, collection_id, pinned_position, created, updated, view_count, title, content"
const postCols = "id, slug, text_appearance, language, rtl, privacy, owner_id, collection_id, pinned_position, created, updated, view_count, title, content"
// getEditablePost returns a PublicPost with the given ID only if the given
// edit token is valid for the post.
@@ -835,7 +830,7 @@ func (db *datastore) GetEditablePost(id, editToken string) (*PublicPost, error)
p := &Post{}
row := db.QueryRow("SELECT "+postCols+", (SELECT username FROM users WHERE users.id = posts.owner_id) AS username FROM posts WHERE id = ? LIMIT 1", id)
err := row.Scan(&p.ID, &p.Slug, &p.Font, &p.Language, &p.RTL, &p.OwnerID, &p.CollectionID, &p.PinnedPosition, &p.Created, &p.Updated, &p.ViewCount, &p.Title, &p.Content, &ownerName)
err := row.Scan(&p.ID, &p.Slug, &p.Font, &p.Language, &p.RTL, &p.Privacy, &p.OwnerID, &p.CollectionID, &p.PinnedPosition, &p.Created, &p.Updated, &p.ViewCount, &p.Title, &p.Content, &ownerName)
switch {
case err == sql.ErrNoRows:
return nil, ErrPostNotFound
@@ -882,7 +877,7 @@ func (db *datastore) GetPost(id string, collectionID int64) (*PublicPost, error)
where = "id = ?"
}
row = db.QueryRow("SELECT "+postCols+", (SELECT username FROM users WHERE users.id = posts.owner_id) AS username FROM posts WHERE "+where+" LIMIT 1", params...)
err := row.Scan(&p.ID, &p.Slug, &p.Font, &p.Language, &p.RTL, &p.OwnerID, &p.CollectionID, &p.PinnedPosition, &p.Created, &p.Updated, &p.ViewCount, &p.Title, &p.Content, &ownerName)
err := row.Scan(&p.ID, &p.Slug, &p.Font, &p.Language, &p.RTL, &p.Privacy, &p.OwnerID, &p.CollectionID, &p.PinnedPosition, &p.Created, &p.Updated, &p.ViewCount, &p.Title, &p.Content, &ownerName)
switch {
case err == sql.ErrNoRows:
if collectionID > 0 {
@@ -914,7 +909,7 @@ func (db *datastore) GetOwnedPost(id string, ownerID int64) (*PublicPost, error)
where := "id = ? AND owner_id = ?"
params := []interface{}{id, ownerID}
row = db.QueryRow("SELECT "+postCols+" FROM posts WHERE "+where+" LIMIT 1", params...)
err := row.Scan(&p.ID, &p.Slug, &p.Font, &p.Language, &p.RTL, &p.OwnerID, &p.CollectionID, &p.PinnedPosition, &p.Created, &p.Updated, &p.ViewCount, &p.Title, &p.Content)
err := row.Scan(&p.ID, &p.Slug, &p.Font, &p.Language, &p.RTL, &p.Privacy, &p.OwnerID, &p.CollectionID, &p.PinnedPosition, &p.Created, &p.Updated, &p.ViewCount, &p.Title, &p.Content)
switch {
case err == sql.ErrNoRows:
return nil, ErrPostNotFound
@@ -967,7 +962,7 @@ func (db *datastore) GetPostsCount(c *CollectionObj, includeFuture bool) {
var count int64
timeCondition := ""
if !includeFuture {
timeCondition = "AND created <= " + sqlNow
timeCondition = "AND created <= NOW()"
}
err := db.QueryRow("SELECT COUNT(*) FROM posts WHERE collection_id = ? AND pinned_position IS NULL "+timeCondition, c.ID).Scan(&count)
switch {
@@ -1006,7 +1001,7 @@ func (db *datastore) GetPosts(c *Collection, page int, includeFuture bool) (*[]P
}
timeCondition := ""
if !includeFuture {
timeCondition = "AND created <= " + sqlNow
timeCondition = "AND created <= NOW()"
}
rows, err := db.Query("SELECT "+postCols+" FROM posts WHERE collection_id = ? AND pinned_position IS NULL "+timeCondition+" ORDER BY created "+order+limitStr, collID)
if err != nil {
@@ -1019,7 +1014,7 @@ func (db *datastore) GetPosts(c *Collection, page int, includeFuture bool) (*[]P
posts := []PublicPost{}
for rows.Next() {
p := &Post{}
err = rows.Scan(&p.ID, &p.Slug, &p.Font, &p.Language, &p.RTL, &p.OwnerID, &p.CollectionID, &p.PinnedPosition, &p.Created, &p.Updated, &p.ViewCount, &p.Title, &p.Content)
err = rows.Scan(&p.ID, &p.Slug, &p.Font, &p.Language, &p.RTL, &p.Privacy, &p.OwnerID, &p.CollectionID, &p.PinnedPosition, &p.Created, &p.Updated, &p.ViewCount, &p.Title, &p.Content)
if err != nil {
log.Error("Failed scanning row: %v", err)
break
@@ -1063,7 +1058,7 @@ func (db *datastore) GetPostsTagged(c *Collection, tag string, page int, include
}
timeCondition := ""
if !includeFuture {
timeCondition = "AND created <= " + sqlNow
timeCondition = "AND created <= NOW()"
}
rows, err := db.Query("SELECT "+postCols+" FROM posts WHERE collection_id = ? AND LOWER(content) RLIKE ? "+timeCondition+" ORDER BY created "+order+limitStr, collID, "#"+strings.ToLower(tag)+"[[:>:]]")
if err != nil {
@@ -1076,7 +1071,7 @@ func (db *datastore) GetPostsTagged(c *Collection, tag string, page int, include
posts := []PublicPost{}
for rows.Next() {
p := &Post{}
err = rows.Scan(&p.ID, &p.Slug, &p.Font, &p.Language, &p.RTL, &p.OwnerID, &p.CollectionID, &p.PinnedPosition, &p.Created, &p.Updated, &p.ViewCount, &p.Title, &p.Content)
err = rows.Scan(&p.ID, &p.Slug, &p.Font, &p.Language, &p.RTL, &p.Privacy, &p.OwnerID, &p.CollectionID, &p.PinnedPosition, &p.Created, &p.Updated, &p.ViewCount, &p.Title, &p.Content)
if err != nil {
log.Error("Failed scanning row: %v", err)
break
@@ -1138,16 +1133,17 @@ func (db *datastore) CanCollect(cpr *ClaimPostRequest, userID int64) bool {
func (db *datastore) AttemptClaim(p *ClaimPostRequest, query string, params []interface{}, slugIdx int) (sql.Result, error) {
qRes, err := db.Exec(query, params...)
if err != nil {
if isConstraintError(err) && slugIdx > -1 {
s := id.GenSafeUniqueSlug(p.Slug)
if s == p.Slug {
// Sanity check to prevent infinite recursion
return qRes, fmt.Errorf("GenSafeUniqueSlug generated nothing unique: %s", s)
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
if mysqlErr.Number == mySQLErrDuplicateKey && slugIdx > -1 {
s := id.GenSafeUniqueSlug(p.Slug)
if s == p.Slug {
// Sanity check to prevent infinite recursion
return qRes, fmt.Errorf("GenSafeUniqueSlug generated nothing unique: %s", s)
}
p.Slug = s
params[slugIdx] = p.Slug
return db.AttemptClaim(p, query, params, slugIdx)
}
p.Slug = s
params[slugIdx] = p.Slug
return db.AttemptClaim(p, query, params, slugIdx)
}
return qRes, fmt.Errorf("attemptClaim: %s", err)
}
@@ -1435,7 +1431,7 @@ func (db *datastore) GetLastPinnedPostPos(collID int64) int64 {
}
func (db *datastore) GetPinnedPosts(coll *CollectionObj) (*[]PublicPost, error) {
rows, err := db.Query("SELECT id, slug, title, SUBSTR(content, 80), pinned_position FROM posts WHERE collection_id = ? AND pinned_position IS NOT NULL ORDER BY pinned_position ASC", coll.ID)
rows, err := db.Query("SELECT id, slug, title, LEFT(content, 80), pinned_position FROM posts WHERE collection_id = ? AND pinned_position IS NOT NULL ORDER BY pinned_position ASC", coll.ID)
if err != nil {
log.Error("Failed selecting pinned posts: %v", err)
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve pinned posts."}
@@ -1698,8 +1694,10 @@ func (db *datastore) ChangeSettings(app *app, u *User, s *userSettings) error {
_, err = t.Exec("UPDATE users SET username = ? WHERE id = ?", newUsername, u.ID)
if err != nil {
t.Rollback()
if isConstraintError(err) {
return impart.HTTPError{http.StatusConflict, "Username is already taken."}
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
if mysqlErr.Number == mySQLErrDuplicateKey {
return impart.HTTPError{http.StatusConflict, "Username is already taken."}
}
}
log.Error("Unable to update users table: %v", err)
return ErrInternalGeneral
@@ -1708,8 +1706,10 @@ func (db *datastore) ChangeSettings(app *app, u *User, s *userSettings) error {
_, err = t.Exec("UPDATE collections SET alias = ? WHERE alias = ? AND owner_id = ?", newUsername, u.Username, u.ID)
if err != nil {
t.Rollback()
if isConstraintError(err) {
return impart.HTTPError{http.StatusConflict, "Username is already taken."}
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
if mysqlErr.Number == mySQLErrDuplicateKey {
return impart.HTTPError{http.StatusConflict, "Username is already taken."}
}
}
log.Error("Unable to update collection: %v", err)
return ErrInternalGeneral

58
keys.go
View File

@@ -1,7 +1,23 @@
package writefreely
import (
"crypto/rand"
"github.com/writeas/web-core/log"
"io/ioutil"
"os"
"path/filepath"
)
const (
keysDir = "keys"
encKeysBytes = 32
)
var (
emailKeyPath = filepath.Join(keysDir, "email.aes256")
cookieAuthKeyPath = filepath.Join(keysDir, "cookies_auth.aes256")
cookieKeyPath = filepath.Join(keysDir, "cookies_enc.aes256")
)
type keychain struct {
@@ -12,20 +28,56 @@ func initKeys(app *app) error {
var err error
app.keys = &keychain{}
app.keys.emailKey, err = ioutil.ReadFile("keys/email.aes256")
app.keys.emailKey, err = ioutil.ReadFile(emailKeyPath)
if err != nil {
return err
}
app.keys.cookieAuthKey, err = ioutil.ReadFile("keys/cookies_auth.aes256")
app.keys.cookieAuthKey, err = ioutil.ReadFile(cookieAuthKeyPath)
if err != nil {
return err
}
app.keys.cookieKey, err = ioutil.ReadFile("keys/cookies_enc.aes256")
app.keys.cookieKey, err = ioutil.ReadFile(cookieKeyPath)
if err != nil {
return err
}
return nil
}
// generateKey generates a key at the given path used for the encryption of
// certain user data. Because user data becomes unrecoverable without these
// keys, this won't overwrite any existing key, and instead outputs a message.
func generateKey(path string) error {
// Check if key file exists
if _, err := os.Stat(path); !os.IsNotExist(err) {
log.Info("%s already exists. rm the file if you understand the consquences.", path)
return nil
}
log.Info("Generating %s.", path)
b, err := generateBytes(encKeysBytes)
if err != nil {
log.Error("FAILED. %s. Run writefreely --gen-keys again.", err)
return err
}
err = ioutil.WriteFile(path, b, 0600)
if err != nil {
log.Error("FAILED writing file: %s", err)
return err
}
log.Info("Success.")
return nil
}
// generateBytes returns securely generated random bytes.
func generateBytes(n int) ([]byte, error) {
b := make([]byte, n)
_, err := rand.Read(b)
if err != nil {
return nil, err
}
return b, nil
}

25
keys.sh
View File

@@ -1,25 +0,0 @@
#!/bin/bash
#
# keys.sh generates keys used for the encryption of certain user data. Because
# user data becomes unrecoverable without these keys, the script and won't
# overwrite any existing keys unless you explicitly delete them.
#
# Generate cookie encryption and authentication keys
if [[ ! -e "$(pwd)/keys/cookies_enc.aes256" ]]; then
dd of=$(pwd)/keys/cookies_enc.aes256 if=/dev/urandom bs=32 count=1
else
echo "cookies key already exists! rm keys/cookies_enc.aes256 if you understand the consquences."
fi
if [[ ! -e "$(pwd)/keys/cookies_auth.aes256" ]]; then
dd of=$(pwd)/keys/cookies_auth.aes256 if=/dev/urandom bs=32 count=1
else
echo "cookies authentication key already exists! rm keys/cookies_auth.aes256 if you understand the consquences."
fi
# Generate email encryption key
if [[ ! -e "$(pwd)/keys/email.aes256" ]]; then
dd of=$(pwd)/keys/email.aes256 if=/dev/urandom bs=32 count=1
else
echo "email key already exists! rm keys/email.aes256 if you understand the consquences."
fi

View File

@@ -1,4 +1,4 @@
Keys
====
Contains keys for encrypting database and session data. Generate necessary keys by running (from the root of the project) `./keys.sh`.
Contains keys for encrypting database and session data. Generate necessary keys by running (from the root of the project) `writefreely --gen-keys`.

View File

@@ -5,7 +5,6 @@ import (
"github.com/writeas/web-core/log"
"github.com/writeas/writefreely/config"
"strings"
"time"
)
type nodeInfoResolver struct {
@@ -68,10 +67,6 @@ func (r nodeInfoResolver) Usage() (nodeinfo.Usage, error) {
log.Error("Unable to fetch post counts: %v", err)
}
ago_1 := time.Now().Add(-(30*24*time.Hour))
ago_6 := time.Now().Add(-(30*24*time.Hour*6))
if r.cfg.App.PublicStats {
// Display bi-yearly / monthly stats
err = r.db.QueryRow(`SELECT COUNT(*) FROM (
@@ -80,7 +75,7 @@ FROM posts
INNER JOIN collections c
ON collection_id = c.id
WHERE collection_id IS NOT NULL
AND updated > ? co`, ago_6).Scan(&activeHalfYear)
AND updated > DATE_SUB(NOW(), INTERVAL 6 MONTH)) co`).Scan(&activeHalfYear)
err = r.db.QueryRow(`SELECT COUNT(*) FROM (
SELECT DISTINCT collection_id
@@ -88,7 +83,7 @@ FROM posts
INNER JOIN FROM collections c
ON collection_id = c.id
WHERE collection_id IS NOT NULL
AND updated > ? co`, ago_1).Scan(&activeMonth)
AND updated > DATE_SUB(NOW(), INTERVAL 1 MONTH)) co`).Scan(&activeMonth)
}
return nodeinfo.Usage{

View File

@@ -80,6 +80,7 @@ type (
Font string `db:"text_appearance" json:"appearance"`
Language zero.String `db:"language" json:"language"`
RTL zero.Bool `db:"rtl" json:"rtl"`
Privacy int64 `db:"privacy" json:"-"`
OwnerID null.Int `db:"owner_id" json:"-"`
CollectionID null.Int `db:"collection_id" json:"-"`
PinnedPosition null.Int `db:"pinned_position" json:"-"`

View File

@@ -17,7 +17,7 @@ CREATE TABLE IF NOT EXISTS `accesstokens` (
`expires` datetime DEFAULT NULL,
`user_agent` varchar(255) NOT NULL,
PRIMARY KEY (`token`)
);
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
-- --------------------------------------------------------
@@ -28,9 +28,9 @@ CREATE TABLE IF NOT EXISTS `accesstokens` (
CREATE TABLE IF NOT EXISTS `collectionattributes` (
`collection_id` int(6) NOT NULL,
`attribute` varchar(128) NOT NULL,
`value` varchar(255) NOT NULL,
`value` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
PRIMARY KEY (`collection_id`,`attribute`)
);
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
-- --------------------------------------------------------
@@ -43,7 +43,7 @@ CREATE TABLE IF NOT EXISTS `collectionkeys` (
`public_key` blob NOT NULL,
`private_key` blob NOT NULL,
PRIMARY KEY (`collection_id`)
);
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
-- --------------------------------------------------------
@@ -55,7 +55,7 @@ CREATE TABLE IF NOT EXISTS `collectionpasswords` (
`collection_id` int(6) NOT NULL,
`password` char(60) NOT NULL,
PRIMARY KEY (`collection_id`)
);
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
-- --------------------------------------------------------
@@ -67,7 +67,7 @@ CREATE TABLE IF NOT EXISTS `collectionredirects` (
`prev_alias` varchar(100) NOT NULL,
`new_alias` varchar(100) NOT NULL,
PRIMARY KEY (`prev_alias`)
);
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
-- --------------------------------------------------------
@@ -76,18 +76,19 @@ CREATE TABLE IF NOT EXISTS `collectionredirects` (
--
CREATE TABLE IF NOT EXISTS `collections` (
`id` integer PRIMARY KEY ASC,
`alias` varchar(100) DEFAULT NULL,
`title` varchar(255) NOT NULL,
`description` varchar(160) NOT NULL,
`id` int(6) NOT NULL AUTO_INCREMENT,
`alias` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
`title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
`description` varchar(160) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
`style_sheet` text,
`script` text,
`script` text CHARACTER SET utf8mb4 COLLATE utf8mb4_bin,
`format` varchar(8) DEFAULT NULL,
`privacy` tinyint(1) NOT NULL,
`owner_id` int(6) NOT NULL,
`view_count` int(6) NOT NULL,
CONSTRAINT `alias` UNIQUE (`alias`)
);
PRIMARY KEY (`id`),
UNIQUE KEY `alias` (`alias`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
-- --------------------------------------------------------
@@ -102,18 +103,20 @@ CREATE TABLE IF NOT EXISTS `posts` (
`text_appearance` char(4) NOT NULL DEFAULT 'norm',
`language` char(2) DEFAULT NULL,
`rtl` tinyint(1) DEFAULT NULL,
`privacy` tinyint(1) NOT NULL,
`owner_id` int(6) DEFAULT NULL,
`collection_id` int(6) DEFAULT NULL,
`pinned_position` tinyint(1) DEFAULT NULL,
`pinned_position` tinyint(1) UNSIGNED DEFAULT NULL,
`created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated` timestamp NOT NULL,
`view_count` int(6) NOT NULL,
`title` varchar(160) NOT NULL,
`content` text NOT NULL,
`title` varchar(160) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
`content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
PRIMARY KEY (`id`),
CONSTRAINT `id_slug` UNIQUE (`collection_id`,`slug`),
CONSTRAINT `owner_id` UNIQUE (`owner_id`,`id`)
);
UNIQUE KEY `id_slug` (`collection_id`,`slug`),
UNIQUE KEY `owner_id` (`owner_id`,`id`),
KEY `privacy_id` (`privacy`,`id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
-- --------------------------------------------------------
@@ -126,7 +129,7 @@ CREATE TABLE IF NOT EXISTS `remotefollows` (
`remote_user_id` int(11) NOT NULL,
`created` datetime NOT NULL,
PRIMARY KEY (`collection_id`,`remote_user_id`)
);
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
-- --------------------------------------------------------
@@ -135,11 +138,12 @@ CREATE TABLE IF NOT EXISTS `remotefollows` (
--
CREATE TABLE IF NOT EXISTS `remoteuserkeys` (
`id` varchar(255) PRIMARY KEY NOT NULL,
`id` varchar(255) NOT NULL,
`remote_user_id` int(11) NOT NULL,
`public_key` blob NOT NULL,
CONSTRAINT `follower_id` UNIQUE (`remote_user_id`)
);
PRIMARY KEY (`id`),
UNIQUE KEY `follower_id` (`remote_user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
-- --------------------------------------------------------
@@ -148,13 +152,13 @@ CREATE TABLE IF NOT EXISTS `remoteuserkeys` (
--
CREATE TABLE IF NOT EXISTS `remoteusers` (
`id` integer PRIMARY KEY ASC,
`id` int(11) NOT NULL AUTO_INCREMENT,
`actor_id` varchar(255) NOT NULL,
`inbox` varchar(255) NOT NULL,
`shared_inbox` varchar(255) NOT NULL,
`followers` varchar(255) NOT NULL,
CONSTRAINT `collection_id` UNIQUE (`actor_id`)
);
PRIMARY KEY (`id`),
UNIQUE KEY `collection_id` (`actor_id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
-- --------------------------------------------------------
@@ -163,11 +167,11 @@ CREATE TABLE IF NOT EXISTS `remoteusers` (
--
CREATE TABLE IF NOT EXISTS `userattributes` (
`user_id` integer NOT NULL,
`user_id` int(6) NOT NULL,
`attribute` varchar(64) NOT NULL,
`value` varchar(255) NOT NULL,
`value` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
PRIMARY KEY (`user_id`,`attribute`)
);
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
-- --------------------------------------------------------
@@ -176,10 +180,11 @@ CREATE TABLE IF NOT EXISTS `userattributes` (
--
CREATE TABLE IF NOT EXISTS `users` (
`id` integer PRIMARY KEY ASC,
`username` varchar(100) NOT NULL,
`password` char(60) NOT NULL,
`id` int(6) NOT NULL AUTO_INCREMENT,
`username` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
`password` char(60) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
`email` varbinary(255) DEFAULT NULL,
`created` datetime NOT NULL,
CONSTRAINT `username` UNIQUE (`username`)
);
PRIMARY KEY (`id`),
UNIQUE KEY `username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;

View File

@@ -20,14 +20,14 @@
<meta name="twitter:card" content="summary">
<meta name="twitter:description" content="{{.Summary}}">
<meta name="twitter:title" content="{{.PlainDisplayTitle}} &mdash; {{if .Collection.Title}}{{.Collection.Title}}{{else}}{{.Collection.Alias}}{{end}}">
{{if gt (len .Images) 0}}<meta name="twitter:image" content="{{index .Images 0}}">{{else}}<meta name="twitter:image" content="https://write.as/img/w-sq-light.png">{{end}}
{{if gt (len .Images) 0}}<meta name="twitter:image" content="{{index .Images 0}}">{{else}}<meta name="twitter:image" content="{{.Collection.AvatarURL}}">{{end}}
<meta property="og:title" content="{{.PlainDisplayTitle}}" />
<meta property="og:description" content="{{.Summary}}" />
<meta property="og:site_name" content="{{.Collection.DisplayTitle}}" />
<meta property="og:type" content="article" />
<meta property="og:url" content="{{.CanonicalURL}}" />
<meta property="og:updated_time" content="{{.Created8601}}" />
{{range .Images}}<meta property="og:image" content="{{.}}" />{{else}}<meta property="og:image" content="https://write.as/img/w-sq-light.png">{{end}}
{{range .Images}}<meta property="og:image" content="{{.}}" />{{else}}<meta property="og:image" content="{{.Collection.AvatarURL}}">{{end}}
<meta property="article:published_time" content="{{.Created8601}}">
{{if .Collection.StyleSheet}}<style type="text/css">{{.Collection.StyleSheetDisplay}}</style>{{end}}
{{if .Collection.RenderMathJax}}

View File

@@ -23,10 +23,12 @@
<meta name="twitter:site" content="@writeas__">
<meta name="twitter:description" content="{{.Tag}} posts on {{.Collection.DisplayTitle}}">
<meta name="twitter:title" content="{{.Tag}} &mdash; {{.Collection.DisplayTitle}}">
<meta name="twitter:image" content="{{.Collection.AvatarURL}}">
<meta property="og:title" content="{{.Tag}} &mdash; {{.Collection.DisplayTitle}}" />
<meta property="og:site_name" content="{{.DisplayTitle}}" />
<meta property="og:type" content="article" />
<meta property="og:url" content="{{.CanonicalURL}}tag:{{.Tag}}" />
<meta property="og:image" content="{{.Collection.AvatarURL}}">
{{if .Collection.StyleSheet}}<style type="text/css">{{.Collection.StyleSheetDisplay}}</style>{{end}}
{{if .Collection.RenderMathJax}}
<script type="text/x-mathjax-config">

View File

@@ -19,12 +19,14 @@
<meta itemprop="description" content="{{.Description}}">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="{{.DisplayTitle}}">
<meta name="twitter:image" content="{{.AvatarURL}}">
<meta name="twitter:description" content="{{.Description}}">
<meta property="og:title" content="{{.DisplayTitle}}" />
<meta property="og:site_name" content="{{.DisplayTitle}}" />
<meta property="og:type" content="article" />
<meta property="og:url" content="{{.CanonicalURL}}" />
<meta property="og:description" content="{{.Description}}" />
<meta property="og:image" content="{{.AvatarURL}}">
{{if .StyleSheet}}<style type="text/css">{{.StyleSheetDisplay}}</style>{{end}}
{{if .RenderMathJax}}
<script type="text/x-mathjax-config">