Added api endpoints and tests
This commit is contained in:
2
.env
2
.env
@@ -2,6 +2,8 @@ ARTICLE_ROOT=../articles/
|
||||
|
||||
SERVER_PORT=8080
|
||||
|
||||
PREFIX=
|
||||
|
||||
DATABASE_FILE=database.sqlite3
|
||||
ARTICLE_TEMPLATE=../frontend/html/article.gohtml
|
||||
FRONTEND_DIR=../frontend/
|
||||
|
||||
48
api/api.go
Normal file
48
api/api.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var (
|
||||
DatabaseError = ApiError{Message: "internal database error", Code: http.StatusInternalServerError}
|
||||
InvalidJson = ApiError{Message: "invalid json", Code: http.StatusUnprocessableEntity}
|
||||
)
|
||||
|
||||
type ApiError struct {
|
||||
Message string `json:"message"`
|
||||
Code int `json:"-"`
|
||||
}
|
||||
|
||||
func (ae ApiError) Send(w http.ResponseWriter) error {
|
||||
w.WriteHeader(ae.Code)
|
||||
return json.NewEncoder(w).Encode(ae)
|
||||
}
|
||||
|
||||
var sessions = map[string]int{}
|
||||
|
||||
type article struct {
|
||||
ID int `json:"-"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Image string `json:"image"`
|
||||
Created int64 `json:"created"`
|
||||
Modified int64 `json:"modified"`
|
||||
Link string `json:"link"`
|
||||
Markdown string `json:"markdown"`
|
||||
Html string `json:"html"`
|
||||
}
|
||||
|
||||
func authorizedSession(r *http.Request) (int, bool) {
|
||||
cookie, err := r.Cookie("session_id")
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
for sessionId, authorId := range sessions {
|
||||
if sessionId == cookie.Value {
|
||||
return authorId, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
126
api/api_test.go
Normal file
126
api/api_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"TheAdversary/database"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
url2 "net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type testInformation struct {
|
||||
Method string
|
||||
Body interface{}
|
||||
Query map[string]interface{}
|
||||
Cookie map[string]string
|
||||
|
||||
ResultBody interface{}
|
||||
ResultCookie []string
|
||||
Code int
|
||||
|
||||
AfterExec func(*testInformation)
|
||||
}
|
||||
|
||||
func initTestDatabase(name string) error {
|
||||
path := filepath.Join(os.TempDir(), name)
|
||||
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
if err = os.Remove(path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
db, err := database.NewSqlite3Connection(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
database.SetGlobDB(db)
|
||||
declaration, _ := os.ReadFile("../database.sql")
|
||||
db.Exec(string(declaration))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func initSession() string {
|
||||
sessid := sessionId()
|
||||
sessions[sessid] = 1
|
||||
return sessid
|
||||
}
|
||||
|
||||
func checkTestInformation(t *testing.T, url string, information []testInformation) {
|
||||
for i, information := range information {
|
||||
var body io.Reader
|
||||
if information.Body != nil {
|
||||
buf, _ := json.Marshal(information.Body)
|
||||
body = bytes.NewReader(buf)
|
||||
}
|
||||
|
||||
query := url2.Values{}
|
||||
if information.Query != nil {
|
||||
for key, value := range information.Query {
|
||||
query.Add(key, fmt.Sprintf("%v", value))
|
||||
}
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest(information.Method, fmt.Sprintf("%s?%s", url, query.Encode()), body)
|
||||
if information.Cookie != nil {
|
||||
for name, value := range information.Cookie {
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
resp, _ := http.DefaultClient.Do(req)
|
||||
|
||||
if information.AfterExec != nil {
|
||||
information.AfterExec(&information)
|
||||
}
|
||||
|
||||
if resp.StatusCode != information.Code {
|
||||
t.Errorf("Test %d sent invalid status code: expected %d, got %d", i+1, information.Code, resp.StatusCode)
|
||||
}
|
||||
|
||||
if resp.Body != nil {
|
||||
var respBody, informationBody interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&respBody)
|
||||
resp.Body.Close()
|
||||
|
||||
tmpInformationBytes, _ := json.Marshal(information.ResultBody)
|
||||
json.Unmarshal(tmpInformationBytes, &informationBody)
|
||||
|
||||
if !reflect.DeepEqual(respBody, informationBody) {
|
||||
respBytes, _ := json.Marshal(respBody)
|
||||
informationBytes, _ := json.Marshal(informationBody)
|
||||
|
||||
// for some reason the maps are sometimes not matched as equal.
|
||||
// this is an additional checks if the map bytes are equal
|
||||
if !bytes.Equal(respBytes, informationBytes) {
|
||||
t.Errorf("Test %d sent invalid response body: expected %s, got %s", i+1, informationBytes, respBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if information.ResultCookie != nil {
|
||||
for _, cookie := range information.ResultCookie {
|
||||
var found bool
|
||||
for _, respCookie := range resp.Cookies() {
|
||||
if cookie == respCookie.Name {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Test %d sent invalid cookies: expected %s, got none", i+1, cookie)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
120
api/assets.go
Normal file
120
api/assets.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"TheAdversary/database"
|
||||
"TheAdversary/schema"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm/clause"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
var assetsPayload struct {
|
||||
ArticleId int `json:"article_id"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func Assets(w http.ResponseWriter, r *http.Request) {
|
||||
_, ok := authorizedSession(r)
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
assetsGet(w, r)
|
||||
case http.MethodPost:
|
||||
assetsPost(w, r)
|
||||
case http.MethodDelete:
|
||||
assetsDelete(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func assetsGet(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
request := database.GetDB().Table("assets")
|
||||
|
||||
if query.Has("q") {
|
||||
request.Where("LOWER(name) LIKE ?", "%"+query.Get("q")+"%")
|
||||
}
|
||||
limit := 20
|
||||
if query.Has("limit") {
|
||||
var err error
|
||||
limit, err = strconv.Atoi(query.Get("limit"))
|
||||
if err != nil {
|
||||
ApiError{"invalid 'limit' parameter", http.StatusUnprocessableEntity}.Send(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
request.Limit(limit)
|
||||
|
||||
var assets []schema.Asset
|
||||
request.Find(&assets)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(&assets)
|
||||
}
|
||||
|
||||
type assetsPostPayload struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"data"`
|
||||
}
|
||||
|
||||
func assetsPost(w http.ResponseWriter, r *http.Request) {
|
||||
var payload assetsPostPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
InvalidJson.Send(w)
|
||||
return
|
||||
}
|
||||
|
||||
rawData, err := base64.StdEncoding.DecodeString(payload.Content)
|
||||
if err != nil {
|
||||
zap.S().Warnf("Cannot decode base64")
|
||||
ApiError{Message: "invalid base64 content", Code: http.StatusUnprocessableEntity}.Send(w)
|
||||
return
|
||||
}
|
||||
|
||||
tmpDatabaseSchema := struct {
|
||||
Id int
|
||||
Name string
|
||||
Data []byte
|
||||
Link string
|
||||
}{Name: payload.Name, Data: rawData, Link: "/" + path.Join("assets", url.PathEscape(payload.Name))}
|
||||
|
||||
if database.GetDB().Table("assets").Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "name"}},
|
||||
DoNothing: true,
|
||||
}).Create(&tmpDatabaseSchema).RowsAffected == 0 {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(schema.Asset{
|
||||
Id: tmpDatabaseSchema.Id,
|
||||
Name: tmpDatabaseSchema.Name,
|
||||
Link: tmpDatabaseSchema.Link,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type assetsDeletePayload struct {
|
||||
Id int `json:"id"`
|
||||
}
|
||||
|
||||
func assetsDelete(w http.ResponseWriter, r *http.Request) {
|
||||
var payload assetsDeletePayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
InvalidJson.Send(w)
|
||||
return
|
||||
}
|
||||
|
||||
if database.GetDB().Table("assets").Delete(schema.Asset{}, payload.Id).RowsAffected == 0 {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
138
api/assets_test.go
Normal file
138
api/assets_test.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"TheAdversary/database"
|
||||
"TheAdversary/schema"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAssetsGet(t *testing.T) {
|
||||
if err := initTestDatabase("assets_get_test.sqlite3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
database.GetDB().Table("assets").Create([]map[string]interface{}{
|
||||
{
|
||||
"name": "linux",
|
||||
"data": "this should be an image of tux :3",
|
||||
"link": "/assets/linux",
|
||||
},
|
||||
{
|
||||
"name": "get test",
|
||||
"data": "",
|
||||
"link": "/assets/get-test",
|
||||
},
|
||||
})
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(assetsGet))
|
||||
checkTestInformation(t, server.URL, []testInformation{
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Query: map[string]interface{}{
|
||||
"q": "linux",
|
||||
},
|
||||
ResultBody: []schema.Asset{
|
||||
{
|
||||
Id: 1,
|
||||
Name: "linux",
|
||||
Link: "/assets/linux",
|
||||
},
|
||||
},
|
||||
Code: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Query: map[string]interface{}{
|
||||
"limit": 1,
|
||||
},
|
||||
ResultBody: []schema.Asset{
|
||||
{
|
||||
Id: 1,
|
||||
Name: "linux",
|
||||
Link: "/assets/linux",
|
||||
},
|
||||
},
|
||||
Code: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
ResultBody: []schema.Asset{
|
||||
{
|
||||
Id: 1,
|
||||
Name: "linux",
|
||||
Link: "/assets/linux",
|
||||
},
|
||||
{
|
||||
Id: 2,
|
||||
Name: "get test",
|
||||
Link: "/assets/get-test",
|
||||
},
|
||||
},
|
||||
Code: http.StatusOK,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestAssetsPost(t *testing.T) {
|
||||
if err := initTestDatabase("assets_post_test.sqlite3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(assetsPost))
|
||||
checkTestInformation(t, server.URL, []testInformation{
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Body: assetsPostPayload{
|
||||
Name: "test",
|
||||
Content: base64.StdEncoding.EncodeToString([]byte("test asset")),
|
||||
},
|
||||
ResultBody: schema.Asset{
|
||||
Id: 1,
|
||||
Name: "test",
|
||||
Link: "/assets/test",
|
||||
},
|
||||
Code: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Body: assetsPostPayload{
|
||||
Name: "test",
|
||||
Content: base64.StdEncoding.EncodeToString([]byte("test asset")),
|
||||
},
|
||||
Code: http.StatusConflict,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestAssetsDelete(t *testing.T) {
|
||||
if err := initTestDatabase("assets_delete_test.sqlite3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
database.GetDB().Table("assets").Create(map[string]interface{}{
|
||||
"name": "example",
|
||||
"data": "just a normal string",
|
||||
"link": "/assets/example",
|
||||
})
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(assetsDelete))
|
||||
checkTestInformation(t, server.URL, []testInformation{
|
||||
{
|
||||
Method: http.MethodDelete,
|
||||
Body: assetsDeletePayload{
|
||||
Id: 1,
|
||||
},
|
||||
Code: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Method: http.MethodDelete,
|
||||
Body: assetsDeletePayload{
|
||||
Id: 69,
|
||||
},
|
||||
Code: http.StatusNotFound,
|
||||
},
|
||||
})
|
||||
}
|
||||
32
api/authors.go
Normal file
32
api/authors.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"TheAdversary/database"
|
||||
"TheAdversary/schema"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func Authors(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
request := database.GetDB().Table("author")
|
||||
|
||||
if query.Has("name") {
|
||||
request.Where("name LIKE ?", "%"+query.Get("name")+"%")
|
||||
}
|
||||
if query.Has("limit") {
|
||||
limit, err := strconv.Atoi(query.Get("limit"))
|
||||
if err != nil {
|
||||
ApiError{"invalid 'limit' parameter", http.StatusUnprocessableEntity}.Send(w)
|
||||
return
|
||||
}
|
||||
request.Limit(limit)
|
||||
}
|
||||
|
||||
var authors []schema.Author
|
||||
request.Find(&authors)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(authors)
|
||||
}
|
||||
55
api/authors_test.go
Normal file
55
api/authors_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"TheAdversary/database"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuthors(t *testing.T) {
|
||||
if err := initTestDatabase("authors_test.sqlite3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
database.GetDB().Table("author").Create([]map[string]interface{}{
|
||||
{
|
||||
"name": "test",
|
||||
"password": "",
|
||||
"information": "test information",
|
||||
},
|
||||
})
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(Authors))
|
||||
checkTestInformation(t, server.URL, []testInformation{
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
ResultBody: []map[string]interface{}{
|
||||
{
|
||||
"id": 1,
|
||||
"name": "test",
|
||||
"information": "test information",
|
||||
},
|
||||
},
|
||||
Code: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Query: map[string]interface{}{
|
||||
"name": "abc",
|
||||
},
|
||||
ResultBody: []interface{}{},
|
||||
Code: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Query: map[string]interface{}{
|
||||
"limit": "notanumber",
|
||||
},
|
||||
ResultBody: map[string]interface{}{
|
||||
"message": "invalid 'limit' parameter",
|
||||
},
|
||||
Code: http.StatusUnprocessableEntity,
|
||||
},
|
||||
})
|
||||
}
|
||||
36
api/delete.go
Normal file
36
api/delete.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"TheAdversary/database"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type deletePayload struct {
|
||||
Id int `json:"id"`
|
||||
}
|
||||
|
||||
func Delete(w http.ResponseWriter, r *http.Request) {
|
||||
authorId, ok := authorizedSession(r)
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var payload deletePayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
InvalidJson.Send(w)
|
||||
return
|
||||
}
|
||||
if !database.Exists(database.GetDB().Table("article"), "id = ?", payload.Id) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
} else if !database.Exists(database.GetDB().Table("article_author"), "article_id = ? AND author_id = ?", payload.Id, authorId) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
database.GetDB().Table("article").Delete(&payload)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
84
api/delete_test.go
Normal file
84
api/delete_test.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"TheAdversary/database"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDelete(t *testing.T) {
|
||||
if err := initTestDatabase("delete_test.sqlite3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
database.GetDB().Table("article").Create([]map[string]interface{}{
|
||||
{
|
||||
"title": "test",
|
||||
"summary": "test summary",
|
||||
"image": "https://upload.wikimedia.org/wikipedia/commons/0/05/Go_Logo_Blue.svg",
|
||||
"created": time.Now().Unix(),
|
||||
"modified": time.Now().Unix(),
|
||||
"link": "/article/test",
|
||||
"markdown": "# Title",
|
||||
"html": "<h1>Title</h1>",
|
||||
},
|
||||
{
|
||||
"title": "owo",
|
||||
"created": time.Now().Unix(),
|
||||
"link": "/article/owo",
|
||||
"markdown": "owo",
|
||||
"html": "<p>owo<p>",
|
||||
},
|
||||
})
|
||||
database.GetDB().Table("author").Create(map[string]interface{}{
|
||||
"name": "test",
|
||||
"password": "",
|
||||
})
|
||||
database.GetDB().Table("article_author").Create([]map[string]interface{}{
|
||||
{
|
||||
"article_id": 1,
|
||||
"author_id": 1,
|
||||
},
|
||||
{
|
||||
"article_id": 2,
|
||||
"author_id": 1,
|
||||
},
|
||||
})
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(Delete))
|
||||
checkTestInformation(t, server.URL, []testInformation{
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Code: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Body: deletePayload{
|
||||
Id: 1,
|
||||
},
|
||||
Code: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Body: deletePayload{
|
||||
Id: 1,
|
||||
},
|
||||
Cookie: map[string]string{
|
||||
"session_id": initSession(),
|
||||
},
|
||||
Code: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Body: deletePayload{
|
||||
Id: 69,
|
||||
},
|
||||
Cookie: map[string]string{
|
||||
"session_id": initSession(),
|
||||
},
|
||||
Code: http.StatusNotFound,
|
||||
},
|
||||
})
|
||||
}
|
||||
129
api/edit.go
Normal file
129
api/edit.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"TheAdversary/config"
|
||||
"TheAdversary/database"
|
||||
"TheAdversary/schema"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/gomarkdown/markdown"
|
||||
"go.uber.org/zap"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type editPayload struct {
|
||||
Id int `json:"id"`
|
||||
Title *string `json:"title"`
|
||||
Summary *string `json:"summary"`
|
||||
Authors *[]int `json:"authors"`
|
||||
Image *string `json:"image"`
|
||||
Tags *[]string `json:"tags"`
|
||||
Link *string `json:"link"`
|
||||
Content *string `json:"content"`
|
||||
}
|
||||
|
||||
func Edit(w http.ResponseWriter, r *http.Request) {
|
||||
authorId, ok := authorizedSession(r)
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var payload editPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
InvalidJson.Send(w)
|
||||
return
|
||||
}
|
||||
if !database.Exists(database.GetDB().Table("article"), "id = ?", payload.Id) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
} else if !database.Exists(database.GetDB().Table("article_author"), "article_id = ? AND author_id = ?", payload.Id, authorId) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{}
|
||||
var authorUpdates, tagUpdates []map[string]interface{}
|
||||
|
||||
a := article{
|
||||
ID: payload.Id,
|
||||
Modified: time.Now().Unix(),
|
||||
}
|
||||
|
||||
if payload.Title != nil {
|
||||
updates["title"] = *payload.Title
|
||||
}
|
||||
if payload.Summary != nil {
|
||||
updates["summary"] = *payload.Summary
|
||||
}
|
||||
if payload.Authors != nil {
|
||||
var notFound []string
|
||||
database.GetDB().Select("* FROM ? EXCEPT ?", *payload.Authors, database.GetDB().Table("author").Select("id")).Find(¬Found)
|
||||
if len(notFound) > 0 {
|
||||
ApiError{fmt.Sprintf("no authors with the id(s) %s were found", strings.Join(notFound, ", ")), http.StatusUnprocessableEntity}.Send(w)
|
||||
return
|
||||
}
|
||||
for _, author := range append([]int{authorId}, *payload.Authors...) {
|
||||
authorUpdates = append(authorUpdates, map[string]interface{}{
|
||||
"article_id": payload.Id,
|
||||
"author_id": author,
|
||||
})
|
||||
}
|
||||
}
|
||||
if payload.Tags != nil {
|
||||
for _, tag := range *payload.Tags {
|
||||
tagUpdates = append(tagUpdates, map[string]interface{}{
|
||||
"article_id": payload.Id,
|
||||
"tag": tag,
|
||||
})
|
||||
}
|
||||
}
|
||||
if payload.Image != nil {
|
||||
updates["image"] = *payload.Image
|
||||
}
|
||||
if payload.Link != nil {
|
||||
updates["link"] = path.Join(config.Prefix, "article", *payload.Link)
|
||||
}
|
||||
if payload.Content != nil {
|
||||
rawMarkdown, err := base64.StdEncoding.DecodeString(*payload.Content)
|
||||
if err != nil {
|
||||
zap.S().Warnf("Cannot decode base64")
|
||||
ApiError{Message: "invalid base64 content", Code: http.StatusUnprocessableEntity}.Send(w)
|
||||
return
|
||||
}
|
||||
a.Markdown = string(rawMarkdown)
|
||||
a.Html = string(markdown.ToHTML(rawMarkdown, nil, nil))
|
||||
updates["markdown"] = string(rawMarkdown)
|
||||
updates["html"] = string(markdown.ToHTML(rawMarkdown, nil, nil))
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
updates["modified"] = time.Now().Unix()
|
||||
|
||||
database.GetDB().Table("article").Where("id = ?", payload.Id).Updates(&updates)
|
||||
if authorUpdates != nil {
|
||||
database.GetDB().Table("article_author").Where("article_id = ?", payload.Id).Delete(nil)
|
||||
database.GetDB().Table("article_author").Create(&authorUpdates)
|
||||
}
|
||||
if tagUpdates != nil {
|
||||
database.GetDB().Table("article_tag").Where("article_id = ?", payload.Id).Delete(nil)
|
||||
database.GetDB().Table("article_tag").Create(&tagUpdates)
|
||||
}
|
||||
}
|
||||
|
||||
var articleSummary schema.ArticleSummary
|
||||
database.GetDB().Table("article").Find(&articleSummary, payload.Id)
|
||||
database.GetDB().Table("author").Where("id IN (?)", database.GetDB().Table("article_author").Select("author_id").Where("article_id = ?", payload.Id)).Find(&articleSummary.Authors)
|
||||
if payload.Tags != nil {
|
||||
articleSummary.Tags = *payload.Tags
|
||||
} else {
|
||||
articleSummary.Tags = []string{}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(articleSummary)
|
||||
}
|
||||
105
api/edit_test.go
Normal file
105
api/edit_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"TheAdversary/database"
|
||||
"TheAdversary/schema"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEdit(t *testing.T) {
|
||||
if err := initTestDatabase("edit_test.sqlite3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
database.GetDB().Table("article").Create([]map[string]interface{}{
|
||||
{
|
||||
"title": "test article",
|
||||
"summary": "example summary",
|
||||
"created": time.Now().Unix(),
|
||||
"link": "/article/test-article",
|
||||
"markdown": "Just a simple test article",
|
||||
"html": "<p>Just a simple test article<p>",
|
||||
},
|
||||
})
|
||||
database.GetDB().Table("author").Create([]map[string]interface{}{
|
||||
{
|
||||
"name": "test",
|
||||
"password": "",
|
||||
},
|
||||
{
|
||||
"name": "admin",
|
||||
"password": "123456",
|
||||
"information": "im the admin",
|
||||
},
|
||||
})
|
||||
database.GetDB().Table("article_author").Create([]map[string]interface{}{
|
||||
{
|
||||
"article_id": 1,
|
||||
"author_id": 1,
|
||||
},
|
||||
})
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(Edit))
|
||||
newTitle := "New title"
|
||||
var created int64
|
||||
database.GetDB().Table("article").Select("created").Where("id = 1").Find(&created)
|
||||
checkTestInformation(t, server.URL, []testInformation{
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Code: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Cookie: map[string]string{
|
||||
"session_id": initSession(),
|
||||
},
|
||||
Body: editPayload{
|
||||
Id: 69,
|
||||
},
|
||||
Code: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Cookie: map[string]string{
|
||||
"session_id": initSession(),
|
||||
},
|
||||
Body: editPayload{
|
||||
Id: 1,
|
||||
Title: &newTitle,
|
||||
Authors: &[]int{
|
||||
2,
|
||||
},
|
||||
},
|
||||
ResultBody: schema.ArticleSummary{
|
||||
Id: 1,
|
||||
Title: "New title",
|
||||
Summary: "example summary",
|
||||
Authors: []schema.Author{
|
||||
{
|
||||
Id: 1,
|
||||
Name: "test",
|
||||
},
|
||||
{
|
||||
Id: 2,
|
||||
Name: "admin",
|
||||
Information: "im the admin",
|
||||
},
|
||||
},
|
||||
Created: created,
|
||||
Tags: []string{},
|
||||
Link: "/article/test-article",
|
||||
},
|
||||
AfterExec: func(information *testInformation) {
|
||||
var modified int64
|
||||
database.GetDB().Table("article").Select("modified").Where("id = 1").Find(&modified)
|
||||
res := information.ResultBody.(schema.ArticleSummary)
|
||||
res.Modified = modified
|
||||
information.ResultBody = res
|
||||
},
|
||||
Code: http.StatusOK,
|
||||
},
|
||||
})
|
||||
}
|
||||
21
api/error.go
21
api/error.go
@@ -1,21 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var (
|
||||
DatabaseError = ApiError{Message: "internal database error", Code: http.StatusInternalServerError}
|
||||
InvalidJson = ApiError{Message: "invalid json", Code: http.StatusUnprocessableEntity}
|
||||
)
|
||||
|
||||
type ApiError struct {
|
||||
Message string `json:"message"`
|
||||
Code int
|
||||
}
|
||||
|
||||
func (ae ApiError) Send(w http.ResponseWriter) error {
|
||||
w.WriteHeader(ae.Code)
|
||||
return json.NewEncoder(w).Encode(ae)
|
||||
}
|
||||
49
api/login.go
Normal file
49
api/login.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"TheAdversary/database"
|
||||
"TheAdversary/schema"
|
||||
"encoding/json"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type loginPayload struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func Login(w http.ResponseWriter, r *http.Request) {
|
||||
var payload loginPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
InvalidJson.Send(w)
|
||||
return
|
||||
}
|
||||
var author schema.Author
|
||||
database.GetDB().Table("author").Select("id", "password").Where("name = ?", payload.Username).Take(&author)
|
||||
if author.Id == 0 || bcrypt.CompareHashAndPassword([]byte(author.Password), []byte(payload.Password)) != nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := sessionId()
|
||||
|
||||
sessions[sessionID] = author.Id
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session_id",
|
||||
Value: sessionID,
|
||||
})
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func sessionId() string {
|
||||
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
|
||||
|
||||
s := make([]rune, 32)
|
||||
for i := range s {
|
||||
s[i] = letters[rand.Intn(len(letters))]
|
||||
}
|
||||
return string(s)
|
||||
}
|
||||
51
api/login_test.go
Normal file
51
api/login_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"TheAdversary/database"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLogin(t *testing.T) {
|
||||
if err := initTestDatabase("login_test.sqlite3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
password, _ := bcrypt.GenerateFromPassword([]byte("super secure password"), bcrypt.DefaultCost)
|
||||
database.GetDB().Table("author").Create([]map[string]interface{}{
|
||||
{
|
||||
"name": "owner",
|
||||
"password": password,
|
||||
"information": "owner of the best blog in the world",
|
||||
},
|
||||
})
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(Login))
|
||||
checkTestInformation(t, server.URL, []testInformation{
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Body: loginPayload{
|
||||
Username: "owner",
|
||||
Password: "super secure password",
|
||||
},
|
||||
ResultCookie: []string{"session_id"},
|
||||
Code: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Body: loginPayload{
|
||||
Username: "not a user",
|
||||
},
|
||||
Code: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Body: loginPayload{
|
||||
Username: "test",
|
||||
},
|
||||
Code: http.StatusUnauthorized,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -2,37 +2,40 @@ package api
|
||||
|
||||
import (
|
||||
"TheAdversary/database"
|
||||
"TheAdversary/schema"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func Recent(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
var limit int64
|
||||
|
||||
query := r.URL.Query()
|
||||
if l := query.Get("limit"); l != "" {
|
||||
limit, err = strconv.ParseInt(l, 10, 64)
|
||||
request := database.GetDB().Table("article")
|
||||
|
||||
limit := 20
|
||||
if query.Has("limit") {
|
||||
var err error
|
||||
limit, err = strconv.Atoi(query.Get("limit"))
|
||||
if err != nil {
|
||||
ApiError{"invalid 'limit' parameter", http.StatusUnprocessableEntity}.Send(w)
|
||||
return
|
||||
} else if limit > 100 {
|
||||
ApiError{"'limit' parameter must not be over 100", http.StatusUnprocessableEntity}.Send(w)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
limit = 20
|
||||
}
|
||||
request.Limit(limit)
|
||||
|
||||
articles, err := database.GetDB().GetArticles("", database.ArticleQueryOptions{
|
||||
Limit: int(limit),
|
||||
})
|
||||
var articleSummaries []schema.ArticleSummary
|
||||
request.Find(&articleSummaries)
|
||||
|
||||
var articleSummaries []database.ArticleSummary
|
||||
for _, article := range articles {
|
||||
articleSummaries = append(articleSummaries, article.ToArticleSummary())
|
||||
for i, summary := range articleSummaries {
|
||||
database.GetDB().Table("author").Where("id IN (?)", database.GetDB().Table("article_author").Select("author_id").Where("article_id = ?", summary.Id)).Find(&summary.Authors)
|
||||
summary.Tags = []string{}
|
||||
database.GetDB().Table("article_tag").Select("tag").Where("article_id = ?", summary.Id).Find(&summary.Tags)
|
||||
articleSummaries[i] = summary
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(struct {
|
||||
Articles []database.ArticleSummary
|
||||
}{articleSummaries})
|
||||
json.NewEncoder(w).Encode(articleSummaries)
|
||||
}
|
||||
|
||||
113
api/recent_test.go
Normal file
113
api/recent_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"TheAdversary/database"
|
||||
"TheAdversary/schema"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRecent(t *testing.T) {
|
||||
if err := initTestDatabase("recent_test.sqlite3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authors := []schema.Author{
|
||||
{
|
||||
Id: 1,
|
||||
Name: "user",
|
||||
Password: "",
|
||||
},
|
||||
}
|
||||
articles := []schema.ArticleSummary{
|
||||
{
|
||||
Id: 1,
|
||||
Title: "test",
|
||||
Summary: "test summary",
|
||||
Authors: authors,
|
||||
Tags: []string{},
|
||||
Created: time.Unix(0, 0).Unix(),
|
||||
Modified: time.Now().Unix(),
|
||||
Link: "/article/test",
|
||||
},
|
||||
{
|
||||
Id: 2,
|
||||
Title: "Recent Article",
|
||||
Summary: "This article is recent",
|
||||
Authors: authors,
|
||||
Tags: []string{},
|
||||
Created: time.Now().Unix(),
|
||||
Link: "/article/recent",
|
||||
},
|
||||
}
|
||||
|
||||
database.GetDB().Table("article").Create([]map[string]interface{}{
|
||||
{
|
||||
"title": articles[0].Title,
|
||||
"summary": articles[0].Summary,
|
||||
"created": articles[0].Created,
|
||||
"modified": articles[0].Modified,
|
||||
"link": articles[0].Link,
|
||||
"markdown": "# Title",
|
||||
"html": "<h1>Title</h1>",
|
||||
},
|
||||
{
|
||||
"title": articles[1].Title,
|
||||
"summary": articles[1].Summary,
|
||||
"created": articles[1].Created,
|
||||
"link": articles[1].Link,
|
||||
"markdown": "This is the most recent article",
|
||||
"html": "<p>This is the most recent article</p>",
|
||||
},
|
||||
})
|
||||
database.GetDB().Table("author").Create(authors)
|
||||
database.GetDB().Table("article_author").Create([]map[string]interface{}{
|
||||
{
|
||||
"article_id": 1,
|
||||
"author_id": 1,
|
||||
},
|
||||
{
|
||||
"article_id": 2,
|
||||
"author_id": 1,
|
||||
},
|
||||
})
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(Recent))
|
||||
checkTestInformation(t, server.URL, []testInformation{
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
ResultBody: articles,
|
||||
Code: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Query: map[string]interface{}{
|
||||
"limit": 10,
|
||||
},
|
||||
ResultBody: articles,
|
||||
Code: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Query: map[string]interface{}{
|
||||
"limit": 1001,
|
||||
},
|
||||
ResultBody: map[string]interface{}{
|
||||
"message": "'limit' parameter must not be over 100",
|
||||
},
|
||||
Code: http.StatusUnprocessableEntity,
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Query: map[string]interface{}{
|
||||
"limit": "notanumber",
|
||||
},
|
||||
ResultBody: map[string]interface{}{
|
||||
"message": "invalid 'limit' parameter",
|
||||
},
|
||||
Code: http.StatusUnprocessableEntity,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -2,59 +2,77 @@ package api
|
||||
|
||||
import (
|
||||
"TheAdversary/database"
|
||||
"TheAdversary/schema"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Search(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
var q string
|
||||
var from, to time.Time
|
||||
var limit int64
|
||||
|
||||
query := r.URL.Query()
|
||||
q = query.Get("q")
|
||||
if f := query.Get("from"); f != "" {
|
||||
from, err = time.Parse(time.RFC3339, f)
|
||||
request := database.GetDB().Table("article")
|
||||
|
||||
if query.Has("q") {
|
||||
request.Where("LOWER(title) LIKE ?", "%"+query.Get("q")+"%")
|
||||
}
|
||||
if query.Has("from") {
|
||||
from, err := strconv.ParseInt(query.Get("from"), 10, 64)
|
||||
if err != nil {
|
||||
ApiError{"could not parse 'from' parameter as RFC3339 time", http.StatusUnprocessableEntity}.Send(w)
|
||||
ApiError{"invalid 'from' parameter", http.StatusUnprocessableEntity}.Send(w)
|
||||
return
|
||||
}
|
||||
request.Where("created >= ?", from).Or("modified >= ?", from)
|
||||
}
|
||||
if t := query.Get("to"); t != "" {
|
||||
to, err = time.Parse(time.RFC3339, t)
|
||||
if query.Has("to") {
|
||||
to, err := strconv.ParseInt(query.Get("to"), 10, 64)
|
||||
if err != nil {
|
||||
ApiError{"could not parse 'to' parameter as RFC3339 time", http.StatusUnprocessableEntity}.Send(w)
|
||||
ApiError{"invalid 'to' parameter", http.StatusUnprocessableEntity}.Send(w)
|
||||
return
|
||||
}
|
||||
request.Where("created <= ?", to).Or("modified <= ?", to)
|
||||
}
|
||||
if l := query.Get("limit"); l != "" {
|
||||
limit, err = strconv.ParseInt(l, 10, 64)
|
||||
if query.Has("authors") {
|
||||
var authorIds []int
|
||||
if err := json.NewDecoder(strings.NewReader(query.Get("authors"))).Decode(&authorIds); err != nil {
|
||||
ApiError{"could not parse 'authors' parameter as array of integers / numbers", http.StatusUnprocessableEntity}.Send(w)
|
||||
return
|
||||
}
|
||||
request.Where("id IN (?)", database.GetDB().Table("article_author").Select("article_id").Where("author_id IN (?)", authorIds))
|
||||
}
|
||||
if query.Has("tags") {
|
||||
var tags []string
|
||||
if err := json.NewDecoder(strings.NewReader(query.Get("tags"))).Decode(&tags); err != nil {
|
||||
ApiError{"could not parse 'tags' parameter as array of strings", http.StatusUnprocessableEntity}.Send(w)
|
||||
return
|
||||
}
|
||||
authorRequest := database.GetDB().Table("article_tag").Select("article_id").Where("tag IN ?", tags)
|
||||
request.Where("id IN (?)", authorRequest)
|
||||
}
|
||||
limit := 20
|
||||
if query.Has("limit") {
|
||||
var err error
|
||||
limit, err = strconv.Atoi(query.Get("limit"))
|
||||
if err != nil {
|
||||
ApiError{"invalid 'limit' parameter", http.StatusUnprocessableEntity}.Send(w)
|
||||
return
|
||||
} else if limit > 100 {
|
||||
ApiError{"'limit' parameter must not be over 100", http.StatusUnprocessableEntity}.Send(w)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
limit = 20
|
||||
}
|
||||
request.Limit(limit)
|
||||
|
||||
articles, err := database.GetDB().GetArticles(q, database.ArticleQueryOptions{
|
||||
Title: true,
|
||||
Summary: true,
|
||||
From: from.Unix(),
|
||||
To: to.Unix(),
|
||||
Limit: int(limit),
|
||||
})
|
||||
var articleSummaries []schema.ArticleSummary
|
||||
request.Find(&articleSummaries)
|
||||
|
||||
var articleSummaries []database.ArticleSummary
|
||||
for _, article := range articles {
|
||||
articleSummaries = append(articleSummaries, article.ToArticleSummary())
|
||||
for i, summary := range articleSummaries {
|
||||
database.GetDB().Table("author").Where("id IN (?)", database.GetDB().Table("article_author").Select("author_id").Where("article_id = ?", summary.Id)).Find(&summary.Authors)
|
||||
summary.Tags = []string{}
|
||||
database.GetDB().Table("article_tag").Select("tag").Where("article_id = ?", summary.Id).Find(&summary.Tags)
|
||||
articleSummaries[i] = summary
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(struct {
|
||||
Articles []database.ArticleSummary `json:"articles"`
|
||||
}{articleSummaries})
|
||||
json.NewEncoder(w).Encode(articleSummaries)
|
||||
}
|
||||
|
||||
231
api/search_test.go
Normal file
231
api/search_test.go
Normal file
@@ -0,0 +1,231 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"TheAdversary/database"
|
||||
"TheAdversary/schema"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSearch(t *testing.T) {
|
||||
if err := initTestDatabase("search_test.sqlite3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
database.GetDB().Table("article").Create([]map[string]interface{}{
|
||||
{
|
||||
"title": "First article",
|
||||
"created": now.Unix(),
|
||||
"link": "/article/first-article",
|
||||
"markdown": "This is my first article",
|
||||
"html": "<p>This is my first article</p>",
|
||||
},
|
||||
{
|
||||
"title": "test",
|
||||
"summary": "test summary",
|
||||
"image": "https://upload.wikimedia.org/wikipedia/commons/0/05/Go_Logo_Blue.svg",
|
||||
"created": now.Unix(),
|
||||
"modified": now.Add(24 * time.Hour).Unix(),
|
||||
"link": "/article/test",
|
||||
"markdown": "# Title",
|
||||
"html": "<h1>Title</h1>",
|
||||
},
|
||||
{
|
||||
"title": "owo",
|
||||
"created": now.Unix(),
|
||||
"modified": now.Add(12 * time.Hour).Unix(),
|
||||
"link": "/article/owo",
|
||||
"markdown": "owo",
|
||||
"html": "<p>owo<p>",
|
||||
},
|
||||
})
|
||||
database.GetDB().Table("author").Create([]map[string]interface{}{
|
||||
{
|
||||
"name": "test",
|
||||
"password": "",
|
||||
},
|
||||
{
|
||||
"name": "hacr",
|
||||
"password": "1234567890",
|
||||
},
|
||||
})
|
||||
database.GetDB().Table("article_tag").Create([]map[string]interface{}{
|
||||
{
|
||||
"article_id": 1,
|
||||
"tag": "example",
|
||||
},
|
||||
})
|
||||
database.GetDB().Table("article_author").Create([]map[string]interface{}{
|
||||
{
|
||||
"article_id": 1,
|
||||
"author_id": 1,
|
||||
},
|
||||
{
|
||||
"article_id": 2,
|
||||
"author_id": 1,
|
||||
},
|
||||
{
|
||||
"article_id": 3,
|
||||
"author_id": 2,
|
||||
},
|
||||
})
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(Search))
|
||||
checkTestInformation(t, server.URL, []testInformation{
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Query: map[string]interface{}{
|
||||
"q": "first",
|
||||
},
|
||||
ResultBody: []schema.ArticleSummary{
|
||||
{
|
||||
Id: 1,
|
||||
Title: "First article",
|
||||
Created: now.Unix(),
|
||||
Authors: []schema.Author{
|
||||
{
|
||||
Id: 1,
|
||||
Name: "test",
|
||||
},
|
||||
},
|
||||
Tags: []string{
|
||||
"example",
|
||||
},
|
||||
Link: "/article/first-article",
|
||||
},
|
||||
},
|
||||
Code: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Query: map[string]interface{}{
|
||||
"from": now.Add(1 * time.Hour).Unix(),
|
||||
"to": now.Add(10 * time.Hour).Unix(),
|
||||
},
|
||||
ResultBody: []schema.ArticleSummary{
|
||||
{
|
||||
Id: 2,
|
||||
Title: "test",
|
||||
Summary: "test summary",
|
||||
Authors: []schema.Author{
|
||||
{
|
||||
Id: 1,
|
||||
Name: "test",
|
||||
},
|
||||
},
|
||||
Image: "https://upload.wikimedia.org/wikipedia/commons/0/05/Go_Logo_Blue.svg",
|
||||
Created: now.Unix(),
|
||||
Modified: now.Add(24 * time.Hour).Unix(),
|
||||
Tags: []string{},
|
||||
Link: "/article/test",
|
||||
},
|
||||
{
|
||||
Id: 3,
|
||||
Title: "owo",
|
||||
Authors: []schema.Author{
|
||||
{
|
||||
Id: 2,
|
||||
Name: "hacr",
|
||||
},
|
||||
},
|
||||
Created: now.Unix(),
|
||||
Modified: now.Add(12 * time.Hour).Unix(),
|
||||
Tags: []string{},
|
||||
Link: "/article/owo",
|
||||
},
|
||||
},
|
||||
Code: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Query: map[string]interface{}{
|
||||
"authors": []int{2},
|
||||
},
|
||||
ResultBody: []schema.ArticleSummary{
|
||||
{
|
||||
Id: 3,
|
||||
Title: "owo",
|
||||
Authors: []schema.Author{
|
||||
{
|
||||
Id: 2,
|
||||
Name: "hacr",
|
||||
},
|
||||
},
|
||||
Created: now.Unix(),
|
||||
Modified: now.Add(12 * time.Hour).Unix(),
|
||||
Tags: []string{},
|
||||
Link: "/article/owo",
|
||||
},
|
||||
},
|
||||
Code: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Query: map[string]interface{}{
|
||||
"tags": []string{"\"example\""},
|
||||
},
|
||||
ResultBody: []schema.ArticleSummary{
|
||||
{
|
||||
Id: 1,
|
||||
Title: "First article",
|
||||
Authors: []schema.Author{
|
||||
{
|
||||
Id: 1,
|
||||
Name: "test",
|
||||
},
|
||||
},
|
||||
Created: now.Unix(),
|
||||
Tags: []string{
|
||||
"example",
|
||||
},
|
||||
Link: "/article/first-article",
|
||||
},
|
||||
},
|
||||
Code: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Query: map[string]interface{}{
|
||||
"limit": 2,
|
||||
},
|
||||
ResultBody: []schema.ArticleSummary{
|
||||
{
|
||||
Id: 1,
|
||||
Title: "First article",
|
||||
Authors: []schema.Author{
|
||||
{
|
||||
Id: 1,
|
||||
Name: "test",
|
||||
},
|
||||
},
|
||||
Created: now.Unix(),
|
||||
Tags: []string{
|
||||
"example",
|
||||
},
|
||||
Link: "/article/first-article",
|
||||
},
|
||||
{
|
||||
Id: 2,
|
||||
Title: "test",
|
||||
Summary: "test summary",
|
||||
Authors: []schema.Author{
|
||||
{
|
||||
Id: 1,
|
||||
Name: "test",
|
||||
},
|
||||
},
|
||||
Image: "https://upload.wikimedia.org/wikipedia/commons/0/05/Go_Logo_Blue.svg",
|
||||
Created: now.Unix(),
|
||||
Modified: now.Add(24 * time.Hour).Unix(),
|
||||
Tags: []string{},
|
||||
Link: "/article/test",
|
||||
},
|
||||
},
|
||||
Code: http.StatusOK,
|
||||
},
|
||||
})
|
||||
}
|
||||
31
api/tags.go
Normal file
31
api/tags.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"TheAdversary/database"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func Tags(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
request := database.GetDB().Table("article_tag")
|
||||
|
||||
if query.Has("name") {
|
||||
request.Where("name LIKE ?", "%"+query.Get("name")+"%")
|
||||
}
|
||||
if query.Has("limit") {
|
||||
limit, err := strconv.Atoi(query.Get("limit"))
|
||||
if err != nil {
|
||||
ApiError{"invalid 'limit' parameter", http.StatusUnprocessableEntity}.Send(w)
|
||||
return
|
||||
}
|
||||
request.Limit(limit)
|
||||
}
|
||||
|
||||
tags := make([]string, 0)
|
||||
request.Distinct("tag").Find(&tags)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(tags)
|
||||
}
|
||||
81
api/tags_test.go
Normal file
81
api/tags_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"TheAdversary/database"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTags(t *testing.T) {
|
||||
if err := initTestDatabase("tags_test.sqlite3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tags := []string{
|
||||
"test",
|
||||
"linux",
|
||||
}
|
||||
|
||||
database.GetDB().Table("article").Create([]map[string]interface{}{
|
||||
{
|
||||
"title": "Upload test",
|
||||
"summary": "An example article to test the upload api endpoint",
|
||||
"created": time.Now().Unix(),
|
||||
"link": "/article/upload-test",
|
||||
"markdown": "Oh god i have to test all this, what am i doing with my life",
|
||||
"html": "<p>Oh god i have to test all this, what am i doing with my life<p>",
|
||||
},
|
||||
})
|
||||
database.GetDB().Table("author").Create([]map[string]interface{}{
|
||||
{
|
||||
"name": "me",
|
||||
"password": "",
|
||||
"information": "this is my account",
|
||||
},
|
||||
})
|
||||
database.GetDB().Table("article_author").Create([]map[string]interface{}{
|
||||
{
|
||||
"article_id": 1,
|
||||
"author_id": 1,
|
||||
},
|
||||
})
|
||||
database.GetDB().Table("article_tag").Create([]map[string]interface{}{
|
||||
{
|
||||
"article_id": 1,
|
||||
"tag": "test",
|
||||
},
|
||||
{
|
||||
"article_id": 1,
|
||||
"tag": "linux",
|
||||
},
|
||||
})
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(Tags))
|
||||
checkTestInformation(t, server.URL, []testInformation{
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
ResultBody: tags,
|
||||
Code: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Query: map[string]interface{}{
|
||||
"name": "abc",
|
||||
},
|
||||
ResultBody: []interface{}{},
|
||||
Code: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Query: map[string]interface{}{
|
||||
"limit": "notanumber",
|
||||
},
|
||||
ResultBody: map[string]interface{}{
|
||||
"message": "invalid 'limit' parameter",
|
||||
},
|
||||
Code: http.StatusUnprocessableEntity,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,60 +1,94 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"TheAdversary/config"
|
||||
"TheAdversary/database"
|
||||
"TheAdversary/parse"
|
||||
"TheAdversary/schema"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/gomarkdown/markdown"
|
||||
"go.uber.org/zap"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type uploadRequest struct {
|
||||
Name string
|
||||
Author string
|
||||
Title string
|
||||
Summary string
|
||||
Image string
|
||||
Tags []string
|
||||
Content string
|
||||
type uploadPayload struct {
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Authors []int `json:"authors"`
|
||||
Image string `json:"image"`
|
||||
Tags []string `json:"tags"`
|
||||
Link string `json:"link"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func Upload(w http.ResponseWriter, r *http.Request) {
|
||||
var request uploadRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
authorId, ok := authorizedSession(r)
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var payload uploadPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
InvalidJson.Send(w)
|
||||
return
|
||||
}
|
||||
|
||||
rawMarkdown, err := base64.StdEncoding.DecodeString(request.Content)
|
||||
rawMarkdown, err := base64.StdEncoding.DecodeString(payload.Content)
|
||||
if err != nil {
|
||||
zap.S().Warnf("Cannot decode base64")
|
||||
ApiError{Message: "invalid base64 content", Code: http.StatusUnprocessableEntity}.Send(w)
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
tags, err := db.AddOrGetTags(request.Tags)
|
||||
if err != nil {
|
||||
zap.S().Error("Failed to add or get tag to / from database: %v", err)
|
||||
DatabaseError.Send(w)
|
||||
return
|
||||
}
|
||||
if err = db.AddArticle(database.Article{
|
||||
Name: request.Name,
|
||||
Title: request.Title,
|
||||
Summary: request.Summary,
|
||||
Image: request.Image,
|
||||
Added: time.Now().Unix(),
|
||||
Markdown: request.Content,
|
||||
Html: string(parse.ParseToHtml(rawMarkdown)),
|
||||
}, tags); err != nil {
|
||||
zap.S().Errorf("Failed to add article to database: %v", err)
|
||||
DatabaseError.Send(w)
|
||||
var notFound []string
|
||||
database.GetDB().Select("* FROM ? EXCEPT ?", payload.Authors, database.GetDB().Table("author").Select("id")).Find(¬Found)
|
||||
if len(notFound) > 0 {
|
||||
ApiError{fmt.Sprintf("no authors with the id(s) %s were found", strings.Join(notFound, ", ")), http.StatusUnprocessableEntity}.Send(w)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
a := article{
|
||||
Title: payload.Title,
|
||||
Summary: payload.Summary,
|
||||
Image: payload.Image,
|
||||
Created: time.Now().Unix(),
|
||||
Modified: time.Unix(0, 0).Unix(),
|
||||
Link: "/" + path.Join(config.Prefix, "article", payload.Link),
|
||||
Markdown: string(rawMarkdown),
|
||||
Html: string(markdown.ToHTML(rawMarkdown, nil, nil)),
|
||||
}
|
||||
database.GetDB().Table("article").Create(&a)
|
||||
var authors []map[string]interface{}
|
||||
for _, author := range append([]int{authorId}, payload.Authors...) {
|
||||
authors = append(authors, map[string]interface{}{
|
||||
"article_id": a.ID,
|
||||
"author_id": author,
|
||||
})
|
||||
}
|
||||
database.GetDB().Table("article_author").Create(&authors)
|
||||
var tags []map[string]interface{}
|
||||
for _, tag := range payload.Tags {
|
||||
authors = append(authors, map[string]interface{}{
|
||||
"article_id": a.ID,
|
||||
"tag": tag,
|
||||
})
|
||||
}
|
||||
database.GetDB().Table("article_tag").Create(&tags)
|
||||
|
||||
var articleSummary schema.ArticleSummary
|
||||
database.GetDB().Table("article").Find(&articleSummary, &a.ID)
|
||||
database.GetDB().Table("author").Where("id IN (?)", database.GetDB().Table("article_author").Select("author_id").Where("article_id = ?", a.ID)).Find(&articleSummary.Authors)
|
||||
if payload.Tags != nil {
|
||||
articleSummary.Tags = payload.Tags
|
||||
} else {
|
||||
articleSummary.Tags = []string{}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(articleSummary)
|
||||
}
|
||||
|
||||
84
api/upload_test.go
Normal file
84
api/upload_test.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"TheAdversary/database"
|
||||
"TheAdversary/schema"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestUpload(t *testing.T) {
|
||||
if err := initTestDatabase("upload_test.sqlite3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
database.GetDB().Table("article").Create([]map[string]interface{}{
|
||||
{
|
||||
"title": "Upload test",
|
||||
"summary": "An example article to test the upload api endpoint",
|
||||
"created": time.Now().Unix(),
|
||||
"link": "/article/upload-test",
|
||||
"markdown": "Oh god i have to test all this, what am i doing with my life",
|
||||
"html": "<p>Oh god i have to test all this, what am i doing with my life<p>",
|
||||
},
|
||||
})
|
||||
database.GetDB().Table("author").Create([]map[string]interface{}{
|
||||
{
|
||||
"name": "me",
|
||||
"password": "",
|
||||
"information": "this is my account",
|
||||
},
|
||||
})
|
||||
database.GetDB().Table("article_author").Create([]map[string]interface{}{
|
||||
{
|
||||
"article_id": 1,
|
||||
"author_id": 1,
|
||||
},
|
||||
})
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(Upload))
|
||||
checkTestInformation(t, server.URL, []testInformation{
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Code: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Cookie: map[string]string{
|
||||
"session_id": initSession(),
|
||||
},
|
||||
Body: uploadPayload{
|
||||
Title: "Testooo",
|
||||
Summary: "I have no idea what to put in here",
|
||||
Authors: []int{1},
|
||||
Link: "testooo",
|
||||
Content: base64.StdEncoding.EncodeToString([]byte("### Testo")),
|
||||
},
|
||||
ResultBody: schema.ArticleSummary{
|
||||
Id: 2,
|
||||
Title: "Testooo",
|
||||
Summary: "I have no idea what to put in here",
|
||||
Authors: []schema.Author{
|
||||
{
|
||||
Id: 1,
|
||||
Name: "me",
|
||||
Information: "this is my account",
|
||||
},
|
||||
},
|
||||
Tags: []string{},
|
||||
Link: "/article/testooo",
|
||||
},
|
||||
Code: http.StatusCreated,
|
||||
AfterExec: func(information *testInformation) {
|
||||
var created int64
|
||||
database.GetDB().Table("article").Select("created").Where("id = 2").Find(&created)
|
||||
res := information.ResultBody.(schema.ArticleSummary)
|
||||
res.Created = created
|
||||
information.ResultBody = res
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -10,6 +10,8 @@ var (
|
||||
|
||||
ServerPort string
|
||||
|
||||
Prefix string
|
||||
|
||||
DatabaseFile string
|
||||
ArticleTemplate string
|
||||
FrontendDir string
|
||||
@@ -22,6 +24,8 @@ func init() {
|
||||
|
||||
ServerPort = os.Getenv("SERVER_PORT")
|
||||
|
||||
Prefix = os.Getenv("PREFIX")
|
||||
|
||||
DatabaseFile = os.Getenv("DATABASE_FILE")
|
||||
ArticleTemplate = os.Getenv("ARTICLE_TEMPLATE")
|
||||
FrontendDir = os.Getenv("FRONTEND_DIR")
|
||||
|
||||
68
database.sql
Normal file
68
database.sql
Normal file
@@ -0,0 +1,68 @@
|
||||
create table article
|
||||
(
|
||||
id integer
|
||||
constraint article_pk
|
||||
primary key autoincrement,
|
||||
title text not null,
|
||||
summary text,
|
||||
image text,
|
||||
created integer not null,
|
||||
modified integer default 0,
|
||||
link text not null,
|
||||
markdown text not null,
|
||||
html text not null
|
||||
);
|
||||
|
||||
create unique index article_link_uindex
|
||||
on article (link);
|
||||
|
||||
create unique index article_title_uindex
|
||||
on article (title);
|
||||
|
||||
create table article_tag
|
||||
(
|
||||
article_id integer not null
|
||||
references article
|
||||
on delete cascade,
|
||||
tag text
|
||||
);
|
||||
|
||||
create table assets
|
||||
(
|
||||
id integer
|
||||
constraint assets_pk
|
||||
primary key autoincrement,
|
||||
name text not null,
|
||||
data blob not null,
|
||||
link string not null
|
||||
);
|
||||
|
||||
create unique index assets_link_uindex
|
||||
on assets (link);
|
||||
|
||||
create unique index assets_name_uindex
|
||||
on assets (name);
|
||||
|
||||
create table author
|
||||
(
|
||||
id integer
|
||||
constraint author_pk
|
||||
primary key autoincrement,
|
||||
name text not null,
|
||||
password text not null,
|
||||
information text
|
||||
);
|
||||
|
||||
create table article_author
|
||||
(
|
||||
article_id integer not null
|
||||
references article
|
||||
on delete cascade,
|
||||
author_id integer not null
|
||||
references author
|
||||
on delete cascade
|
||||
);
|
||||
|
||||
create unique index author_name_uindex
|
||||
on author (name);
|
||||
|
||||
BIN
database.sqlite3
BIN
database.sqlite3
Binary file not shown.
@@ -1,104 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type ArticleQueryOptions struct {
|
||||
Name bool
|
||||
Title bool
|
||||
Summary bool
|
||||
From int64
|
||||
To int64
|
||||
Limit int
|
||||
}
|
||||
|
||||
func (db *Database) GetArticleByName(name string) (article *Article, err error) {
|
||||
err = db.gormDB.Table("article").Where("name = ?", name).Scan(&article).Error
|
||||
if article == nil {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
return article, err
|
||||
}
|
||||
|
||||
func (db *Database) GetArticles(query string, options ArticleQueryOptions) ([]*Article, error) {
|
||||
request := db.gormDB.Table("article")
|
||||
var where bool
|
||||
if options.Name {
|
||||
if where {
|
||||
request.Or("name LIKE ?", fmt.Sprintf("%%%s%%", query))
|
||||
} else {
|
||||
request.Where("name LIKE ?", fmt.Sprintf("%%%s%%", query))
|
||||
where = true
|
||||
}
|
||||
}
|
||||
if options.Title {
|
||||
if where {
|
||||
request.Or("title LIKE ?", fmt.Sprintf("%%%s%%", query))
|
||||
} else {
|
||||
request.Where("title LIKE ?", fmt.Sprintf("%%%s%%", query))
|
||||
where = true
|
||||
}
|
||||
}
|
||||
if options.Summary {
|
||||
if where {
|
||||
request.Or("summary LIKE ?", fmt.Sprintf("%%%s%%", query))
|
||||
} else {
|
||||
request.Where("summary LIKE ?", fmt.Sprintf("%%%s%%", query))
|
||||
where = true
|
||||
}
|
||||
}
|
||||
if !(options.From == 0 || options.To == 0) {
|
||||
var from, to int64
|
||||
if options.From != 0 {
|
||||
from = options.From
|
||||
}
|
||||
if options.To != 0 {
|
||||
to = options.To
|
||||
}
|
||||
request.Where("added BETWEEN ? AND ?", from, to)
|
||||
}
|
||||
if options.Limit > 0 {
|
||||
request.Limit(options.Limit)
|
||||
}
|
||||
rows, err := request.Rows()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var articles []*Article
|
||||
for rows.Next() {
|
||||
article := &Article{}
|
||||
if err = db.gormDB.ScanRows(rows, article); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
articles = append(articles, article)
|
||||
}
|
||||
|
||||
return articles, nil
|
||||
}
|
||||
|
||||
func (db *Database) AddArticle(article Article, tags []Tag) error {
|
||||
if err := db.gormDB.Table("article").Create(&article).Select("id", &article.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.gormDB.Table("article_tags").Create(ArticleTagsFromTagSlice(article, tags)).Error
|
||||
}
|
||||
|
||||
func (db *Database) UpdateArticle(article Article, tags []Tag) error {
|
||||
if err := db.gormDB.Table("article").Where("id = ?", article.ID).Save(article).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.gormDB.Table("article_tags").Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "article_id"}, {Name: "tag_id"}},
|
||||
DoNothing: true,
|
||||
}).Create(ArticleTagsFromTagSlice(article, tags)).Error
|
||||
}
|
||||
|
||||
func (db *Database) DeleteArticlesByNames(names ...string) error {
|
||||
return db.gormDB.Table("article").Where("name IN (?)", names).Delete("*").Error
|
||||
}
|
||||
@@ -5,29 +5,20 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var globDatabase *Database
|
||||
var globDatabase *gorm.DB
|
||||
|
||||
type Database struct {
|
||||
gormDB *gorm.DB
|
||||
func newDatabaseConnection(dialector gorm.Dialector) (*gorm.DB, error) {
|
||||
return gorm.Open(dialector)
|
||||
}
|
||||
|
||||
func newDatabaseConnection(dialector gorm.Dialector) (*Database, error) {
|
||||
db, err := gorm.Open(dialector)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Database{db}, nil
|
||||
}
|
||||
|
||||
func NewSqlite3Connection(databaseFile string) (*Database, error) {
|
||||
func NewSqlite3Connection(databaseFile string) (*gorm.DB, error) {
|
||||
return newDatabaseConnection(sqlite.Open(databaseFile))
|
||||
}
|
||||
|
||||
func GetDB() *Database {
|
||||
func GetDB() *gorm.DB {
|
||||
return globDatabase
|
||||
}
|
||||
|
||||
func SetGlobDB(database *Database) {
|
||||
func SetGlobDB(database *gorm.DB) {
|
||||
globDatabase = database
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
package database
|
||||
|
||||
import "fmt"
|
||||
|
||||
type Article struct {
|
||||
ID int
|
||||
Name string
|
||||
Title string
|
||||
Summary string
|
||||
Image string
|
||||
Added int64
|
||||
Modified int64
|
||||
Markdown string
|
||||
Html string
|
||||
}
|
||||
|
||||
func (a Article) ToArticleSummary() ArticleSummary {
|
||||
return ArticleSummary{
|
||||
Title: a.Title,
|
||||
Summary: a.Summary,
|
||||
Image: a.Image,
|
||||
Link: fmt.Sprintf("/article/%s", a.Name),
|
||||
}
|
||||
}
|
||||
|
||||
type ArticleSummary struct {
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Image string `json:"image"`
|
||||
Link string `json:"link"`
|
||||
}
|
||||
|
||||
type Author struct {
|
||||
ID int
|
||||
Name string
|
||||
Email string
|
||||
Password string
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"gorm.io/gorm/clause"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Tag struct {
|
||||
ID int
|
||||
Name string
|
||||
}
|
||||
|
||||
func ArticleTagsFromTagSlice(article Article, tags []Tag) (tagsTable []ArticleTags) {
|
||||
for _, tag := range tags {
|
||||
tagsTable = append(tagsTable, ArticleTags{
|
||||
ArticleID: article.ID,
|
||||
TagID: tag.ID,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type ArticleTags struct {
|
||||
ArticleID int
|
||||
TagID int
|
||||
}
|
||||
|
||||
func (db *Database) AddOrGetTags(names []string) (tags []Tag, err error) {
|
||||
for i, name := range names {
|
||||
names[i] = strings.ToLower(name)
|
||||
}
|
||||
|
||||
err = db.gormDB.Table("tag").Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "name"}},
|
||||
DoNothing: true,
|
||||
}).Select("name").Create(&names).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = db.gormDB.Table("tag").Find(&tags).Where("name in (?)", &names).Error
|
||||
return
|
||||
}
|
||||
|
||||
func (db *Database) SetTags(article Article, tags []Tag) error {
|
||||
return db.gormDB.Table("tags").Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "article_id"}, {Name: "tag_id"}},
|
||||
DoNothing: true,
|
||||
}).Create(ArticleTagsFromTagSlice(article, tags)).Error
|
||||
}
|
||||
|
||||
func (db *Database) RemoveTags(article Article, tags []Tag) error {
|
||||
return db.gormDB.Table("tags").Delete(ArticleTagsFromTagSlice(article, tags)).Error
|
||||
}
|
||||
9
database/utils.go
Normal file
9
database/utils.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package database
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
func Exists(tx *gorm.DB, query interface{}, args ...interface{}) bool {
|
||||
var exists bool
|
||||
tx.Where(query, args...).Select("count(*) > 0").Find(&exists)
|
||||
return exists
|
||||
}
|
||||
3
go.mod
3
go.mod
@@ -17,5 +17,6 @@ require (
|
||||
github.com/mattn/go-sqlite3 v1.14.9 // indirect
|
||||
go.uber.org/atomic v1.7.0 // indirect
|
||||
go.uber.org/multierr v1.6.0 // indirect
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007 // indirect
|
||||
golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce // indirect
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 // indirect
|
||||
)
|
||||
|
||||
3
go.sum
3
go.sum
@@ -40,6 +40,8 @@ go.uber.org/zap v1.19.1 h1:ue41HOKd1vGURxrmeKIgELGb3jPW9DMUDGtsinblHwI=
|
||||
go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce h1:Roh6XWxHFKrPgC/EQhVubSAGQ6Ozk6IdxHSzt1mR0EI=
|
||||
golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
@@ -55,6 +57,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007 h1:gG67DSER+11cZvqIMb8S8bt0vZtiN6xWYARwirrOSfE=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
|
||||
12
main.go
12
main.go
@@ -15,10 +15,20 @@ func main() {
|
||||
r := mux.NewRouter()
|
||||
r.StrictSlash(true)
|
||||
|
||||
r.HandleFunc("/api/upload", api.Upload).Methods(http.MethodPost)
|
||||
r.HandleFunc("/api/login", api.Login).Methods(http.MethodPost)
|
||||
|
||||
r.HandleFunc("/api/authors", api.Authors).Methods(http.MethodGet)
|
||||
r.HandleFunc("/api/tags", api.Tags).Methods(http.MethodGet)
|
||||
|
||||
r.HandleFunc("/api/recent", api.Recent).Methods(http.MethodGet)
|
||||
r.HandleFunc("/api/search", api.Search).Methods(http.MethodGet)
|
||||
|
||||
r.HandleFunc("/api/upload", api.Upload).Methods(http.MethodPost)
|
||||
r.HandleFunc("/api/edit", api.Edit).Methods(http.MethodPost)
|
||||
r.HandleFunc("/api/delete", api.Delete).Methods(http.MethodPost)
|
||||
|
||||
r.HandleFunc("/api/assets", api.Assets).Methods(http.MethodGet, http.MethodPost, http.MethodDelete)
|
||||
|
||||
r.HandleFunc("/article/{article}", server.Article).Methods(http.MethodGet)
|
||||
|
||||
r.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
26
schema/schema.go
Normal file
26
schema/schema.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package schema
|
||||
|
||||
type ArticleSummary struct {
|
||||
Id int `json:"id" gorm:"primaryKey"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Authors []Author `json:"authors" gorm:"-"`
|
||||
Image string `json:"image"`
|
||||
Tags []string `json:"tags" gorm:"-"`
|
||||
Created int64 `json:"created"`
|
||||
Modified int64 `json:"modified"`
|
||||
Link string `json:"link"`
|
||||
}
|
||||
|
||||
type Author struct {
|
||||
Id int `json:"id" gorm:"primaryKey"`
|
||||
Name string `json:"name"`
|
||||
Password string `json:"-"`
|
||||
Information string `json:"information"`
|
||||
}
|
||||
|
||||
type Asset struct {
|
||||
Id int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Link string `json:"link"`
|
||||
}
|
||||
@@ -14,8 +14,8 @@ func Article(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
|
||||
articleName := mux.Vars(r)["article"]
|
||||
article, err := database.GetDB().GetArticleByName(articleName)
|
||||
if err != nil {
|
||||
var article string
|
||||
if err := database.GetDB().Table("article").Select("html").Where("name = ?", articleName).First(&article).Error; err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
Error404(w, r)
|
||||
} else {
|
||||
@@ -25,5 +25,5 @@ func Article(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, article.Html)
|
||||
fmt.Fprint(w, article)
|
||||
}
|
||||
|
||||
1
server/landingpage.go
Normal file
1
server/landingpage.go
Normal file
@@ -0,0 +1 @@
|
||||
package server
|
||||
Reference in New Issue
Block a user