diff --git a/announcements.md b/announcements.md new file mode 100644 index 00000000..a74faf35 --- /dev/null +++ b/announcements.md @@ -0,0 +1,19 @@ +# Announcements + +## Announcement 2 + +Hello announcements! + +- This is the first announcement. +- Stay tuned for more updates. + +[https://github.com/ivpn/mailx/releases/tag/0.2.4](https://github.com/ivpn/mailx/releases/tag/0.2.4) + +## Announcement 1 + +Hello announcements! + +- This is the first announcement. +- Stay tuned for more updates. + +[https://github.com/ivpn/mailx/releases/tag/0.2.1](https://github.com/ivpn/mailx/releases/tag/0.2.1) diff --git a/api/.env.sample b/api/.env.sample index c4bec568..37bb0631 100644 --- a/api/.env.sample +++ b/api/.env.sample @@ -20,6 +20,7 @@ SIGNUP_WEBHOOK_PSK= PREAUTH_URL= PREAUTH_PSK= PREAUTH_TTL=60m +ANNOUNCEMENTS_URL= APP_PORT=3001 diff --git a/api/config/config.go b/api/config/config.go index 6daf1882..7cc2fdb3 100644 --- a/api/config/config.go +++ b/api/config/config.go @@ -27,6 +27,7 @@ type APIConfig struct { PreauthURL string PreauthPSK string PreauthTTL time.Duration + AnnouncementsURL string } type DBConfig struct { @@ -169,6 +170,7 @@ func New() (Config, error) { PreauthURL: os.Getenv("PREAUTH_URL"), PreauthPSK: os.Getenv("PREAUTH_PSK"), PreauthTTL: preauthTTL, + AnnouncementsURL: os.Getenv("ANNOUNCEMENTS_URL"), }, DB: DBConfig{ Hosts: dbHosts, diff --git a/api/internal/transport/api/announcements.go b/api/internal/transport/api/announcements.go new file mode 100644 index 00000000..a4f78ea0 --- /dev/null +++ b/api/internal/transport/api/announcements.go @@ -0,0 +1,136 @@ +package api + +import ( + "fmt" + "io" + "net/http" + "regexp" + "strings" + + "github.com/gofiber/fiber/v2" +) + +type Announcement struct { + Title string `json:"title"` + Body string `json:"body"` + Link string `json:"link"` +} + +// @Summary Get announcements +// @Description Get list of announcements from the public announcements file +// @Produce json +// @Success 200 {array} Announcement +// @Failure 500 {object} ErrorRes +// @Router /announcements [get] +func (h *Handler) GetAnnouncements(c *fiber.Ctx) error { + resp, err := http.Get(h.Cfg.AnnouncementsURL) //nolint:noctx + if err != nil { + return c.Status(500).JSON(ErrorRes{Error: "failed to fetch announcements"}) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return c.Status(500).JSON(ErrorRes{Error: fmt.Sprintf("unexpected status fetching announcements: %d", resp.StatusCode)}) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return c.Status(500).JSON(ErrorRes{Error: "failed to read announcements"}) + } + + announcements := parseAnnouncements(string(data)) + return c.JSON(announcements) +} + +var linkPattern = regexp.MustCompile(`^\[.*?\]\((https?://[^)]+)\)$`) + +func parseAnnouncements(content string) []Announcement { + lines := strings.Split(content, "\n") + var announcements []Announcement + var current *Announcement + var bodyLines []string + + flush := func() { + if current == nil { + return + } + current.Body = bodyLinesToHTML(bodyLines) + announcements = append(announcements, *current) + current = nil + bodyLines = nil + } + + for _, line := range lines { + line = strings.TrimRight(line, "\r") + + if strings.HasPrefix(line, "## ") { + flush() + current = &Announcement{Title: strings.TrimPrefix(line, "## ")} + continue + } + + if current == nil { + continue + } + + if m := linkPattern.FindStringSubmatch(strings.TrimSpace(line)); m != nil { + current.Link = m[1] + continue + } + + bodyLines = append(bodyLines, line) + } + + flush() + return announcements +} + +func bodyLinesToHTML(lines []string) string { + var sb strings.Builder + var listItems []string + var paraLines []string + + flushList := func() { + if len(listItems) == 0 { + return + } + sb.WriteString("") + listItems = nil + } + + flushPara := func() { + text := strings.TrimSpace(strings.Join(paraLines, " ")) + if text != "" { + sb.WriteString("

") + sb.WriteString(text) + sb.WriteString("

") + } + paraLines = nil + } + + for _, line := range lines { + if strings.TrimSpace(line) == "" { + flushPara() + flushList() + continue + } + if strings.HasPrefix(line, "- ") { + flushPara() + listItems = append(listItems, strings.TrimPrefix(line, "- ")) + continue + } + flushList() + paraLines = append(paraLines, line) + } + + flushPara() + flushList() + + return sb.String() +} diff --git a/api/internal/transport/api/routes.go b/api/internal/transport/api/routes.go index f8e91991..1cd2fbf2 100644 --- a/api/internal/transport/api/routes.go +++ b/api/internal/transport/api/routes.go @@ -24,6 +24,7 @@ func (h *Handler) SetupRoutes(cfg config.APIConfig) { h.Server.Use(helmet.New()) h.Server.Use(healthcheck.New()) + h.Server.Get("/v1/announcements", limiter.New(), h.GetAnnouncements) h.Server.Post("/v1/register", limiter.New(), h.Register) h.Server.Post("/v1/login", limit.New(5, 10*time.Minute), h.Login) h.Server.Post("/v1/initiatepasswordreset", limiter.New(), h.InitiatePasswordReset) diff --git a/app/src/api/announcements.ts b/app/src/api/announcements.ts new file mode 100644 index 00000000..f53c5335 --- /dev/null +++ b/app/src/api/announcements.ts @@ -0,0 +1,5 @@ +import { api } from './api.ts' + +export const announcementsApi = { + getList: () => api.get('/announcements'), +} diff --git a/app/src/components/Announcements.vue b/app/src/components/Announcements.vue new file mode 100644 index 00000000..0b941cf1 --- /dev/null +++ b/app/src/components/Announcements.vue @@ -0,0 +1,76 @@ + + + diff --git a/app/src/components/Sidebar.vue b/app/src/components/Sidebar.vue index b35a7d29..b7792767 100644 --- a/app/src/components/Sidebar.vue +++ b/app/src/components/Sidebar.vue @@ -52,9 +52,9 @@

- Support: - Email / - FAQ + Support / + FAQ / + News

diff --git a/app/src/router.ts b/app/src/router.ts index c904fd64..89579b19 100644 --- a/app/src/router.ts +++ b/app/src/router.ts @@ -16,6 +16,7 @@ import ResetPassword from './components/ResetPassword.vue' import Terms from './components/Terms.vue' import Privacy from './components/Privacy.vue' import Faq from './components/Faq.vue' +import Announcements from './components/Announcements.vue' import NotFound from './components/NotFound.vue' import Landing from './components/Landing.vue' import { type IStaticMethods } from 'preline/preline' @@ -129,6 +130,11 @@ const routes: RouteRecordRaw[] = [ name: `${AppName}: FAQ`, component: Faq }, + { + path: '/news', + name: `${AppName}: News`, + component: Announcements + }, { path: '/:pathMatch(.*)*', name: '404: Not Found',