Added backend

This commit is contained in:
2022-02-06 22:51:36 +01:00
parent 77599e26f8
commit 431cadaeea
35 changed files with 2422 additions and 0 deletions

9
backend/.env Normal file
View File

@@ -0,0 +1,9 @@
SERVER_PORT=8080
# the global address of your webserver (protocol://domain[:port]). make sure this DOES NOT has a trailing slash
ADDRESS=http://localhost:8080
# the path you serve on
SUBPATH=
DATABASE_FILE=database.sqlite3
FRONTEND_DIR=./frontend/

1
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.idea/

1
backend/README.md Normal file
View File

@@ -0,0 +1 @@
# backend

48
backend/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
}

136
backend/api/api_test.go Normal file
View File

@@ -0,0 +1,136 @@
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
Header map[string]string
Cookie map[string]string
Body interface{}
Query map[string]interface{}
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 {
if b, ok := information.Body.([]byte); ok {
body = bytes.NewReader(b)
} else {
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,
})
}
}
if information.Header != nil {
for name, value := range information.Header {
req.Header.Set(name, 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)
}
}
}
}
}

308
backend/api/article.go Normal file
View File

@@ -0,0 +1,308 @@
package api
import (
"TheAdversary/config"
"TheAdversary/database"
"TheAdversary/schema"
"encoding/base64"
"encoding/json"
"fmt"
"github.com/gomarkdown/markdown"
"go.uber.org/zap"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"time"
)
func Article(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
articleGet(w, r)
case http.MethodPost:
articlePost(w, r)
case http.MethodDelete:
articleDelete(w, r)
case http.MethodPatch:
articlePatch(w, r)
}
}
type getResponse 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 articleGet(w http.ResponseWriter, r *http.Request) {
authorId, ok := authorizedSession(r)
if !ok {
w.WriteHeader(http.StatusUnauthorized)
return
}
rawId := r.URL.Query().Get("id")
if rawId == "" {
ApiError{Message: "no id was given", Code: http.StatusBadRequest}.Send(w)
return
}
id, err := strconv.Atoi(rawId)
if err != nil {
ApiError{"invalid 'id' parameter", http.StatusUnprocessableEntity}.Send(w)
return
}
if !database.Exists(database.GetDB().Table("article_author"), "author_id=?", authorId) {
w.WriteHeader(http.StatusUnauthorized)
return
}
var article database.Article
if database.GetDB().Table("article").First(&article, id).RowsAffected == 0 {
ApiError{Message: "no such id", Code: http.StatusNotFound}.Send(w)
return
}
resp := getResponse{
Title: article.Title,
Summary: article.Summary,
Image: article.Image,
Link: article.Link,
Content: base64.StdEncoding.EncodeToString([]byte(article.Markdown)),
}
database.GetDB().Table("article_author").Select("author_id").Where("article_id", article.Id).Find(&resp.Authors)
database.GetDB().Table("article_tag").Select("tag").Where("article_id", article.Id).Find(&resp.Tags)
json.NewEncoder(w).Encode(resp)
}
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 articlePost(w http.ResponseWriter, r *http.Request) {
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(payload.Content)
if err != nil {
zap.S().Warnf("Cannot decode base64")
ApiError{Message: "invalid base64 content", Code: http.StatusUnprocessableEntity}.Send(w)
return
}
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
}
a := article{
Title: payload.Title,
Summary: payload.Summary,
Image: payload.Image,
Created: time.Now().Unix(),
Modified: time.Unix(0, 0).Unix(),
Link: 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)
if len(payload.Tags) > 0 {
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{}
}
articleSummary.Link = path.Join(config.SubPath, "article", url.PathEscape(articleSummary.Link))
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(articleSummary)
}
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 articlePatch(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"] = *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{}
}
articleSummary.Link = path.Join(config.SubPath, "article", url.PathEscape(articleSummary.Link))
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(articleSummary)
}
type deletePayload struct {
Id int `json:"id"`
}
func articleDelete(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)
}

319
backend/api/article_test.go Normal file
View File

@@ -0,0 +1,319 @@
package api
import (
"TheAdversary/database"
"TheAdversary/schema"
"encoding/base64"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestArticleGet(t *testing.T) {
if err := initTestDatabase("upload_get_test.sqlite3"); err != nil {
t.Fatal(err)
}
database.GetDB().Table("article").Create([]map[string]interface{}{
{
"title": "Get test",
"summary": "",
"created": time.Now().Unix(),
"link": "get-test",
"markdown": "Testing ._.",
"html": "<p>Testing ._.<p>",
},
})
database.GetDB().Table("author").Create([]map[string]interface{}{
{
"name": "admin",
"password": "",
"information": "admin",
},
})
database.GetDB().Table("article_author").Create([]map[string]interface{}{
{
"article_id": 1,
"author_id": 1,
},
})
server := httptest.NewServer(http.HandlerFunc(articleGet))
checkTestInformation(t, server.URL, []testInformation{
{
Method: http.MethodGet,
Code: http.StatusUnauthorized,
},
{
Method: http.MethodGet,
Cookie: map[string]string{
"session_id": initSession(),
},
ResultBody: map[string]interface{}{
"message": "no id was given",
},
Code: http.StatusBadRequest,
},
{
Method: http.MethodGet,
Query: map[string]interface{}{
"id": 1,
},
Cookie: map[string]string{
"session_id": initSession(),
},
ResultBody: getResponse{
Title: "Get test",
Authors: []int{1},
Tags: []string{},
Link: "get-test",
Content: base64.StdEncoding.EncodeToString([]byte("Testing ._.")),
},
Code: http.StatusOK,
},
})
}
func TestArticlePost(t *testing.T) {
if err := initTestDatabase("upload_post_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": "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(articlePost))
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
},
},
})
}
func TestArticlePatch(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": "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(articlePatch))
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,
},
})
}
func TestArticleDelete(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": "test",
"markdown": "# Title",
"html": "<h1>Title</h1>",
},
{
"title": "owo",
"created": time.Now().Unix(),
"link": "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(articleDelete))
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,
},
})
}

140
backend/api/assets.go Normal file
View File

@@ -0,0 +1,140 @@
package api
import (
"TheAdversary/config"
"TheAdversary/database"
"TheAdversary/schema"
"encoding/json"
"gorm.io/gorm/clause"
"io"
"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) {
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) {
_, ok := authorizedSession(r)
if !ok {
w.WriteHeader(http.StatusUnauthorized)
return
}
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)
for _, asset := range assets {
asset.Link = path.Join(config.SubPath, "assets", asset.Link)
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(&assets)
}
func assetsPost(w http.ResponseWriter, r *http.Request) {
_, ok := authorizedSession(r)
if !ok {
w.WriteHeader(http.StatusUnauthorized)
return
}
file, header, err := r.FormFile("file")
if err != nil {
if err == http.ErrMissingFile {
ApiError{Message: "file is missing", Code: http.StatusUnprocessableEntity}.Send(w)
} else {
ApiError{Message: "could not parse file" + err.Error(), Code: http.StatusInternalServerError}.Send(w)
}
return
}
defer file.Close()
var name string
if name = r.FormValue("name"); name == "" {
name = header.Filename
}
rawData, err := io.ReadAll(file)
if err != nil {
ApiError{Message: "failed to read file", Code: http.StatusInternalServerError}.Send(w)
return
}
tmpDatabaseSchema := struct {
Id int
Name string
Data []byte
Link string
}{Name: name, Data: rawData, Link: url.PathEscape(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.StatusCreated)
json.NewEncoder(w).Encode(schema.Asset{
Id: tmpDatabaseSchema.Id,
Name: tmpDatabaseSchema.Name,
Link: path.Join(config.SubPath, "assets", tmpDatabaseSchema.Link),
})
}
}
type assetsDeletePayload struct {
Id int `json:"id"`
}
func assetsDelete(w http.ResponseWriter, r *http.Request) {
_, ok := authorizedSession(r)
if !ok {
w.WriteHeader(http.StatusUnauthorized)
return
}
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)
}
}

183
backend/api/assets_test.go Normal file
View File

@@ -0,0 +1,183 @@
package api
import (
"TheAdversary/database"
"TheAdversary/schema"
"bytes"
"fmt"
"mime/multipart"
"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,
Code: http.StatusUnauthorized,
},
{
Method: http.MethodGet,
Cookie: map[string]string{
"session_id": initSession(),
},
Query: map[string]interface{}{
"q": "linux",
},
ResultBody: []schema.Asset{
{
Id: 1,
Name: "linux",
Link: "assets/linux",
},
},
Code: http.StatusOK,
},
{
Method: http.MethodGet,
Cookie: map[string]string{
"session_id": initSession(),
},
Query: map[string]interface{}{
"limit": 1,
},
ResultBody: []schema.Asset{
{
Id: 1,
Name: "linux",
Link: "assets/linux",
},
},
Code: http.StatusOK,
},
{
Method: http.MethodGet,
Cookie: map[string]string{
"session_id": initSession(),
},
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)
}
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
mw.SetBoundary("test")
formFile, _ := mw.CreateFormFile("file", "srfwsr")
formFile.Write([]byte("just a test file"))
mw.WriteField("name", "test")
mw.Close()
fmt.Println(buf.String())
server := httptest.NewServer(http.HandlerFunc(assetsPost))
checkTestInformation(t, server.URL, []testInformation{
{
Method: http.MethodPost,
Code: http.StatusUnauthorized,
},
{
Method: http.MethodPost,
Header: map[string]string{
"Content-Type": "multipart/form-data; boundary=test",
},
Cookie: map[string]string{
"session_id": initSession(),
},
Body: buf.Bytes(),
ResultBody: schema.Asset{
Id: 1,
Name: "test",
Link: "assets/test",
},
Code: http.StatusCreated,
},
{
Method: http.MethodPost,
Header: map[string]string{
"Content-Type": "multipart/form-data; boundary=test",
},
Cookie: map[string]string{
"session_id": initSession(),
},
Body: buf.Bytes(),
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,
Code: http.StatusUnauthorized,
},
{
Method: http.MethodDelete,
Cookie: map[string]string{
"session_id": initSession(),
},
Body: assetsDeletePayload{
Id: 1,
},
Code: http.StatusOK,
},
{
Method: http.MethodDelete,
Cookie: map[string]string{
"session_id": initSession(),
},
Body: assetsDeletePayload{
Id: 69,
},
Code: http.StatusNotFound,
},
})
}

32
backend/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)
}

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

50
backend/api/login.go Normal file
View File

@@ -0,0 +1,50 @@
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,
Path: "/",
})
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
backend/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,
},
})
}

44
backend/api/recent.go Normal file
View File

@@ -0,0 +1,44 @@
package api
import (
"TheAdversary/config"
"TheAdversary/database"
"TheAdversary/schema"
"encoding/json"
"net/http"
"path"
"strconv"
)
func Recent(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
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
}
}
request.Limit(limit)
var articleSummaries []schema.ArticleSummary
request.Find(&articleSummaries)
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)
summary.Link = path.Join(config.SubPath, "article", summary.Link)
articleSummaries[i] = summary
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(articleSummaries)
}

114
backend/api/recent_test.go Normal file
View File

@@ -0,0 +1,114 @@
package api
import (
"TheAdversary/database"
"TheAdversary/schema"
"net/http"
"net/http/httptest"
"path"
"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": path.Base(articles[0].Link),
"markdown": "# Title",
"html": "<h1>Title</h1>",
},
{
"title": articles[1].Title,
"summary": articles[1].Summary,
"created": articles[1].Created,
"link": path.Base(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,
},
})
}

81
backend/api/search.go Normal file
View File

@@ -0,0 +1,81 @@
package api
import (
"TheAdversary/config"
"TheAdversary/database"
"TheAdversary/schema"
"encoding/json"
"net/http"
"path"
"strconv"
"strings"
)
func Search(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
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{"invalid 'from' parameter", http.StatusUnprocessableEntity}.Send(w)
return
}
request.Where("created >= ?", from).Or("modified >= ?", from)
}
if query.Has("to") {
to, err := strconv.ParseInt(query.Get("to"), 10, 64)
if err != nil {
ApiError{"invalid 'to' parameter", http.StatusUnprocessableEntity}.Send(w)
return
}
request.Where("created <= ?", to).Or("modified <= ?", to)
}
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
}
}
request.Limit(limit)
var articleSummaries []schema.ArticleSummary
request.Find(&articleSummaries)
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)
summary.Link = path.Join(config.SubPath, "article", summary.Link)
articleSummaries[i] = summary
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(articleSummaries)
}

231
backend/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": "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": "test",
"markdown": "# Title",
"html": "<h1>Title</h1>",
},
{
"title": "owo",
"created": now.Unix(),
"modified": now.Add(12 * time.Hour).Unix(),
"link": "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
backend/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("tag 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
backend/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,
},
})
}

28
backend/config/config.go Normal file
View File

@@ -0,0 +1,28 @@
package config
import (
"github.com/joho/godotenv"
"os"
)
var (
ServerPort string
Address string
SubPath string
DatabaseFile string
FrontendDir string
)
func init() {
godotenv.Load()
ServerPort = os.Getenv("SERVER_PORT")
Address = os.Getenv("ADDRESS")
SubPath = os.Getenv("SUBPATH")
DatabaseFile = os.Getenv("DATABASE_FILE")
FrontendDir = os.Getenv("FRONTEND_DIR")
}

68
backend/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);

BIN
backend/database.sqlite3 Normal file

Binary file not shown.

View File

@@ -0,0 +1,24 @@
package database
import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
var globDatabase *gorm.DB
func newDatabaseConnection(dialector gorm.Dialector) (*gorm.DB, error) {
return gorm.Open(dialector)
}
func NewSqlite3Connection(databaseFile string) (*gorm.DB, error) {
return newDatabaseConnection(sqlite.Open(databaseFile))
}
func GetDB() *gorm.DB {
return globDatabase
}
func SetGlobDB(database *gorm.DB) {
globDatabase = database
}

View File

@@ -0,0 +1,13 @@
package database
type Article struct {
Id int `gorm:"primaryKey"`
Title string
Summary string
Image string
Created int64
Modified int64
Link string
Markdown string
Html string
}

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
}

22
backend/go.mod Normal file
View File

@@ -0,0 +1,22 @@
module TheAdversary
go 1.17
require (
github.com/gomarkdown/markdown v0.0.0-20211207152620-5d6539fd8bfc
github.com/gorilla/mux v1.8.0
github.com/joho/godotenv v1.4.0
go.uber.org/zap v1.19.1
gorm.io/driver/sqlite v1.2.6
gorm.io/gorm v1.22.4
)
require (
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.3 // indirect
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/crypto v0.0.0-20220112180741-5e0467b6c7ce // indirect
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 // indirect
)

82
backend/go.sum Normal file
View File

@@ -0,0 +1,82 @@
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gomarkdown/markdown v0.0.0-20211207152620-5d6539fd8bfc h1:mmMAHzJGtMsCaDyRgPNMO6cbSzeKCZxHTA1Sn/wirko=
github.com/gomarkdown/markdown v0.0.0-20211207152620-5d6539fd8bfc/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.2/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.3 h1:PlHq1bSCSZL9K0wUhbm2pGLoTWs2GwVhsP6emvGV/ZI=
github.com/jinzhu/now v1.1.3/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg=
github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/mattn/go-sqlite3 v1.14.9 h1:10HX2Td0ocZpYEjhilsuo6WWtUqttj2Kb0KtD86/KYA=
github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723 h1:sHOAIxRGBp443oHZIPB+HsUGaksVCXVQENPxwTfQdH4=
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
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=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/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/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=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.2.6 h1:SStaH/b+280M7C8vXeZLz/zo9cLQmIGwwj3cSj7p6l4=
gorm.io/driver/sqlite v1.2.6/go.mod h1:gyoX0vHiiwi0g49tv+x2E7l8ksauLK0U/gShcdUsjWY=
gorm.io/gorm v1.22.3/go.mod h1:F+OptMscr0P2F2qU97WT1WimdH9GaQPoDW7AYd5i2Y0=
gorm.io/gorm v1.22.4 h1:8aPcyEJhY0MAt8aY6Dc524Pn+pO29K+ydu+e/cXSpQM=
gorm.io/gorm v1.22.4/go.mod h1:1aeVC+pe9ZmvKZban/gW4QPra7PRoTEssyc922qCAkk=

82
backend/main.go Normal file
View File

@@ -0,0 +1,82 @@
package main
import (
"TheAdversary/api"
"TheAdversary/config"
"TheAdversary/database"
"TheAdversary/server"
"fmt"
"github.com/gorilla/mux"
"html/template"
"net/http"
"path"
"path/filepath"
"strings"
)
func main() {
r := mux.NewRouter()
r.StrictSlash(true)
var subrouter *mux.Router
if config.SubPath != "" {
subrouter = r.PathPrefix(config.SubPath).Subrouter()
} else {
subrouter = r
}
setupApi(subrouter)
setupFrontend(subrouter)
db, err := database.NewSqlite3Connection(config.DatabaseFile)
if err != nil {
panic(err)
}
database.SetGlobDB(db)
if err := http.ListenAndServe(fmt.Sprintf(":%s", config.ServerPort), r); err != nil {
panic(err)
}
}
func setupApi(r *mux.Router) {
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/article", api.Article).Methods(http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodDelete)
r.HandleFunc("/api/assets", api.Assets).Methods(http.MethodGet, http.MethodPost, http.MethodDelete)
r.MethodNotAllowedHandler = http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
api.ApiError{Message: "invalid method", Code: http.StatusNotFound}.Send(w)
}))
r.NotFoundHandler = http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
api.ApiError{Message: "invalid endpoint", Code: http.StatusNotFound}.Send(w)
}))
}
func setupFrontend(r *mux.Router) {
r.HandleFunc("/article/{article}", server.Article).Methods(http.MethodGet)
r.HandleFunc("/assets/{asset}", server.Assets).Methods(http.MethodGet)
r.PathPrefix("/css/").HandlerFunc(server.ServePath).Methods(http.MethodGet)
r.PathPrefix("/img/").HandlerFunc(server.ServePath).Methods(http.MethodGet)
r.PathPrefix("/js/").HandlerFunc(server.ServePath).Methods(http.MethodGet)
r.PathPrefix("/html/").HandlerFunc(server.ServePath).Methods(http.MethodGet)
landingpage := template.Must(template.ParseFiles(filepath.Join(config.FrontendDir, "html", "landingpage.gohtml")))
r.Path("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
landingpage.Execute(w, struct {
BasePath string
}{BasePath: config.Address + strings.TrimSuffix(path.Join("/", config.SubPath), "/") + "/"})
}).Methods(http.MethodGet)
r.NotFoundHandler = http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
server.Error404(w, r)
}))
}

25
backend/parse/parse.go Normal file
View File

@@ -0,0 +1,25 @@
package parse
import (
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/html"
"github.com/gomarkdown/markdown/parser"
)
func newParser() *parser.Parser {
extensions := parser.CommonExtensions | parser.AutoHeadingIDs
return parser.NewWithExtensions(extensions)
}
func newHtmlRenderer() *html.Renderer {
renderOpts := html.RendererOptions{
Flags: html.CommonFlags | html.LazyLoadImages,
}
return html.NewRenderer(renderOpts)
}
func ParseToHtml(rawMarkdown []byte) []byte {
node := markdown.Parse(rawMarkdown, newParser())
return markdown.Render(node, newHtmlRenderer())
}

26
backend/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"`
}

61
backend/server/article.go Normal file
View File

@@ -0,0 +1,61 @@
package server
import (
"TheAdversary/config"
"TheAdversary/database"
"github.com/gorilla/mux"
"net/http"
"path"
"path/filepath"
"text/template"
"time"
)
var tmpl = template.Must(template.ParseFiles(filepath.Join(config.FrontendDir, "html", "article.gohtml")))
type tmplArticle struct {
Title string
BasePath string
Summary string
Image string
Authors []string
Tags []string
Date string
Modified bool
Content string
}
func Article(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
articleName := mux.Vars(r)["article"]
var article database.Article
if database.GetDB().Table("article").Where("link = ?", articleName).First(&article).RowsAffected == 0 {
Error404(w, r)
} else if database.GetDB().Error != nil {
Error500(w, r)
} else {
var authors, tags []string
database.GetDB().Table("author").Select("name").Where("id IN (?)", database.GetDB().Table("article_author").Select("author_id").Where("article_id = ?", article.Id)).Find(&authors)
database.GetDB().Table("article_tag").Where("article_id = ?", article.Id).Find(&tags)
ta := tmplArticle{
Title: article.Title,
BasePath: config.Address + path.Join("/", config.SubPath) + "/",
Summary: article.Summary,
Image: article.Image,
Authors: authors,
Tags: tags,
Content: article.Html,
}
if article.Modified > 0 {
ta.Date = time.Unix(article.Modified, 0).Format("Monday, 2. January 2006 | 15:04")
ta.Modified = true
} else {
ta.Date = time.Unix(article.Created, 0).Format("Monday, 2. January 2006 | 15:04")
}
w.WriteHeader(http.StatusOK)
tmpl.Execute(w, ta)
}
}

23
backend/server/assets.go Normal file
View File

@@ -0,0 +1,23 @@
package server
import (
"TheAdversary/database"
"github.com/gorilla/mux"
"mime"
"net/http"
"path"
)
func Assets(w http.ResponseWriter, r *http.Request) {
assetName := mux.Vars(r)["asset"]
var buf []interface{}
database.GetDB().Table("assets").Select("data").Find(&buf, "link = ?", assetName)
if buf == nil {
Error404(w, r)
} else {
data := buf[0].([]byte)
w.Header().Set("Content-Type", mime.TypeByExtension(path.Ext(assetName)))
w.Write(data)
}
}

25
backend/server/error.go Normal file
View File

@@ -0,0 +1,25 @@
package server
import (
"TheAdversary/config"
"io"
"log"
"net/http"
"path/filepath"
)
func init() {
// disable default log output because http.ServeFile prints
// a message if a header is written 2 times or more
log.Default().SetOutput(io.Discard)
}
func Error404(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
http.ServeFile(w, r, filepath.Join(config.FrontendDir, "error", "404.html"))
}
func Error500(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
http.ServeFile(w, r, filepath.Join(config.FrontendDir, "error", "500.html"))
}

View File

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

18
backend/server/path.go Normal file
View File

@@ -0,0 +1,18 @@
package server
import (
"TheAdversary/config"
"net/http"
"os"
"path/filepath"
"strings"
)
func ServePath(w http.ResponseWriter, r *http.Request) {
path := filepath.Join(config.FrontendDir, strings.TrimPrefix(r.URL.Path, config.SubPath))
if _, err := os.Stat(path); os.IsNotExist(err) {
Error404(w, r)
} else {
http.ServeFile(w, r, path)
}
}