Compare commits
10 Commits
sqlite3-mu
...
master
Author | SHA1 | Date | |
---|---|---|---|
![]() |
3576ab15d1 | ||
![]() |
78953f27f0 | ||
![]() |
002d0e6309 | ||
![]() |
b8ce944b5c | ||
![]() |
7bc873580c | ||
![]() |
96c197453d | ||
![]() |
561568343a | ||
![]() |
c996ae1cad | ||
![]() |
393f6d6834 | ||
![]() |
bbed72ff6b |
24
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
24
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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**:
|
9
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
9
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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
|
@@ -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
|
## Asking Questions
|
||||||
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.
|
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.
|
||||||
|
4
Makefile
4
Makefile
@@ -20,8 +20,8 @@ run:
|
|||||||
deps :
|
deps :
|
||||||
$(GOGET) -v ./...
|
$(GOGET) -v ./...
|
||||||
|
|
||||||
install :
|
install : build
|
||||||
./keys.sh
|
cmd/writefreely/$(BINARY_NAME) --gen-keys
|
||||||
cd less/; $(MAKE) install $(MFLAGS)
|
cd less/; $(MAKE) install $(MFLAGS)
|
||||||
|
|
||||||
ui : force_look
|
ui : force_look
|
||||||
|
46
README.md
46
README.md
@@ -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>
|
||||||
|
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
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
|
## 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.
|
> **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
|
```bash
|
||||||
# 1) Start an SQLite3 database
|
# 1) Log into MySQL and run:
|
||||||
sqlite3 writefreely.sqlite3
|
# CREATE DATABASE writefreely;
|
||||||
|
|
||||||
#
|
#
|
||||||
# 2) Import the schema with:
|
# 2) Import the schema with:
|
||||||
.read schema.sql
|
mysql -u YOURUSERNAME -p writefreely < schema.sql
|
||||||
|
|
||||||
# 3) Configure your blog
|
# 3) Configure your blog
|
||||||
./writefreely --config
|
./writefreely --config
|
||||||
|
|
||||||
# 4) Generate data encryption keys (especially for production)
|
# 4) Generate data encryption keys
|
||||||
./keys.sh
|
./writefreely --gen-keys
|
||||||
|
|
||||||
# 5) Run
|
# 5) Run
|
||||||
./writefreely
|
./writefreely
|
||||||
@@ -60,9 +82,7 @@ Ready to hack on your site? Here's a quick overview.
|
|||||||
go get github.com/writeas/writefreely/cmd/writefreely
|
go get github.com/writeas/writefreely/cmd/writefreely
|
||||||
```
|
```
|
||||||
|
|
||||||
Create your database, import the schema, and configure your site [as shown above](#quick-start).
|
Create your database, import the schema, and configure your site [as shown above](#quick-start). Then generate the remaining files you'll need:
|
||||||
|
|
||||||
Now generate the CSS:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make install # Generates encryption keys; installs LESS compiler
|
make install # Generates encryption keys; installs LESS compiler
|
||||||
|
@@ -7,6 +7,7 @@ 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"
|
||||||
@@ -254,6 +255,16 @@ func handleFetchCollectionInbox(app *app, w http.ResponseWriter, r *http.Request
|
|||||||
b, _ := json.Marshal(m)
|
b, _ := json.Marshal(m)
|
||||||
log.Info("Follow: %s", b)
|
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())
|
a.AppendObject(f.Raw())
|
||||||
_, to = f.GetActor(0)
|
_, to = f.GetActor(0)
|
||||||
obj := f.Raw().GetObjectIRI(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
|
// 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 isConstraintError(err) {
|
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
|
||||||
t.Rollback()
|
if mysqlErr.Number != mySQLErrDuplicateKey {
|
||||||
log.Error("Couldn't add new remoteuser in DB: %v\n", err)
|
t.Rollback()
|
||||||
return
|
log.Error("Couldn't add new remoteuser in DB: %v\n", err)
|
||||||
|
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)
|
||||||
@@ -364,10 +377,12 @@ 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 isConstraintError(err) {
|
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
|
||||||
t.Rollback()
|
if mysqlErr.Number != mySQLErrDuplicateKey {
|
||||||
log.Error("Couldn't add follower keys in DB: %v\n", err)
|
t.Rollback()
|
||||||
return
|
log.Error("Couldn't add follower keys in DB: %v\n", err)
|
||||||
|
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)
|
||||||
@@ -377,12 +392,14 @@ 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 (?, ?, "+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 err != nil {
|
||||||
if isConstraintError(err) {
|
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
|
||||||
t.Rollback()
|
if mysqlErr.Number != mySQLErrDuplicateKey {
|
||||||
log.Error("Couldn't add follower in DB: %v\n", err)
|
t.Rollback()
|
||||||
return
|
log.Error("Couldn't add follower in DB: %v\n", err)
|
||||||
|
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)
|
||||||
@@ -573,6 +590,8 @@ func federatePost(app *app, p *PublicPost, collID int64, isUpdate bool) error {
|
|||||||
activity = activitystreams.NewUpdateActivity(na)
|
activity = activitystreams.NewUpdateActivity(na)
|
||||||
} else {
|
} else {
|
||||||
activity = activitystreams.NewCreateActivity(na)
|
activity = activitystreams.NewCreateActivity(na)
|
||||||
|
activity.To = na.To
|
||||||
|
activity.CC = na.CC
|
||||||
}
|
}
|
||||||
err = makeActivityPost(actor, si, activity)
|
err = makeActivityPost(actor, si, activity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
42
app.go
42
app.go
@@ -4,7 +4,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/go-sql-driver/mysql"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -29,7 +29,8 @@ const (
|
|||||||
|
|
||||||
serverSoftware = "WriteFreely"
|
serverSoftware = "WriteFreely"
|
||||||
softwareURL = "https://writefreely.org"
|
softwareURL = "https://writefreely.org"
|
||||||
softwareVer = "0.1"
|
|
||||||
|
softwareVer = "0.2"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -124,6 +125,7 @@ func Serve() {
|
|||||||
debugPtr := flag.Bool("debug", false, "Enables debug logging.")
|
debugPtr := flag.Bool("debug", false, "Enables debug logging.")
|
||||||
createConfig := flag.Bool("create-config", false, "Creates a basic configuration and exits")
|
createConfig := flag.Bool("create-config", false, "Creates a basic configuration and exits")
|
||||||
doConfig := flag.Bool("config", false, "Run the configuration process")
|
doConfig := flag.Bool("config", false, "Run the configuration process")
|
||||||
|
genKeys := flag.Bool("gen-keys", false, "Generate encryption and authentication keys")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
debugging = *debugPtr
|
debugging = *debugPtr
|
||||||
@@ -167,6 +169,23 @@ func Serve() {
|
|||||||
log.Info("Done!")
|
log.Info("Done!")
|
||||||
}
|
}
|
||||||
os.Exit(0)
|
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...")
|
log.Info("Initializing...")
|
||||||
@@ -203,10 +222,16 @@ 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.Database == "" {
|
if app.cfg.Database.User == "" || app.cfg.Database.Password == "" {
|
||||||
log.Error("Database filename not set.")
|
log.Error("Database user or password not set.")
|
||||||
os.Exit(1)
|
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)
|
connectToDatabase(app)
|
||||||
defer shutdown(app)
|
defer shutdown(app)
|
||||||
@@ -243,7 +268,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("localhost:%d", app.cfg.Server.Port), nil)
|
err = http.ListenAndServe(fmt.Sprintf(":%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)
|
||||||
@@ -252,12 +277,7 @@ 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)
|
||||||
|
@@ -264,12 +264,11 @@ func (c *Collection) PersonObject(ids ...int64) *activitystreams.Person {
|
|||||||
p.Name = c.DisplayTitle()
|
p.Name = c.DisplayTitle()
|
||||||
p.Summary = c.Description
|
p.Summary = c.Description
|
||||||
if p.Name != "" {
|
if p.Name != "" {
|
||||||
fl := string(unicode.ToLower([]rune(p.Name)[0]))
|
if av := c.AvatarURL(); av != "" {
|
||||||
if isLowerLetter(fl) {
|
|
||||||
p.Icon = activitystreams.Image{
|
p.Icon = activitystreams.Image{
|
||||||
Type: "Image",
|
Type: "Image",
|
||||||
MediaType: "image/png",
|
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
|
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 {
|
func (c *Collection) FederatedAPIBase() string {
|
||||||
return hostName + "/"
|
return hostName + "/"
|
||||||
}
|
}
|
||||||
|
@@ -18,7 +18,11 @@ 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 {
|
||||||
@@ -55,8 +59,9 @@ func New() *Config {
|
|||||||
Port: 8080,
|
Port: 8080,
|
||||||
},
|
},
|
||||||
Database: DatabaseCfg{
|
Database: DatabaseCfg{
|
||||||
Type: "sqlite3",
|
Type: "mysql",
|
||||||
Database: "writefreely.sqlite3",
|
Host: "localhost",
|
||||||
|
Port: 3306,
|
||||||
},
|
},
|
||||||
App: AppCfg{
|
App: AppCfg{
|
||||||
Host: "http://localhost:8080",
|
Host: "http://localhost:8080",
|
||||||
|
@@ -65,7 +65,30 @@ func Configure() (*SetupData, error) {
|
|||||||
|
|
||||||
prompt = promptui.Prompt{
|
prompt = promptui.Prompt{
|
||||||
Templates: tmpls,
|
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,
|
Validate: validateNonEmpty,
|
||||||
Default: data.Config.Database.Database,
|
Default: data.Config.Database.Database,
|
||||||
}
|
}
|
||||||
@@ -74,6 +97,29 @@ 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()
|
||||||
|
126
database.go
126
database.go
@@ -7,7 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mattn/go-sqlite3"
|
"github.com/go-sql-driver/mysql"
|
||||||
"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,19 +22,10 @@ import (
|
|||||||
"github.com/writeas/writefreely/author"
|
"github.com/writeas/writefreely/author"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
const (
|
||||||
sqlNow = `datetime('now')`
|
mySQLErrDuplicateKey = 1062
|
||||||
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
|
||||||
@@ -119,11 +110,13 @@ 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 (?, ?, ?, "+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 {
|
if err != nil {
|
||||||
t.Rollback()
|
t.Rollback()
|
||||||
if isConstraintError(err) {
|
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
|
||||||
return impart.HTTPError{http.StatusConflict, "Username is already taken."}
|
if mysqlErr.Number == mySQLErrDuplicateKey {
|
||||||
|
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)
|
||||||
@@ -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)
|
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 isConstraintError(err) {
|
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
|
||||||
return impart.HTTPError{http.StatusConflict, "Username is already taken."}
|
if mysqlErr.Number == mySQLErrDuplicateKey {
|
||||||
|
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
|
||||||
@@ -213,8 +208,10 @@ 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 isConstraintError(err) {
|
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
|
||||||
return nil, impart.HTTPError{http.StatusConflict, "Collection already exists."}
|
if mysqlErr.Number == mySQLErrDuplicateKey {
|
||||||
|
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
|
||||||
@@ -332,7 +329,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 "+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 {
|
switch {
|
||||||
case err == sql.ErrNoRows:
|
case err == sql.ErrNoRows:
|
||||||
return "", ErrBadAccessToken
|
return "", ErrBadAccessToken
|
||||||
@@ -357,7 +354,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 "+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 {
|
switch {
|
||||||
case err == sql.ErrNoRows:
|
case err == sql.ErrNoRows:
|
||||||
return 0, "", ErrBadAccessToken
|
return 0, "", ErrBadAccessToken
|
||||||
@@ -396,7 +393,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 "+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 {
|
switch {
|
||||||
case err == sql.ErrNoRows:
|
case err == sql.ErrNoRows:
|
||||||
return -1, false
|
return -1, false
|
||||||
@@ -428,7 +425,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 "+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 {
|
switch {
|
||||||
case err == sql.ErrNoRows:
|
case err == sql.ErrNoRows:
|
||||||
return ""
|
return ""
|
||||||
@@ -471,16 +468,12 @@ func (db *datastore) GetTemporaryOneTimeAccessToken(userID int64, validSecs int,
|
|||||||
// Insert UUID to `accesstokens`
|
// Insert UUID to `accesstokens`
|
||||||
binTok := u[:]
|
binTok := u[:]
|
||||||
|
|
||||||
now := time.Now()
|
expirationVal := "NULL"
|
||||||
expirationVal := interface{}("NULL")
|
|
||||||
|
|
||||||
if validSecs > 0 {
|
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 (?, ?, 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
|
||||||
@@ -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 err != nil {
|
||||||
if isConstraintError(err) {
|
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
|
||||||
// Duplicate entry error; try a new slug
|
if mysqlErr.Number == mySQLErrDuplicateKey {
|
||||||
// TODO: make this a little more robust
|
// Duplicate entry error; try a new slug
|
||||||
// TODO: reuse exact same db.Exec statement as above
|
// TODO: make this a little more robust
|
||||||
slug = sql.NullString{id.GenSafeUniqueSlug(slug.String), true}
|
// TODO: reuse exact same db.Exec statement as above
|
||||||
_, 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)
|
slug = sql.NullString{id.GenSafeUniqueSlug(slug.String), true}
|
||||||
if err != nil {
|
_, 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)
|
||||||
return nil, handleFailedPostInsert(fmt.Errorf("Retried slug generation, still failed: %v", err))
|
if err != nil {
|
||||||
|
return nil, handleFailedPostInsert(fmt.Errorf("Retried slug generation, still failed: %v", err))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return nil, handleFailedPostInsert(err)
|
return nil, handleFailedPostInsert(err)
|
||||||
}
|
}
|
||||||
@@ -655,7 +650,7 @@ func (db *datastore) UpdateOwnedPost(post *AuthenticatedPost, userID int64) erro
|
|||||||
return ErrPostNoUpdatableVals
|
return ErrPostNoUpdatableVals
|
||||||
}
|
}
|
||||||
|
|
||||||
queryUpdates += sep + "updated = " + sqlNow
|
queryUpdates += sep + "updated = NOW()"
|
||||||
|
|
||||||
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 {
|
||||||
@@ -824,7 +819,7 @@ func (db *datastore) UpdateCollection(c *SubmittedCollection, alias string) erro
|
|||||||
return nil
|
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
|
// 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.
|
||||||
@@ -835,7 +830,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.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 {
|
switch {
|
||||||
case err == sql.ErrNoRows:
|
case err == sql.ErrNoRows:
|
||||||
return nil, ErrPostNotFound
|
return nil, ErrPostNotFound
|
||||||
@@ -882,7 +877,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.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 {
|
switch {
|
||||||
case err == sql.ErrNoRows:
|
case err == sql.ErrNoRows:
|
||||||
if collectionID > 0 {
|
if collectionID > 0 {
|
||||||
@@ -914,7 +909,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.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 {
|
switch {
|
||||||
case err == sql.ErrNoRows:
|
case err == sql.ErrNoRows:
|
||||||
return nil, ErrPostNotFound
|
return nil, ErrPostNotFound
|
||||||
@@ -967,7 +962,7 @@ func (db *datastore) GetPostsCount(c *CollectionObj, includeFuture bool) {
|
|||||||
var count int64
|
var count int64
|
||||||
timeCondition := ""
|
timeCondition := ""
|
||||||
if !includeFuture {
|
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)
|
err := db.QueryRow("SELECT COUNT(*) FROM posts WHERE collection_id = ? AND pinned_position IS NULL "+timeCondition, c.ID).Scan(&count)
|
||||||
switch {
|
switch {
|
||||||
@@ -1006,7 +1001,7 @@ func (db *datastore) GetPosts(c *Collection, page int, includeFuture bool) (*[]P
|
|||||||
}
|
}
|
||||||
timeCondition := ""
|
timeCondition := ""
|
||||||
if !includeFuture {
|
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)
|
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 {
|
||||||
@@ -1019,7 +1014,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.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 {
|
if err != nil {
|
||||||
log.Error("Failed scanning row: %v", err)
|
log.Error("Failed scanning row: %v", err)
|
||||||
break
|
break
|
||||||
@@ -1063,7 +1058,7 @@ func (db *datastore) GetPostsTagged(c *Collection, tag string, page int, include
|
|||||||
}
|
}
|
||||||
timeCondition := ""
|
timeCondition := ""
|
||||||
if !includeFuture {
|
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)+"[[:>:]]")
|
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 {
|
||||||
@@ -1076,7 +1071,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.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 {
|
if err != nil {
|
||||||
log.Error("Failed scanning row: %v", err)
|
log.Error("Failed scanning row: %v", err)
|
||||||
break
|
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) {
|
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 isConstraintError(err) && slugIdx > -1 {
|
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
|
||||||
s := id.GenSafeUniqueSlug(p.Slug)
|
if mysqlErr.Number == mySQLErrDuplicateKey && slugIdx > -1 {
|
||||||
if s == p.Slug {
|
s := id.GenSafeUniqueSlug(p.Slug)
|
||||||
// Sanity check to prevent infinite recursion
|
if s == p.Slug {
|
||||||
return qRes, fmt.Errorf("GenSafeUniqueSlug generated nothing unique: %s", s)
|
// 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)
|
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) {
|
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 {
|
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."}
|
||||||
@@ -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)
|
_, err = t.Exec("UPDATE users SET username = ? WHERE id = ?", newUsername, u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Rollback()
|
t.Rollback()
|
||||||
if isConstraintError(err) {
|
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
|
||||||
return impart.HTTPError{http.StatusConflict, "Username is already taken."}
|
if mysqlErr.Number == mySQLErrDuplicateKey {
|
||||||
|
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
|
||||||
@@ -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)
|
_, 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 isConstraintError(err) {
|
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
|
||||||
return impart.HTTPError{http.StatusConflict, "Username is already taken."}
|
if mysqlErr.Number == mySQLErrDuplicateKey {
|
||||||
|
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
|
||||||
|
58
keys.go
58
keys.go
@@ -1,7 +1,23 @@
|
|||||||
package writefreely
|
package writefreely
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"github.com/writeas/web-core/log"
|
||||||
"io/ioutil"
|
"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 {
|
type keychain struct {
|
||||||
@@ -12,20 +28,56 @@ func initKeys(app *app) error {
|
|||||||
var err error
|
var err error
|
||||||
app.keys = &keychain{}
|
app.keys = &keychain{}
|
||||||
|
|
||||||
app.keys.emailKey, err = ioutil.ReadFile("keys/email.aes256")
|
app.keys.emailKey, err = ioutil.ReadFile(emailKeyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
app.keys.cookieAuthKey, err = ioutil.ReadFile("keys/cookies_auth.aes256")
|
app.keys.cookieAuthKey, err = ioutil.ReadFile(cookieAuthKeyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
app.keys.cookieKey, err = ioutil.ReadFile("keys/cookies_enc.aes256")
|
app.keys.cookieKey, err = ioutil.ReadFile(cookieKeyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
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
25
keys.sh
@@ -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
|
|
@@ -1,4 +1,4 @@
|
|||||||
Keys
|
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`.
|
||||||
|
@@ -5,7 +5,6 @@ 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 {
|
||||||
@@ -68,10 +67,6 @@ 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 (
|
||||||
@@ -80,7 +75,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 > ? co`, ago_6).Scan(&activeHalfYear)
|
AND updated > DATE_SUB(NOW(), INTERVAL 6 MONTH)) co`).Scan(&activeHalfYear)
|
||||||
|
|
||||||
err = r.db.QueryRow(`SELECT COUNT(*) FROM (
|
err = r.db.QueryRow(`SELECT COUNT(*) FROM (
|
||||||
SELECT DISTINCT collection_id
|
SELECT DISTINCT collection_id
|
||||||
@@ -88,7 +83,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 > ? co`, ago_1).Scan(&activeMonth)
|
AND updated > DATE_SUB(NOW(), INTERVAL 1 MONTH)) co`).Scan(&activeMonth)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nodeinfo.Usage{
|
return nodeinfo.Usage{
|
||||||
|
1
posts.go
1
posts.go
@@ -80,6 +80,7 @@ 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:"-"`
|
||||||
|
75
schema.sql
75
schema.sql
@@ -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) NOT NULL,
|
`value` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin 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,18 +76,19 @@ CREATE TABLE IF NOT EXISTS `collectionredirects` (
|
|||||||
--
|
--
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `collections` (
|
CREATE TABLE IF NOT EXISTS `collections` (
|
||||||
`id` integer PRIMARY KEY ASC,
|
`id` int(6) NOT NULL AUTO_INCREMENT,
|
||||||
`alias` varchar(100) DEFAULT NULL,
|
`alias` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
|
||||||
`title` varchar(255) NOT NULL,
|
`title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
`description` varchar(160) NOT NULL,
|
`description` varchar(160) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
`style_sheet` text,
|
`style_sheet` text,
|
||||||
`script` text,
|
`script` text CHARACTER SET utf8mb4 COLLATE utf8mb4_bin,
|
||||||
`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,
|
||||||
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',
|
`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) DEFAULT NULL,
|
`pinned_position` tinyint(1) UNSIGNED 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) NOT NULL,
|
`title` varchar(160) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
`content` text NOT NULL,
|
`content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
CONSTRAINT `id_slug` UNIQUE (`collection_id`,`slug`),
|
UNIQUE KEY `id_slug` (`collection_id`,`slug`),
|
||||||
CONSTRAINT `owner_id` UNIQUE (`owner_id`,`id`)
|
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,
|
`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;
|
||||||
|
|
||||||
-- --------------------------------------------------------
|
-- --------------------------------------------------------
|
||||||
|
|
||||||
@@ -135,11 +138,12 @@ CREATE TABLE IF NOT EXISTS `remotefollows` (
|
|||||||
--
|
--
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `remoteuserkeys` (
|
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,
|
`remote_user_id` int(11) NOT NULL,
|
||||||
`public_key` blob 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` (
|
CREATE TABLE IF NOT EXISTS `remoteusers` (
|
||||||
`id` integer PRIMARY KEY ASC,
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
`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,
|
PRIMARY KEY (`id`),
|
||||||
CONSTRAINT `collection_id` UNIQUE (`actor_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` (
|
CREATE TABLE IF NOT EXISTS `userattributes` (
|
||||||
`user_id` integer NOT NULL,
|
`user_id` int(6) NOT NULL,
|
||||||
`attribute` varchar(64) 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`)
|
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` (
|
CREATE TABLE IF NOT EXISTS `users` (
|
||||||
`id` integer PRIMARY KEY ASC,
|
`id` int(6) NOT NULL AUTO_INCREMENT,
|
||||||
`username` varchar(100) NOT NULL,
|
`username` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
`password` char(60) NOT NULL,
|
`password` char(60) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
|
||||||
`email` varbinary(255) DEFAULT NULL,
|
`email` varbinary(255) DEFAULT NULL,
|
||||||
`created` datetime NOT NULL,
|
`created` datetime NOT NULL,
|
||||||
CONSTRAINT `username` UNIQUE (`username`)
|
PRIMARY KEY (`id`),
|
||||||
);
|
UNIQUE KEY `username` (`username`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
|
||||||
|
@@ -20,14 +20,14 @@
|
|||||||
<meta name="twitter:card" content="summary">
|
<meta name="twitter:card" content="summary">
|
||||||
<meta name="twitter:description" content="{{.Summary}}">
|
<meta name="twitter:description" content="{{.Summary}}">
|
||||||
<meta name="twitter:title" content="{{.PlainDisplayTitle}} — {{if .Collection.Title}}{{.Collection.Title}}{{else}}{{.Collection.Alias}}{{end}}">
|
<meta name="twitter:title" content="{{.PlainDisplayTitle}} — {{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:title" content="{{.PlainDisplayTitle}}" />
|
||||||
<meta property="og:description" content="{{.Summary}}" />
|
<meta property="og:description" content="{{.Summary}}" />
|
||||||
<meta property="og:site_name" content="{{.Collection.DisplayTitle}}" />
|
<meta property="og:site_name" content="{{.Collection.DisplayTitle}}" />
|
||||||
<meta property="og:type" content="article" />
|
<meta property="og:type" content="article" />
|
||||||
<meta property="og:url" content="{{.CanonicalURL}}" />
|
<meta property="og:url" content="{{.CanonicalURL}}" />
|
||||||
<meta property="og:updated_time" content="{{.Created8601}}" />
|
<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}}">
|
<meta property="article:published_time" content="{{.Created8601}}">
|
||||||
{{if .Collection.StyleSheet}}<style type="text/css">{{.Collection.StyleSheetDisplay}}</style>{{end}}
|
{{if .Collection.StyleSheet}}<style type="text/css">{{.Collection.StyleSheetDisplay}}</style>{{end}}
|
||||||
{{if .Collection.RenderMathJax}}
|
{{if .Collection.RenderMathJax}}
|
||||||
|
@@ -23,10 +23,12 @@
|
|||||||
<meta name="twitter:site" content="@writeas__">
|
<meta name="twitter:site" content="@writeas__">
|
||||||
<meta name="twitter:description" content="{{.Tag}} posts on {{.Collection.DisplayTitle}}">
|
<meta name="twitter:description" content="{{.Tag}} posts on {{.Collection.DisplayTitle}}">
|
||||||
<meta name="twitter:title" content="{{.Tag}} — {{.Collection.DisplayTitle}}">
|
<meta name="twitter:title" content="{{.Tag}} — {{.Collection.DisplayTitle}}">
|
||||||
|
<meta name="twitter:image" content="{{.Collection.AvatarURL}}">
|
||||||
<meta property="og:title" content="{{.Tag}} — {{.Collection.DisplayTitle}}" />
|
<meta property="og:title" content="{{.Tag}} — {{.Collection.DisplayTitle}}" />
|
||||||
<meta property="og:site_name" content="{{.DisplayTitle}}" />
|
<meta property="og:site_name" content="{{.DisplayTitle}}" />
|
||||||
<meta property="og:type" content="article" />
|
<meta property="og:type" content="article" />
|
||||||
<meta property="og:url" content="{{.CanonicalURL}}tag:{{.Tag}}" />
|
<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.StyleSheet}}<style type="text/css">{{.Collection.StyleSheetDisplay}}</style>{{end}}
|
||||||
{{if .Collection.RenderMathJax}}
|
{{if .Collection.RenderMathJax}}
|
||||||
<script type="text/x-mathjax-config">
|
<script type="text/x-mathjax-config">
|
||||||
|
@@ -19,12 +19,14 @@
|
|||||||
<meta itemprop="description" content="{{.Description}}">
|
<meta itemprop="description" content="{{.Description}}">
|
||||||
<meta name="twitter:card" content="summary">
|
<meta name="twitter:card" content="summary">
|
||||||
<meta name="twitter:title" content="{{.DisplayTitle}}">
|
<meta name="twitter:title" content="{{.DisplayTitle}}">
|
||||||
|
<meta name="twitter:image" content="{{.AvatarURL}}">
|
||||||
<meta name="twitter:description" content="{{.Description}}">
|
<meta name="twitter:description" content="{{.Description}}">
|
||||||
<meta property="og:title" content="{{.DisplayTitle}}" />
|
<meta property="og:title" content="{{.DisplayTitle}}" />
|
||||||
<meta property="og:site_name" content="{{.DisplayTitle}}" />
|
<meta property="og:site_name" content="{{.DisplayTitle}}" />
|
||||||
<meta property="og:type" content="article" />
|
<meta property="og:type" content="article" />
|
||||||
<meta property="og:url" content="{{.CanonicalURL}}" />
|
<meta property="og:url" content="{{.CanonicalURL}}" />
|
||||||
<meta property="og:description" content="{{.Description}}" />
|
<meta property="og:description" content="{{.Description}}" />
|
||||||
|
<meta property="og:image" content="{{.AvatarURL}}">
|
||||||
{{if .StyleSheet}}<style type="text/css">{{.StyleSheetDisplay}}</style>{{end}}
|
{{if .StyleSheet}}<style type="text/css">{{.StyleSheetDisplay}}</style>{{end}}
|
||||||
{{if .RenderMathJax}}
|
{{if .RenderMathJax}}
|
||||||
<script type="text/x-mathjax-config">
|
<script type="text/x-mathjax-config">
|
||||||
|
Reference in New Issue
Block a user