Skip to content
Closed
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
2 changes: 1 addition & 1 deletion docs/docs/content/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
### TOML Configuration file
One or more TOML files can be read by passing `--config config.toml` multiple times. Apart from a few low level configuration variables and the database configuration, all other settings can be managed from the `Settings` dashboard on the admin UI.

To generate a new sample configuration file, run `--listmonk --new-config`
To generate a new sample configuration file, run `listmonk --new-config`

### Environment variables
Variables in config.toml can also be provided as environment variables prefixed by `LISTMONK_` with periods replaced by `__` (double underscore). To start listmonk purely with environment variables without a configuration file, set the environment variables and pass the config flag as `--config=""`.
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
1 change: 1 addition & 0 deletions frontend/src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const models = Object.freeze({
profile: 'profile',
userRoles: 'userRoles',
listRoles: 'listRoles',
sqlSnippets: 'sqlSnippets',
settings: 'settings',
logs: 'logs',
maintenance: 'maintenance',
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/router/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ const routes = [
meta: { title: 'import.title', group: 'subscribers' },
component: () => import('../views/Import.vue'),
},
{
path: '/sql-snippets',
name: 'sql-snippets',
meta: { title: 'sqlSnippets.title', group: 'settings' },
component: () => import('../views/SqlSnippets.vue'),
},
{
path: '/subscribers/bounces',
name: 'bounces',
Expand Down
Loading