From 431cadaeea569b3c712c054b744809fdce5fa8a4 Mon Sep 17 00:00:00 2001 From: bytedream Date: Sun, 6 Feb 2022 22:51:36 +0100 Subject: [PATCH] Added backend --- backend/.env | 9 + backend/.gitignore | 1 + backend/README.md | 1 + backend/api/api.go | 48 +++++ backend/api/api_test.go | 136 +++++++++++++++ backend/api/article.go | 308 ++++++++++++++++++++++++++++++++ backend/api/article_test.go | 319 ++++++++++++++++++++++++++++++++++ backend/api/assets.go | 140 +++++++++++++++ backend/api/assets_test.go | 183 +++++++++++++++++++ backend/api/authors.go | 32 ++++ backend/api/authors_test.go | 55 ++++++ backend/api/login.go | 50 ++++++ backend/api/login_test.go | 51 ++++++ backend/api/recent.go | 44 +++++ backend/api/recent_test.go | 114 ++++++++++++ backend/api/search.go | 81 +++++++++ backend/api/search_test.go | 231 ++++++++++++++++++++++++ backend/api/tags.go | 31 ++++ backend/api/tags_test.go | 81 +++++++++ backend/config/config.go | 28 +++ backend/database.sql | 68 ++++++++ backend/database.sqlite3 | Bin 0 -> 65536 bytes backend/database/database.go | 24 +++ backend/database/schema.go | 13 ++ backend/database/utils.go | 9 + backend/go.mod | 22 +++ backend/go.sum | 82 +++++++++ backend/main.go | 82 +++++++++ backend/parse/parse.go | 25 +++ backend/schema/schema.go | 26 +++ backend/server/article.go | 61 +++++++ backend/server/assets.go | 23 +++ backend/server/error.go | 25 +++ backend/server/landingpage.go | 1 + backend/server/path.go | 18 ++ 35 files changed, 2422 insertions(+) create mode 100644 backend/.env create mode 100644 backend/.gitignore create mode 100644 backend/README.md create mode 100644 backend/api/api.go create mode 100644 backend/api/api_test.go create mode 100644 backend/api/article.go create mode 100644 backend/api/article_test.go create mode 100644 backend/api/assets.go create mode 100644 backend/api/assets_test.go create mode 100644 backend/api/authors.go create mode 100644 backend/api/authors_test.go create mode 100644 backend/api/login.go create mode 100644 backend/api/login_test.go create mode 100644 backend/api/recent.go create mode 100644 backend/api/recent_test.go create mode 100644 backend/api/search.go create mode 100644 backend/api/search_test.go create mode 100644 backend/api/tags.go create mode 100644 backend/api/tags_test.go create mode 100644 backend/config/config.go create mode 100644 backend/database.sql create mode 100644 backend/database.sqlite3 create mode 100644 backend/database/database.go create mode 100644 backend/database/schema.go create mode 100644 backend/database/utils.go create mode 100644 backend/go.mod create mode 100644 backend/go.sum create mode 100644 backend/main.go create mode 100644 backend/parse/parse.go create mode 100644 backend/schema/schema.go create mode 100644 backend/server/article.go create mode 100644 backend/server/assets.go create mode 100644 backend/server/error.go create mode 100644 backend/server/landingpage.go create mode 100644 backend/server/path.go diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000..e15f0fe --- /dev/null +++ b/backend/.env @@ -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/ diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..62c8935 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1 @@ +.idea/ \ No newline at end of file diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..18803d0 --- /dev/null +++ b/backend/README.md @@ -0,0 +1 @@ +# backend diff --git a/backend/api/api.go b/backend/api/api.go new file mode 100644 index 0000000..b2565d3 --- /dev/null +++ b/backend/api/api.go @@ -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 +} diff --git a/backend/api/api_test.go b/backend/api/api_test.go new file mode 100644 index 0000000..755d6d4 --- /dev/null +++ b/backend/api/api_test.go @@ -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) + } + } + } + } +} diff --git a/backend/api/article.go b/backend/api/article.go new file mode 100644 index 0000000..2dca9c6 --- /dev/null +++ b/backend/api/article.go @@ -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(¬Found) + if len(notFound) > 0 { + ApiError{fmt.Sprintf("no authors with the id(s) %s were found", strings.Join(notFound, ", ")), http.StatusUnprocessableEntity}.Send(w) + return + } + + 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(¬Found) + if len(notFound) > 0 { + ApiError{fmt.Sprintf("no authors with the id(s) %s were found", strings.Join(notFound, ", ")), http.StatusUnprocessableEntity}.Send(w) + return + } + for _, author := range append([]int{authorId}, *payload.Authors...) { + authorUpdates = append(authorUpdates, map[string]interface{}{ + "article_id": payload.Id, + "author_id": author, + }) + } + } + if payload.Tags != nil { + for _, tag := range *payload.Tags { + tagUpdates = append(tagUpdates, map[string]interface{}{ + "article_id": payload.Id, + "tag": tag, + }) + } + } + if payload.Image != nil { + updates["image"] = *payload.Image + } + if payload.Link != nil { + updates["link"] = *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) +} diff --git a/backend/api/article_test.go b/backend/api/article_test.go new file mode 100644 index 0000000..2de9e2b --- /dev/null +++ b/backend/api/article_test.go @@ -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": "

Testing ._.

", + }, + }) + 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": "

Oh god i have to test all this, what am i doing with my life

", + }, + }) + 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": "

Just a simple test article

", + }, + }) + 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": "

Title

", + }, + { + "title": "owo", + "created": time.Now().Unix(), + "link": "owo", + "markdown": "owo", + "html": "

owo

", + }, + }) + 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, + }, + }) +} diff --git a/backend/api/assets.go b/backend/api/assets.go new file mode 100644 index 0000000..0ad8f3c --- /dev/null +++ b/backend/api/assets.go @@ -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) + } +} diff --git a/backend/api/assets_test.go b/backend/api/assets_test.go new file mode 100644 index 0000000..814c106 --- /dev/null +++ b/backend/api/assets_test.go @@ -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, + }, + }) +} diff --git a/backend/api/authors.go b/backend/api/authors.go new file mode 100644 index 0000000..0db8723 --- /dev/null +++ b/backend/api/authors.go @@ -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) +} diff --git a/backend/api/authors_test.go b/backend/api/authors_test.go new file mode 100644 index 0000000..2bcbb5b --- /dev/null +++ b/backend/api/authors_test.go @@ -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, + }, + }) +} diff --git a/backend/api/login.go b/backend/api/login.go new file mode 100644 index 0000000..89765be --- /dev/null +++ b/backend/api/login.go @@ -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) +} diff --git a/backend/api/login_test.go b/backend/api/login_test.go new file mode 100644 index 0000000..ebdd07d --- /dev/null +++ b/backend/api/login_test.go @@ -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, + }, + }) +} diff --git a/backend/api/recent.go b/backend/api/recent.go new file mode 100644 index 0000000..968e234 --- /dev/null +++ b/backend/api/recent.go @@ -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) +} diff --git a/backend/api/recent_test.go b/backend/api/recent_test.go new file mode 100644 index 0000000..d1aae0e --- /dev/null +++ b/backend/api/recent_test.go @@ -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": "

Title

", + }, + { + "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": "

This is the most recent article

", + }, + }) + 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, + }, + }) +} diff --git a/backend/api/search.go b/backend/api/search.go new file mode 100644 index 0000000..0026f18 --- /dev/null +++ b/backend/api/search.go @@ -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) +} diff --git a/backend/api/search_test.go b/backend/api/search_test.go new file mode 100644 index 0000000..414c99d --- /dev/null +++ b/backend/api/search_test.go @@ -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": "

This is my first article

", + }, + { + "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": "

Title

", + }, + { + "title": "owo", + "created": now.Unix(), + "modified": now.Add(12 * time.Hour).Unix(), + "link": "owo", + "markdown": "owo", + "html": "

owo

", + }, + }) + 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, + }, + }) +} diff --git a/backend/api/tags.go b/backend/api/tags.go new file mode 100644 index 0000000..87a1991 --- /dev/null +++ b/backend/api/tags.go @@ -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) +} diff --git a/backend/api/tags_test.go b/backend/api/tags_test.go new file mode 100644 index 0000000..0e0385d --- /dev/null +++ b/backend/api/tags_test.go @@ -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": "

Oh god i have to test all this, what am i doing with my life

", + }, + }) + 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, + }, + }) +} diff --git a/backend/config/config.go b/backend/config/config.go new file mode 100644 index 0000000..de4e197 --- /dev/null +++ b/backend/config/config.go @@ -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") +} diff --git a/backend/database.sql b/backend/database.sql new file mode 100644 index 0000000..3a0d05a --- /dev/null +++ b/backend/database.sql @@ -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); + diff --git a/backend/database.sqlite3 b/backend/database.sqlite3 new file mode 100644 index 0000000000000000000000000000000000000000..0231d247862a7b7548d42dea95ecef2e7fc4c706 GIT binary patch literal 65536 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 0").Find(&exists) + return exists +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..3658334 --- /dev/null +++ b/backend/go.mod @@ -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 +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..ded159b --- /dev/null +++ b/backend/go.sum @@ -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= diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000..9b94c0f --- /dev/null +++ b/backend/main.go @@ -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) + })) +} diff --git a/backend/parse/parse.go b/backend/parse/parse.go new file mode 100644 index 0000000..f19499f --- /dev/null +++ b/backend/parse/parse.go @@ -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()) +} diff --git a/backend/schema/schema.go b/backend/schema/schema.go new file mode 100644 index 0000000..7e67348 --- /dev/null +++ b/backend/schema/schema.go @@ -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"` +} diff --git a/backend/server/article.go b/backend/server/article.go new file mode 100644 index 0000000..8606999 --- /dev/null +++ b/backend/server/article.go @@ -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) + } +} diff --git a/backend/server/assets.go b/backend/server/assets.go new file mode 100644 index 0000000..aa00962 --- /dev/null +++ b/backend/server/assets.go @@ -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) + } +} diff --git a/backend/server/error.go b/backend/server/error.go new file mode 100644 index 0000000..c741a76 --- /dev/null +++ b/backend/server/error.go @@ -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")) +} diff --git a/backend/server/landingpage.go b/backend/server/landingpage.go new file mode 100644 index 0000000..abb4e43 --- /dev/null +++ b/backend/server/landingpage.go @@ -0,0 +1 @@ +package server diff --git a/backend/server/path.go b/backend/server/path.go new file mode 100644 index 0000000..3e24878 --- /dev/null +++ b/backend/server/path.go @@ -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) + } +}