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