Added backend
This commit is contained in:
9
backend/.env
Normal file
9
backend/.env
Normal 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
1
backend/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.idea/
|
||||||
1
backend/README.md
Normal file
1
backend/README.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# backend
|
||||||
48
backend/api/api.go
Normal file
48
backend/api/api.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
DatabaseError = ApiError{Message: "internal database error", Code: http.StatusInternalServerError}
|
||||||
|
InvalidJson = ApiError{Message: "invalid json", Code: http.StatusUnprocessableEntity}
|
||||||
|
)
|
||||||
|
|
||||||
|
type ApiError struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Code int `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ae ApiError) Send(w http.ResponseWriter) error {
|
||||||
|
w.WriteHeader(ae.Code)
|
||||||
|
return json.NewEncoder(w).Encode(ae)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessions = map[string]int{}
|
||||||
|
|
||||||
|
type article struct {
|
||||||
|
ID int `json:"-"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
Created int64 `json:"created"`
|
||||||
|
Modified int64 `json:"modified"`
|
||||||
|
Link string `json:"link"`
|
||||||
|
Markdown string `json:"markdown"`
|
||||||
|
Html string `json:"html"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func authorizedSession(r *http.Request) (int, bool) {
|
||||||
|
cookie, err := r.Cookie("session_id")
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
for sessionId, authorId := range sessions {
|
||||||
|
if sessionId == cookie.Value {
|
||||||
|
return authorId, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
136
backend/api/api_test.go
Normal file
136
backend/api/api_test.go
Normal 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
308
backend/api/article.go
Normal 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(¬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)
|
||||||
|
}
|
||||||
319
backend/api/article_test.go
Normal file
319
backend/api/article_test.go
Normal 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
140
backend/api/assets.go
Normal 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
183
backend/api/assets_test.go
Normal 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
32
backend/api/authors.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"TheAdversary/database"
|
||||||
|
"TheAdversary/schema"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Authors(w http.ResponseWriter, r *http.Request) {
|
||||||
|
query := r.URL.Query()
|
||||||
|
request := database.GetDB().Table("author")
|
||||||
|
|
||||||
|
if query.Has("name") {
|
||||||
|
request.Where("name LIKE ?", "%"+query.Get("name")+"%")
|
||||||
|
}
|
||||||
|
if query.Has("limit") {
|
||||||
|
limit, err := strconv.Atoi(query.Get("limit"))
|
||||||
|
if err != nil {
|
||||||
|
ApiError{"invalid 'limit' parameter", http.StatusUnprocessableEntity}.Send(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request.Limit(limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
var authors []schema.Author
|
||||||
|
request.Find(&authors)
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(authors)
|
||||||
|
}
|
||||||
55
backend/api/authors_test.go
Normal file
55
backend/api/authors_test.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"TheAdversary/database"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAuthors(t *testing.T) {
|
||||||
|
if err := initTestDatabase("authors_test.sqlite3"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
database.GetDB().Table("author").Create([]map[string]interface{}{
|
||||||
|
{
|
||||||
|
"name": "test",
|
||||||
|
"password": "",
|
||||||
|
"information": "test information",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(Authors))
|
||||||
|
checkTestInformation(t, server.URL, []testInformation{
|
||||||
|
{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
ResultBody: []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "test",
|
||||||
|
"information": "test information",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Code: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Query: map[string]interface{}{
|
||||||
|
"name": "abc",
|
||||||
|
},
|
||||||
|
ResultBody: []interface{}{},
|
||||||
|
Code: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Query: map[string]interface{}{
|
||||||
|
"limit": "notanumber",
|
||||||
|
},
|
||||||
|
ResultBody: map[string]interface{}{
|
||||||
|
"message": "invalid 'limit' parameter",
|
||||||
|
},
|
||||||
|
Code: http.StatusUnprocessableEntity,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
50
backend/api/login.go
Normal file
50
backend/api/login.go
Normal 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
51
backend/api/login_test.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"TheAdversary/database"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLogin(t *testing.T) {
|
||||||
|
if err := initTestDatabase("login_test.sqlite3"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
password, _ := bcrypt.GenerateFromPassword([]byte("super secure password"), bcrypt.DefaultCost)
|
||||||
|
database.GetDB().Table("author").Create([]map[string]interface{}{
|
||||||
|
{
|
||||||
|
"name": "owner",
|
||||||
|
"password": password,
|
||||||
|
"information": "owner of the best blog in the world",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(Login))
|
||||||
|
checkTestInformation(t, server.URL, []testInformation{
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Body: loginPayload{
|
||||||
|
Username: "owner",
|
||||||
|
Password: "super secure password",
|
||||||
|
},
|
||||||
|
ResultCookie: []string{"session_id"},
|
||||||
|
Code: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Body: loginPayload{
|
||||||
|
Username: "not a user",
|
||||||
|
},
|
||||||
|
Code: http.StatusUnauthorized,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Body: loginPayload{
|
||||||
|
Username: "test",
|
||||||
|
},
|
||||||
|
Code: http.StatusUnauthorized,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
44
backend/api/recent.go
Normal file
44
backend/api/recent.go
Normal 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
114
backend/api/recent_test.go
Normal 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
81
backend/api/search.go
Normal 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
231
backend/api/search_test.go
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"TheAdversary/database"
|
||||||
|
"TheAdversary/schema"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSearch(t *testing.T) {
|
||||||
|
if err := initTestDatabase("search_test.sqlite3"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
database.GetDB().Table("article").Create([]map[string]interface{}{
|
||||||
|
{
|
||||||
|
"title": "First article",
|
||||||
|
"created": now.Unix(),
|
||||||
|
"link": "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
31
backend/api/tags.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"TheAdversary/database"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Tags(w http.ResponseWriter, r *http.Request) {
|
||||||
|
query := r.URL.Query()
|
||||||
|
request := database.GetDB().Table("article_tag")
|
||||||
|
|
||||||
|
if query.Has("name") {
|
||||||
|
request.Where("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
81
backend/api/tags_test.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"TheAdversary/database"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTags(t *testing.T) {
|
||||||
|
if err := initTestDatabase("tags_test.sqlite3"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tags := []string{
|
||||||
|
"test",
|
||||||
|
"linux",
|
||||||
|
}
|
||||||
|
|
||||||
|
database.GetDB().Table("article").Create([]map[string]interface{}{
|
||||||
|
{
|
||||||
|
"title": "Upload test",
|
||||||
|
"summary": "An example article to test the upload api endpoint",
|
||||||
|
"created": time.Now().Unix(),
|
||||||
|
"link": "article/upload-test",
|
||||||
|
"markdown": "Oh god i have to test all this, what am i doing with my life",
|
||||||
|
"html": "<p>Oh god i have to test all this, what am i doing with my life<p>",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
database.GetDB().Table("author").Create([]map[string]interface{}{
|
||||||
|
{
|
||||||
|
"name": "me",
|
||||||
|
"password": "",
|
||||||
|
"information": "this is my account",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
database.GetDB().Table("article_author").Create([]map[string]interface{}{
|
||||||
|
{
|
||||||
|
"article_id": 1,
|
||||||
|
"author_id": 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
database.GetDB().Table("article_tag").Create([]map[string]interface{}{
|
||||||
|
{
|
||||||
|
"article_id": 1,
|
||||||
|
"tag": "test",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"article_id": 1,
|
||||||
|
"tag": "linux",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(Tags))
|
||||||
|
checkTestInformation(t, server.URL, []testInformation{
|
||||||
|
{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
ResultBody: tags,
|
||||||
|
Code: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Query: map[string]interface{}{
|
||||||
|
"name": "abc",
|
||||||
|
},
|
||||||
|
ResultBody: []interface{}{},
|
||||||
|
Code: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Query: map[string]interface{}{
|
||||||
|
"limit": "notanumber",
|
||||||
|
},
|
||||||
|
ResultBody: map[string]interface{}{
|
||||||
|
"message": "invalid 'limit' parameter",
|
||||||
|
},
|
||||||
|
Code: http.StatusUnprocessableEntity,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
28
backend/config/config.go
Normal file
28
backend/config/config.go
Normal 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
68
backend/database.sql
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
create table article
|
||||||
|
(
|
||||||
|
id integer
|
||||||
|
constraint article_pk
|
||||||
|
primary key autoincrement,
|
||||||
|
title text not null,
|
||||||
|
summary text,
|
||||||
|
image text,
|
||||||
|
created integer not null,
|
||||||
|
modified integer default 0,
|
||||||
|
link text not null,
|
||||||
|
markdown text not null,
|
||||||
|
html text not null
|
||||||
|
);
|
||||||
|
|
||||||
|
create unique index article_link_uindex
|
||||||
|
on article (link);
|
||||||
|
|
||||||
|
create unique index article_title_uindex
|
||||||
|
on article (title);
|
||||||
|
|
||||||
|
create table article_tag
|
||||||
|
(
|
||||||
|
article_id integer not null
|
||||||
|
references article
|
||||||
|
on delete cascade,
|
||||||
|
tag text
|
||||||
|
);
|
||||||
|
|
||||||
|
create table assets
|
||||||
|
(
|
||||||
|
id integer
|
||||||
|
constraint assets_pk
|
||||||
|
primary key autoincrement,
|
||||||
|
name text not null,
|
||||||
|
data blob not null,
|
||||||
|
link string not null
|
||||||
|
);
|
||||||
|
|
||||||
|
create unique index assets_link_uindex
|
||||||
|
on assets (link);
|
||||||
|
|
||||||
|
create unique index assets_name_uindex
|
||||||
|
on assets (name);
|
||||||
|
|
||||||
|
create table author
|
||||||
|
(
|
||||||
|
id integer
|
||||||
|
constraint author_pk
|
||||||
|
primary key autoincrement,
|
||||||
|
name text not null,
|
||||||
|
password text not null,
|
||||||
|
information text
|
||||||
|
);
|
||||||
|
|
||||||
|
create table article_author
|
||||||
|
(
|
||||||
|
article_id integer not null
|
||||||
|
references article
|
||||||
|
on delete cascade,
|
||||||
|
author_id integer not null
|
||||||
|
references author
|
||||||
|
on delete cascade
|
||||||
|
);
|
||||||
|
|
||||||
|
create unique index author_name_uindex
|
||||||
|
on author (name);
|
||||||
|
|
||||||
BIN
backend/database.sqlite3
Normal file
BIN
backend/database.sqlite3
Normal file
Binary file not shown.
24
backend/database/database.go
Normal file
24
backend/database/database.go
Normal 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
|
||||||
|
}
|
||||||
13
backend/database/schema.go
Normal file
13
backend/database/schema.go
Normal 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
|
||||||
|
}
|
||||||
9
backend/database/utils.go
Normal file
9
backend/database/utils.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import "gorm.io/gorm"
|
||||||
|
|
||||||
|
func Exists(tx *gorm.DB, query interface{}, args ...interface{}) bool {
|
||||||
|
var exists bool
|
||||||
|
tx.Where(query, args...).Select("count(*) > 0").Find(&exists)
|
||||||
|
return exists
|
||||||
|
}
|
||||||
22
backend/go.mod
Normal file
22
backend/go.mod
Normal 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
82
backend/go.sum
Normal 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
82
backend/main.go
Normal 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
25
backend/parse/parse.go
Normal 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
26
backend/schema/schema.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package schema
|
||||||
|
|
||||||
|
type ArticleSummary struct {
|
||||||
|
Id int `json:"id" gorm:"primaryKey"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Authors []Author `json:"authors" gorm:"-"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
Tags []string `json:"tags" gorm:"-"`
|
||||||
|
Created int64 `json:"created"`
|
||||||
|
Modified int64 `json:"modified"`
|
||||||
|
Link string `json:"link"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Author struct {
|
||||||
|
Id int `json:"id" gorm:"primaryKey"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Password string `json:"-"`
|
||||||
|
Information string `json:"information"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Asset struct {
|
||||||
|
Id int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Link string `json:"link"`
|
||||||
|
}
|
||||||
61
backend/server/article.go
Normal file
61
backend/server/article.go
Normal 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
23
backend/server/assets.go
Normal 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
25
backend/server/error.go
Normal 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"))
|
||||||
|
}
|
||||||
1
backend/server/landingpage.go
Normal file
1
backend/server/landingpage.go
Normal file
@@ -0,0 +1 @@
|
|||||||
|
package server
|
||||||
18
backend/server/path.go
Normal file
18
backend/server/path.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user