Added api endpoints and tests

This commit is contained in:
2022-01-24 12:59:43 +01:00
parent 10b768743b
commit cfbdcc7f82
36 changed files with 1781 additions and 315 deletions

2
.env
View File

@@ -2,6 +2,8 @@ ARTICLE_ROOT=../articles/
SERVER_PORT=8080 SERVER_PORT=8080
PREFIX=
DATABASE_FILE=database.sqlite3 DATABASE_FILE=database.sqlite3
ARTICLE_TEMPLATE=../frontend/html/article.gohtml ARTICLE_TEMPLATE=../frontend/html/article.gohtml
FRONTEND_DIR=../frontend/ FRONTEND_DIR=../frontend/

48
api/api.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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(&notFound)
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
View 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,
},
})
}

View File

@@ -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
View 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
View 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,
},
})
}

View File

@@ -2,37 +2,40 @@ package api
import ( import (
"TheAdversary/database" "TheAdversary/database"
"TheAdversary/schema"
"encoding/json" "encoding/json"
"net/http" "net/http"
"strconv" "strconv"
) )
func Recent(w http.ResponseWriter, r *http.Request) { func Recent(w http.ResponseWriter, r *http.Request) {
var err error
var limit int64
query := r.URL.Query() query := r.URL.Query()
if l := query.Get("limit"); l != "" { request := database.GetDB().Table("article")
limit, err = strconv.ParseInt(l, 10, 64)
limit := 20
if query.Has("limit") {
var err error
limit, err = strconv.Atoi(query.Get("limit"))
if err != nil { if err != nil {
ApiError{"invalid 'limit' parameter", http.StatusUnprocessableEntity}.Send(w) ApiError{"invalid 'limit' parameter", http.StatusUnprocessableEntity}.Send(w)
return 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{ var articleSummaries []schema.ArticleSummary
Limit: int(limit), request.Find(&articleSummaries)
})
var articleSummaries []database.ArticleSummary for i, summary := range articleSummaries {
for _, article := range articles { database.GetDB().Table("author").Where("id IN (?)", database.GetDB().Table("article_author").Select("author_id").Where("article_id = ?", summary.Id)).Find(&summary.Authors)
articleSummaries = append(articleSummaries, article.ToArticleSummary()) 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) w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(struct { json.NewEncoder(w).Encode(articleSummaries)
Articles []database.ArticleSummary
}{articleSummaries})
} }

113
api/recent_test.go Normal file
View 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,
},
})
}

View File

@@ -2,59 +2,77 @@ package api
import ( import (
"TheAdversary/database" "TheAdversary/database"
"TheAdversary/schema"
"encoding/json" "encoding/json"
"net/http" "net/http"
"strconv" "strconv"
"time" "strings"
) )
func Search(w http.ResponseWriter, r *http.Request) { 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() query := r.URL.Query()
q = query.Get("q") request := database.GetDB().Table("article")
if f := query.Get("from"); f != "" {
from, err = time.Parse(time.RFC3339, f) 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 { 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 return
} }
request.Where("created >= ?", from).Or("modified >= ?", from)
} }
if t := query.Get("to"); t != "" { if query.Has("to") {
to, err = time.Parse(time.RFC3339, t) to, err := strconv.ParseInt(query.Get("to"), 10, 64)
if err != nil { 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 return
} }
request.Where("created <= ?", to).Or("modified <= ?", to)
} }
if l := query.Get("limit"); l != "" { if query.Has("authors") {
limit, err = strconv.ParseInt(l, 10, 64) 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 { if err != nil {
ApiError{"invalid 'limit' parameter", http.StatusUnprocessableEntity}.Send(w) ApiError{"invalid 'limit' parameter", http.StatusUnprocessableEntity}.Send(w)
return 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{ var articleSummaries []schema.ArticleSummary
Title: true, request.Find(&articleSummaries)
Summary: true,
From: from.Unix(),
To: to.Unix(),
Limit: int(limit),
})
var articleSummaries []database.ArticleSummary for i, summary := range articleSummaries {
for _, article := range articles { database.GetDB().Table("author").Where("id IN (?)", database.GetDB().Table("article_author").Select("author_id").Where("article_id = ?", summary.Id)).Find(&summary.Authors)
articleSummaries = append(articleSummaries, article.ToArticleSummary()) 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) w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(struct { json.NewEncoder(w).Encode(articleSummaries)
Articles []database.ArticleSummary `json:"articles"`
}{articleSummaries})
} }

231
api/search_test.go Normal file
View 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
View 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
View 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,
},
})
}

View File

@@ -1,60 +1,94 @@
package api package api
import ( import (
"TheAdversary/config"
"TheAdversary/database" "TheAdversary/database"
"TheAdversary/parse" "TheAdversary/schema"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt"
"github.com/gomarkdown/markdown"
"go.uber.org/zap" "go.uber.org/zap"
"net/http" "net/http"
"path"
"strings"
"time" "time"
) )
type uploadRequest struct { type uploadPayload struct {
Name string Title string `json:"title"`
Author string Summary string `json:"summary"`
Title string Authors []int `json:"authors"`
Summary string Image string `json:"image"`
Image string Tags []string `json:"tags"`
Tags []string Link string `json:"link"`
Content string Content string `json:"content"`
} }
func Upload(w http.ResponseWriter, r *http.Request) { func Upload(w http.ResponseWriter, r *http.Request) {
var request uploadRequest authorId, ok := authorizedSession(r)
if err := json.NewDecoder(r.Body).Decode(&request); err != nil { if !ok {
w.WriteHeader(http.StatusUnauthorized)
return
}
var payload uploadPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
InvalidJson.Send(w) InvalidJson.Send(w)
return return
} }
rawMarkdown, err := base64.StdEncoding.DecodeString(request.Content) rawMarkdown, err := base64.StdEncoding.DecodeString(payload.Content)
if err != nil { if err != nil {
zap.S().Warnf("Cannot decode base64") zap.S().Warnf("Cannot decode base64")
ApiError{Message: "invalid base64 content", Code: http.StatusUnprocessableEntity}.Send(w) ApiError{Message: "invalid base64 content", Code: http.StatusUnprocessableEntity}.Send(w)
return return
} }
db := database.GetDB() var notFound []string
database.GetDB().Select("* FROM ? EXCEPT ?", payload.Authors, database.GetDB().Table("author").Select("id")).Find(&notFound)
tags, err := db.AddOrGetTags(request.Tags) if len(notFound) > 0 {
if err != nil { ApiError{fmt.Sprintf("no authors with the id(s) %s were found", strings.Join(notFound, ", ")), http.StatusUnprocessableEntity}.Send(w)
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)
return 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
View 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
},
},
})
}

View File

@@ -10,6 +10,8 @@ var (
ServerPort string ServerPort string
Prefix string
DatabaseFile string DatabaseFile string
ArticleTemplate string ArticleTemplate string
FrontendDir string FrontendDir string
@@ -22,6 +24,8 @@ func init() {
ServerPort = os.Getenv("SERVER_PORT") ServerPort = os.Getenv("SERVER_PORT")
Prefix = os.Getenv("PREFIX")
DatabaseFile = os.Getenv("DATABASE_FILE") DatabaseFile = os.Getenv("DATABASE_FILE")
ArticleTemplate = os.Getenv("ARTICLE_TEMPLATE") ArticleTemplate = os.Getenv("ARTICLE_TEMPLATE")
FrontendDir = os.Getenv("FRONTEND_DIR") FrontendDir = os.Getenv("FRONTEND_DIR")

68
database.sql Normal file
View 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);

Binary file not shown.

View File

@@ -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
}

View File

@@ -5,29 +5,20 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
var globDatabase *Database var globDatabase *gorm.DB
type Database struct { func newDatabaseConnection(dialector gorm.Dialector) (*gorm.DB, error) {
gormDB *gorm.DB return gorm.Open(dialector)
} }
func newDatabaseConnection(dialector gorm.Dialector) (*Database, error) { func NewSqlite3Connection(databaseFile string) (*gorm.DB, error) {
db, err := gorm.Open(dialector)
if err != nil {
return nil, err
}
return &Database{db}, nil
}
func NewSqlite3Connection(databaseFile string) (*Database, error) {
return newDatabaseConnection(sqlite.Open(databaseFile)) return newDatabaseConnection(sqlite.Open(databaseFile))
} }
func GetDB() *Database { func GetDB() *gorm.DB {
return globDatabase return globDatabase
} }
func SetGlobDB(database *Database) { func SetGlobDB(database *gorm.DB) {
globDatabase = database globDatabase = database
} }

View File

@@ -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
}

View File

@@ -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
View 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
View File

@@ -17,5 +17,6 @@ require (
github.com/mattn/go-sqlite3 v1.14.9 // indirect github.com/mattn/go-sqlite3 v1.14.9 // indirect
go.uber.org/atomic v1.7.0 // indirect go.uber.org/atomic v1.7.0 // indirect
go.uber.org/multierr v1.6.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
View File

@@ -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= 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-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-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/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/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 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-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 h1:gG67DSER+11cZvqIMb8S8bt0vZtiN6xWYARwirrOSfE=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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/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.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=

12
main.go
View File

@@ -15,10 +15,20 @@ func main() {
r := mux.NewRouter() r := mux.NewRouter()
r.StrictSlash(true) 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/recent", api.Recent).Methods(http.MethodGet)
r.HandleFunc("/api/search", api.Search).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("/article/{article}", server.Article).Methods(http.MethodGet)
r.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { r.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {

26
schema/schema.go Normal file
View 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"`
}

View File

@@ -14,8 +14,8 @@ func Article(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
articleName := mux.Vars(r)["article"] articleName := mux.Vars(r)["article"]
article, err := database.GetDB().GetArticleByName(articleName) var article string
if err != nil { if err := database.GetDB().Table("article").Select("html").Where("name = ?", articleName).First(&article).Error; err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
Error404(w, r) Error404(w, r)
} else { } else {
@@ -25,5 +25,5 @@ func Article(w http.ResponseWriter, r *http.Request) {
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
fmt.Fprint(w, article.Html) fmt.Fprint(w, article)
} }

1
server/landingpage.go Normal file
View File

@@ -0,0 +1 @@
package server