Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions cmd/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,15 @@ func initHTTPHandlers(e *echo.Echo, a *App) {
g.PUT("/api/templates/:id/default", pm(hasID(a.TemplateSetDefault), "templates:manage"))
g.DELETE("/api/templates/:id", pm(hasID(a.DeleteTemplate), "templates:manage"))

// SQL snippets
g.GET("/api/sql-snippets", pm(a.HandleGetSQLSnippets, "subscribers:sql_query"))
g.GET("/api/sql-snippets/:id", pm(hasID(a.HandleGetSQLSnippets), "subscribers:sql_query"))
g.POST("/api/sql-snippets", pm(a.HandleCreateSQLSnippet, "subscribers:sql_query"))
g.PUT("/api/sql-snippets/:id", pm(hasID(a.HandleUpdateSQLSnippet), "subscribers:sql_query"))
g.DELETE("/api/sql-snippets/:id", pm(hasID(a.HandleDeleteSQLSnippet), "subscribers:sql_query"))
g.POST("/api/sql-snippets/validate", pm(a.HandleValidateSQLSnippet, "subscribers:sql_query"))
g.POST("/api/sql-snippets/count", pm(a.HandleCountSQLSnippet, "subscribers:sql_query"))

g.DELETE("/api/maintenance/subscribers/:type", pm(a.GCSubscribers, "settings:maintain"))
g.DELETE("/api/maintenance/analytics/:type", pm(a.GCCampaignAnalytics, "settings:maintain"))
g.DELETE("/api/maintenance/subscriptions/unconfirmed", pm(a.GCSubscriptions, "settings:maintain"))
Expand Down
213 changes: 213 additions & 0 deletions cmd/sql_snippets.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
package main

import (
"net/http"
"strconv"

"github.com/knadh/listmonk/internal/auth"
"github.com/knadh/listmonk/models"
"github.com/labstack/echo/v4"
)

// HandleGetSQLSnippets handles the retrieval of SQL snippets.
func (a *App) HandleGetSQLSnippets(c echo.Context) error {
// Get the authenticated user.
user := auth.GetUser(c)

// Check permissions
if !user.HasPerm(auth.PermSubscribersSqlQuery) {
return echo.NewHTTPError(http.StatusForbidden,
a.i18n.Ts("globals.messages.permissionDenied", "name", auth.PermSubscribersSqlQuery))
}

var (
pg = a.pg.NewFromURL(c.Request().URL.Query())
id, _ = strconv.Atoi(c.Param("id"))
name = c.QueryParam("name")
isActive *bool
)

if v := c.QueryParam("is_active"); v != "" {
if val, err := strconv.ParseBool(v); err == nil {
isActive = &val
}
}

// Single snippet by ID.
if id > 0 {
out, err := a.core.GetSQLSnippet(id, "")
if err != nil {
return err
}
return c.JSON(http.StatusOK, okResp{out})
}

// Multiple snippets.
limit := pg.Limit
if limit == 0 {
limit = 50 // Default limit
}

out, err := a.core.GetSQLSnippets(0, name, isActive, pg.Offset, limit)
if err != nil {
return err
}

return c.JSON(http.StatusOK, okResp{out})
}

// HandleCreateSQLSnippet handles the creation of a SQL snippet.
func (a *App) HandleCreateSQLSnippet(c echo.Context) error {
var s models.SQLSnippet
if err := c.Bind(&s); err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
a.i18n.Ts("globals.messages.invalidData", "error", err.Error()))
}

// Get the authenticated user.
user := auth.GetUser(c)

// Check permissions
if !user.HasPerm(auth.PermSubscribersSqlQuery) {
return echo.NewHTTPError(http.StatusForbidden,
a.i18n.Ts("globals.messages.permissionDenied", "name", auth.PermSubscribersSqlQuery))
}

// Validate the SQL snippet
if err := a.core.ValidateSQLSnippet(s.QuerySQL); err != nil {
return err
}

out, err := a.core.CreateSQLSnippet(s, user.ID)
if err != nil {
return err
}

return c.JSON(http.StatusCreated, okResp{out})
}

// HandleUpdateSQLSnippet handles the updating of a SQL snippet.
func (a *App) HandleUpdateSQLSnippet(c echo.Context) error {
var s models.SQLSnippet
if err := c.Bind(&s); err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
a.i18n.Ts("globals.messages.invalidData", "error", err.Error()))
}

id, _ := strconv.Atoi(c.Param("id"))
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidID"))
}

// Get the authenticated user.
user := auth.GetUser(c)

// Check permissions
if !user.HasPerm(auth.PermSubscribersSqlQuery) {
return echo.NewHTTPError(http.StatusForbidden,
a.i18n.Ts("globals.messages.permissionDenied", "name", auth.PermSubscribersSqlQuery))
}

// Validate the SQL snippet if it's being changed
if s.QuerySQL != "" {
if err := a.core.ValidateSQLSnippet(s.QuerySQL); err != nil {
return err
}
}

out, err := a.core.UpdateSQLSnippet(id, s)
if err != nil {
return err
}

return c.JSON(http.StatusOK, okResp{out})
}

// HandleDeleteSQLSnippet handles the deletion of a SQL snippet.
func (a *App) HandleDeleteSQLSnippet(c echo.Context) error {
id, _ := strconv.Atoi(c.Param("id"))
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidID"))
}

// Get the authenticated user.
user := auth.GetUser(c)

// Check permissions
if !user.HasPerm(auth.PermSubscribersSqlQuery) {
return echo.NewHTTPError(http.StatusForbidden,
a.i18n.Ts("globals.messages.permissionDenied", "name", auth.PermSubscribersSqlQuery))
}

if err := a.core.DeleteSQLSnippet(id); err != nil {
return err
}

return c.JSON(http.StatusOK, okResp{true})
}

// HandleValidateSQLSnippet handles the validation of a SQL snippet.
func (a *App) HandleValidateSQLSnippet(c echo.Context) error {
var req struct {
QuerySQL string `json:"query_sql"`
}
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
a.i18n.Ts("globals.messages.invalidData", "error", err.Error()))
}

// Get the authenticated user.
user := auth.GetUser(c)

// Check permissions
if !user.HasPerm(auth.PermSubscribersSqlQuery) {
return echo.NewHTTPError(http.StatusForbidden,
a.i18n.Ts("globals.messages.permissionDenied", "name", auth.PermSubscribersSqlQuery))
}

if err := a.core.ValidateSQLSnippet(req.QuerySQL); err != nil {
return err
}

return c.JSON(http.StatusOK, okResp{map[string]bool{"valid": true}})
}

// HandleCountSQLSnippet handles counting subscribers that match a SQL snippet.
func (a *App) HandleCountSQLSnippet(c echo.Context) error {
var req struct {
QuerySQL string `json:"query_sql"`
}
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
a.i18n.Ts("globals.messages.invalidData", "error", err.Error()))
}

// Get the authenticated user.
user := auth.GetUser(c)

// Check permissions
if !user.HasPerm(auth.PermSubscribersSqlQuery) {
return echo.NewHTTPError(http.StatusForbidden,
a.i18n.Ts("globals.messages.permissionDenied", "name", auth.PermSubscribersSqlQuery))
}

// Get total subscriber count (cached)
totalCount, err := a.core.GetSubscriberCount("", "", "", []int{})
if err != nil {
return err
}

// Get count for the SQL snippet (if query is provided)
matchedCount := 0
if req.QuerySQL != "" {
matchedCount, err = a.core.GetSubscriberCount("", req.QuerySQL, "", []int{})
if err != nil {
return err
}
}

return c.JSON(http.StatusOK, okResp{map[string]int{
"total": totalCount,
"matched": matchedCount,
}})
}
1 change: 1 addition & 0 deletions cmd/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ var migList = []migFunc{
{"v4.0.0", migrations.V4_0_0},
{"v4.1.0", migrations.V4_1_0},
{"v5.0.0", migrations.V5_0_0},
{"v5.1.0", migrations.V5_1_0},
}

// upgrade upgrades the database to the current version by running SQL migration files
Expand Down
40 changes: 40 additions & 0 deletions frontend/src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -532,3 +532,43 @@ export const deleteRole = (id) => http.delete(
`/api/roles/${id}`,
{ loading: models.userRoles },
);

// SQL Snippets.
export const getSQLSnippets = (params) => http.get(
'/api/sql-snippets',
{ params, loading: models.sqlSnippets },
);

export const getSQLSnippet = (id) => http.get(
`/api/sql-snippets/${id}`,
{ loading: models.sqlSnippets },
);

export const createSQLSnippet = (data) => http.post(
'/api/sql-snippets',
data,
{ loading: models.sqlSnippets },
);

export const updateSQLSnippet = (id, data) => http.put(
`/api/sql-snippets/${id}`,
data,
{ loading: models.sqlSnippets },
);

export const deleteSQLSnippet = (id) => http.delete(
`/api/sql-snippets/${id}`,
{ loading: models.sqlSnippets },
);

export const validateSQLSnippet = (data) => http.post(
'/api/sql-snippets/validate',
data,
{ loading: models.sqlSnippets },
);

export const countSQLSnippet = (data) => http.post(
'/api/sql-snippets/count',
data,
{ loading: models.sqlSnippets },
);
2 changes: 2 additions & 0 deletions frontend/src/components/Navigation.vue
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@
:label="$t('menu.settings')">
<b-menu-item v-if="$can('settings:get')" :to="{ name: 'settings' }" tag="router-link"
:active="activeItem.settings" data-cy="all-settings" icon="cog-outline" :label="$t('menu.settings')" />
<b-menu-item v-if="$can('subscribers:sql_query')" :to="{ name: 'sql-snippets' }" tag="router-link" :active="activeItem['sql-snippets']"
data-cy="sql-snippets" icon="code" :label="$t('sqlSnippets.title')" />
<b-menu-item v-if="$can('settings:maintain')" :to="{ name: 'maintenance' }" tag="router-link"
:active="activeItem.maintenance" data-cy="maintenance" icon="wrench-outline" :label="$t('menu.maintenance')" />
<b-menu-item v-if="$can('settings:get')" :to="{ name: 'logs' }" tag="router-link" :active="activeItem.logs"
Expand Down
Loading