package main import ( "archive/tar" "backend/md" "errors" "fmt" "io" "log" "net/http" "strconv" "strings" "time" ) type ArticleStatus = int const ( ArticleStatusDraft ArticleStatus = iota ArticleStatusPublished ArticleStatusOffline ) type ArticleProperties struct { Id int64 `json:"id"` Title string `json:"title"` Status ArticleStatus `json:"status"` Tags []string `json:"tags"` ReleaseDate time.Time `json:"date"` ModificationDate *time.Time `json:"mod-date"` } type Article struct { ArticleProperties Content string `json:"content"` Files []ArticleFile `json:"-"` } type ArticleFile struct { Id int64 Data []byte } func ParseArticle(reader io.Reader, filePrefix string) (*Article, error) { tarFiles := make(map[string][]byte) tarReader := tar.NewReader(reader) for { header, err := tarReader.Next() if err == io.EOF { break } if err != nil { return nil, err } content, err := io.ReadAll(tarReader) if err != nil { return nil, err } tarFiles[header.Name] = content } readmeBytes, found := tarFiles["README.md"] if !found { return nil, errors.New("file 'README.md' not found") } usedFiles := make(map[string]ArticleFile) prevFileId := int64(0) pc, html, err := md.Parse(func(path string) (string, error) { content, ok := tarFiles[strings.TrimPrefix(path, "./")] if !ok { return "", errors.New("file '" + path + "' not found") } prevFileId++ usedFiles[path] = ArticleFile{prevFileId, content} return filePrefix + strconv.FormatInt(prevFileId, 10), nil }, readmeBytes) if err != nil { return nil, err } title, err := md.GetProperty(pc, "title") if err != nil { if errors.Is(err, md.PropertyNotFoundError) { return nil, errors.New("title property not found") } return nil, err } tagsStr, err := md.GetProperty(pc, "tags") if err != nil && !errors.Is(err, md.PropertyNotFoundError) { return nil, err } tags := make([]string, 0) if strings.TrimSpace(tagsStr) != "" { for _, tagStr := range strings.Split(tagsStr, ",") { tags = append(tags, strings.TrimSpace(tagStr)) } } dateStr, err := md.GetProperty(pc, "date") if err != nil { if errors.Is(err, md.PropertyNotFoundError) { return nil, errors.New("date property not found") } return nil, err } releaseDate, err := time.Parse(time.DateOnly, dateStr) if err != nil { return nil, fmt.Errorf("invalid date property '%s': %v", dateStr, err) } var modificationDate *time.Time dateStr, err = md.GetProperty(pc, "mod-date") if err == nil { tmp, err := time.Parse(time.DateOnly, dateStr) if err != nil { return nil, fmt.Errorf("invalid mod-date property '%s': %v", dateStr, err) } modificationDate = &tmp } else if !errors.Is(err, md.PropertyNotFoundError) { return nil, err } files := make([]ArticleFile, 0, len(usedFiles)) for _, file := range usedFiles { files = append(files, file) } return &Article{ ArticleProperties{ -1, title, ArticleStatusDraft, tags, releaseDate, modificationDate, }, string(html), files, }, nil } func (h *ApiHandler) ServeBlogGet(writer http.ResponseWriter, request *http.Request) { query := request.URL.Query() var err error var offset int var limit int offsetStr := query.Get("offset") if offsetStr == "" { offset = 0 } else { offset, err = strconv.Atoi(offsetStr) if err != nil || offset < 0 { WriteError(writer, http.StatusBadRequest, "invalid offset", nil) return } } limitStr := query.Get("limit") if limitStr == "" { limit = 50 } else { limit, err = strconv.Atoi(limitStr) if err != nil || limit <= 0 || limit > 100 { WriteError(writer, http.StatusBadRequest, "invalid limit", nil) return } } articles, err := h.db.GetBlogArticles(IsAuthorized(request), offset, limit) if err != nil { WriteError(writer, http.StatusInternalServerError, "failed to query database", err) return } WriteResponse(writer, http.StatusOK, articles) } func (h *ApiHandler) ServeBlogPut(writer http.ResponseWriter, request *http.Request) { err := request.ParseMultipartForm(10 * 1024 * 1024) if err != nil { WriteError(writer, http.StatusBadRequest, "failed to parse multipart form", nil) return } file, _, err := request.FormFile("file") if err != nil { WriteError(writer, http.StatusBadRequest, "failed to parse file", nil) return } defer func() { _ = file.Close() }() id, commit, err := h.db.CreateBlogArticle() if err != nil { WriteError(writer, http.StatusInternalServerError, "failed to write database", err) return } article, err := ParseArticle(file, "/api/blog/"+strconv.FormatInt(id, 10)+"/file/") if err != nil { _ = commit(nil) WriteError(writer, http.StatusInternalServerError, "failed to parse article: "+err.Error(), nil) return } err = commit(article) if err != nil { WriteError(writer, http.StatusInternalServerError, "failed to write database", err) return } log.Printf("created new blog article '%s'", article.Title) WriteResponse(writer, http.StatusOK, map[string]interface{}{ "id": id, }) } func (h *ApiHandler) ServeBlogGetSingle(writer http.ResponseWriter, request *http.Request) { idStr := request.PathValue("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { WriteError(writer, http.StatusBadRequest, "invalid id", err) return } article, err := h.db.GetBlogArticle(IsAuthorized(request), id) if err != nil { if errors.Is(err, ErrNotFound) { WriteError(writer, http.StatusNotFound, "article not found", nil) } else { WriteError(writer, http.StatusInternalServerError, "failed to query database", err) } return } WriteResponse(writer, http.StatusOK, article) } func (h *ApiHandler) ServeBlogFileGetSingle(writer http.ResponseWriter, request *http.Request) { idStr := request.PathValue("articleId") articleId, err := strconv.ParseInt(idStr, 10, 64) if err != nil { WriteError(writer, http.StatusBadRequest, "invalid article id", err) return } idStr = request.PathValue("fileId") fileId, err := strconv.ParseInt(idStr, 10, 64) if err != nil { WriteError(writer, http.StatusBadRequest, "invalid file id", err) return } file, err := h.db.GetBlogArticleFile(IsAuthorized(request), articleId, fileId) if err != nil { if errors.Is(err, ErrNotFound) { WriteError(writer, http.StatusNotFound, "article file not found", nil) } else { WriteError(writer, http.StatusInternalServerError, "failed to query database", err) } return } _, err = writer.Write(file.Data) if err != nil { log.Println(err) } } func (h *ApiHandler) ServeBlogPostPublish(writer http.ResponseWriter, request *http.Request) { idStr := request.PathValue("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { WriteError(writer, http.StatusBadRequest, "invalid id", err) return } err = h.db.SetBlogArticleStatus(id, ArticleStatusPublished) if err != nil { if errors.Is(err, ErrNotFound) { WriteError(writer, http.StatusNotFound, "article not found", nil) } else { WriteError(writer, http.StatusInternalServerError, "failed to update database", err) } return } WriteResponse(writer, http.StatusOK, map[string]interface{}{}) } func (h *ApiHandler) ServeBlogPostUnpublish(writer http.ResponseWriter, request *http.Request) { idStr := request.PathValue("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { WriteError(writer, http.StatusBadRequest, "invalid id", err) return } err = h.db.SetBlogArticleStatus(id, ArticleStatusOffline) if err != nil { if errors.Is(err, ErrNotFound) { WriteError(writer, http.StatusNotFound, "article not found", nil) } else { WriteError(writer, http.StatusInternalServerError, "failed to update database", err) } return } WriteResponse(writer, http.StatusOK, map[string]interface{}{}) } func (h *ApiHandler) ServeBlogTagsGet(writer http.ResponseWriter, request *http.Request) { query := request.URL.Query() var err error var offset int var limit int offsetStr := query.Get("offset") if offsetStr == "" { offset = 0 } else { offset, err = strconv.Atoi(offsetStr) if err != nil || offset < 0 { WriteError(writer, http.StatusBadRequest, "invalid offset", nil) return } } limitStr := query.Get("limit") if limitStr == "" { limit = 50 } else { limit, err = strconv.Atoi(limitStr) if err != nil || limit <= 0 || limit > 100 { WriteError(writer, http.StatusBadRequest, "invalid limit", nil) return } } tags, total, err := h.db.GetBlogTags(IsAuthorized(request), offset, limit) if err != nil { WriteError(writer, http.StatusInternalServerError, "failed to query database", err) return } WriteResponse(writer, http.StatusOK, map[string]interface{}{ "tags": tags, "total": total, }) }