5 Commits

10 changed files with 144 additions and 241 deletions

View File

@@ -1,26 +1,9 @@
# Contributing to WriteFreely # Contributing to WriteFreely (lupine's fork)
Welcome! We're glad you're interested in contributing to the WriteFreely project. This is a fork of https://github.com/writeas/writefreely
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. You probably want to contribute there instead of here.
## Asking Questions 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
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. run a mysql server on the limited hardware I have at home.
## 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

@@ -1,30 +1,10 @@
  # WriteFreely (lupine's fork)
<p align="center">
<a href="https://writefreely.org"><img src="https://writefreely.org/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>
</p>
&nbsp;
WriteFreely is a beautifully pared-down blogging platform that's simple on the surface, yet powerful underneath. 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. 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.
**[Start a blog on our instance](https://write.as/new/blog/federated)** See [CONTRIBUTING.md](contributing.md) for more information on why it's forked
[Try the editor](https://write.as/new)
[Find another instance](https://writefreely.org/instances)
## Features ## Features
@@ -38,16 +18,17 @@ It's designed to be flexible and share your writing widely, so it's built around
> **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. > **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, download the [latest release](https://github.com/writeas/writefreely/releases/latest) for your OS. It includes everything you need to start your blog. First, build writefreely.
Now extract the files from the archive, change into the directory, and do the following steps: Now change into the directory, and do the following steps:
```bash ```bash
# 1) Log into MySQL and run: # 1) Start an SQLite3 database
# CREATE DATABASE writefreely; sqlite3 writefreely.sqlite3
# #
# 2) Import the schema with: # 2) Import the schema with:
mysql -u YOURUSERNAME -p writefreely < schema.sql .read schema.sql
# 3) Configure your blog # 3) Configure your blog
./writefreely --config ./writefreely --config

View File

@@ -7,7 +7,6 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/go-sql-driver/mysql"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/writeas/activity/streams" "github.com/writeas/activity/streams"
"github.com/writeas/httpsig" "github.com/writeas/httpsig"
@@ -344,12 +343,10 @@ func handleFetchCollectionInbox(app *app, w http.ResponseWriter, r *http.Request
// Add follower locally, since it wasn't found before // 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) res, err := t.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox) VALUES (?, ?, ?)", fullActor.ID, fullActor.Inbox, fullActor.Endpoints.SharedInbox)
if err != nil { if err != nil {
if mysqlErr, ok := err.(*mysql.MySQLError); ok { if isConstraintError(err) {
if mysqlErr.Number != mySQLErrDuplicateKey { t.Rollback()
t.Rollback() log.Error("Couldn't add new remoteuser in DB: %v\n", err)
log.Error("Couldn't add new remoteuser in DB: %v\n", err) return
return
}
} else { } else {
t.Rollback() t.Rollback()
log.Error("Couldn't add new remoteuser in DB: %v\n", err) log.Error("Couldn't add new remoteuser in DB: %v\n", err)
@@ -367,12 +364,10 @@ func handleFetchCollectionInbox(app *app, w http.ResponseWriter, r *http.Request
// Add in key // Add in key
_, err = t.Exec("INSERT INTO remoteuserkeys (id, remote_user_id, public_key) VALUES (?, ?, ?)", fullActor.PublicKey.ID, followerID, fullActor.PublicKey.PublicKeyPEM) _, err = t.Exec("INSERT INTO remoteuserkeys (id, remote_user_id, public_key) VALUES (?, ?, ?)", fullActor.PublicKey.ID, followerID, fullActor.PublicKey.PublicKeyPEM)
if err != nil { if err != nil {
if mysqlErr, ok := err.(*mysql.MySQLError); ok { if isConstraintError(err) {
if mysqlErr.Number != mySQLErrDuplicateKey { t.Rollback()
t.Rollback() log.Error("Couldn't add follower keys in DB: %v\n", err)
log.Error("Couldn't add follower keys in DB: %v\n", err) return
return
}
} else { } else {
t.Rollback() t.Rollback()
log.Error("Couldn't add follower keys in DB: %v\n", err) log.Error("Couldn't add follower keys in DB: %v\n", err)
@@ -382,14 +377,12 @@ func handleFetchCollectionInbox(app *app, w http.ResponseWriter, r *http.Request
} }
// Add follow // Add follow
_, err = t.Exec("INSERT INTO remotefollows (collection_id, remote_user_id, created) VALUES (?, ?, NOW())", c.ID, followerID) _, err = t.Exec("INSERT INTO remotefollows (collection_id, remote_user_id, created) VALUES (?, ?, "+sqlNow+")", c.ID, followerID)
if err != nil { if err != nil {
if mysqlErr, ok := err.(*mysql.MySQLError); ok { if isConstraintError(err) {
if mysqlErr.Number != mySQLErrDuplicateKey { t.Rollback()
t.Rollback() log.Error("Couldn't add follower in DB: %v\n", err)
log.Error("Couldn't add follower in DB: %v\n", err) return
return
}
} else { } else {
t.Rollback() t.Rollback()
log.Error("Couldn't add follower in DB: %v\n", err) log.Error("Couldn't add follower in DB: %v\n", err)

21
app.go
View File

@@ -4,7 +4,7 @@ import (
"database/sql" "database/sql"
"flag" "flag"
"fmt" "fmt"
_ "github.com/go-sql-driver/mysql" _ "github.com/mattn/go-sqlite3"
"html/template" "html/template"
"net/http" "net/http"
"os" "os"
@@ -203,15 +203,9 @@ func Serve() {
app.formDecoder.RegisterConverter(sql.NullFloat64{}, converter.ConvertSQLNullFloat64) app.formDecoder.RegisterConverter(sql.NullFloat64{}, converter.ConvertSQLNullFloat64)
// Check database configuration // Check database configuration
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 == "" { if app.cfg.Database.Database == "" {
app.cfg.Database.Database = "writeas" log.Error("Database filename not set.")
os.Exit(1)
} }
connectToDatabase(app) connectToDatabase(app)
@@ -249,7 +243,7 @@ func Serve() {
http.Handle("/", r) http.Handle("/", r)
log.Info("Serving on http://localhost:%d\n", app.cfg.Server.Port) log.Info("Serving on http://localhost:%d\n", app.cfg.Server.Port)
log.Info("---") log.Info("---")
err = http.ListenAndServe(fmt.Sprintf(":%d", app.cfg.Server.Port), nil) err = http.ListenAndServe(fmt.Sprintf("localhost:%d", app.cfg.Server.Port), nil)
if err != nil { if err != nil {
log.Error("Unable to start: %v", err) log.Error("Unable to start: %v", err)
os.Exit(1) os.Exit(1)
@@ -258,7 +252,12 @@ func Serve() {
func connectToDatabase(app *app) { func connectToDatabase(app *app) {
log.Info("Connecting to database...") log.Info("Connecting to 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 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)
if err != nil { if err != nil {
log.Error("%s", err) log.Error("%s", err)
os.Exit(1) os.Exit(1)

View File

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

View File

@@ -65,30 +65,7 @@ func Configure() (*SetupData, error) {
prompt = promptui.Prompt{ prompt = promptui.Prompt{
Templates: tmpls, Templates: tmpls,
Label: "Username", Label: "Database filename",
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, Validate: validateNonEmpty,
Default: data.Config.Database.Database, Default: data.Config.Database.Database,
} }
@@ -97,29 +74,6 @@ func Configure() (*SetupData, error) {
return data, err 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() fmt.Println()
title(" App setup ") title(" App setup ")
fmt.Println() fmt.Println()

View File

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

View File

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

View File

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

View File

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