Add data privacy export / wipe features (aimed at GDPR compliance).
- Toggle options to enable self-service data export and wipe options on the public unsubscription page. Subscribers can get a copy of all data on them e-mailed to them as JSON, or instantly wipe all their data. - Refactor "unsubscribe" pages and URIs to "subscription". - Add export icon to subscriber admin view.
This commit is contained in:
parent
d390bc904c
commit
3b7902802e
|
@ -49,7 +49,6 @@ Alternatively, to run a demo of listmonk, you can quickly spin up a container `d
|
|||
- DB migrations
|
||||
- Bounce tracking
|
||||
- User auth, management, permissions
|
||||
- Privacy features for subscribers (Download and wipe all tracking data)
|
||||
- Ability to write raw campaign logs to a target
|
||||
- Analytics views and reports
|
||||
- Make Ant design UI components responsive
|
||||
|
|
|
@ -44,6 +44,25 @@ concurrency = 100
|
|||
max_send_errors = 1000
|
||||
|
||||
|
||||
[privacy]
|
||||
# Allow subscribers to export data recorded on them?
|
||||
allow_export = false
|
||||
|
||||
# Items to include in the data export.
|
||||
# profile Subscriber's profile including custom attributes
|
||||
# subscriptions Subscriber's subscription lists (private list names are masked)
|
||||
# campaign_views Campaigns the subscriber has viewed and the view counts
|
||||
# link_clicks Links that the subscriber has clicked and the click counts
|
||||
exportable = ["profile", "subscriptions", "campaign_views", "link_clicks"]
|
||||
|
||||
# Allow subscribers to delete themselves from the database?
|
||||
# This deletes the subscriber and all their subscriptions.
|
||||
# Their association to campaign views and link clicks are also
|
||||
# removed while views and click counts remain (with no subscriber
|
||||
# associated to them) so that stats and analytics aren't affected.
|
||||
allow_wipe = false
|
||||
|
||||
|
||||
# Database.
|
||||
[db]
|
||||
host = "demo-db"
|
||||
|
@ -53,8 +72,6 @@ password = "listmonk"
|
|||
database = "listmonk"
|
||||
ssl_mode = "disable"
|
||||
|
||||
# TQekh4quVgGc3HQ
|
||||
|
||||
|
||||
# SMTP servers.
|
||||
[smtp]
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
{{ define "subscriber-data" }}
|
||||
{{ template "header" . }}
|
||||
<h2>Your data</h2>
|
||||
<p>
|
||||
A copy of all data recorded on you is attached as a file in the JSON format.
|
||||
It can be viewed in a text editor.
|
||||
</p>
|
||||
{{ template "footer" }}
|
||||
{{ end }}
|
|
@ -7,6 +7,8 @@ import {
|
|||
Select,
|
||||
Button,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Icon,
|
||||
Spin,
|
||||
Popconfirm,
|
||||
notification
|
||||
|
@ -350,7 +352,7 @@ class Subscriber extends React.PureComponent {
|
|||
<section className="content">
|
||||
<header className="header">
|
||||
<Row>
|
||||
<Col span={20}>
|
||||
<Col span={22}>
|
||||
{!this.state.record.id && <h1>Add subscriber</h1>}
|
||||
{this.state.record.id && (
|
||||
<div>
|
||||
|
@ -372,7 +374,16 @@ class Subscriber extends React.PureComponent {
|
|||
</div>
|
||||
)}
|
||||
</Col>
|
||||
<Col span={2} />
|
||||
<Col span={2} className="right">
|
||||
<Tooltip title="Export data" placement="top">
|
||||
<a
|
||||
role="button"
|
||||
href={"/api/subscribers/" + this.state.record.id + "/export"}
|
||||
>
|
||||
<Icon type="export" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</Col>
|
||||
</Row>
|
||||
</header>
|
||||
<div>
|
||||
|
|
3
go.mod
3
go.mod
|
@ -7,8 +7,9 @@ require (
|
|||
github.com/jmoiron/sqlx v1.2.0
|
||||
github.com/jordan-wright/email v0.0.0-20181027021455-480bedc4908b
|
||||
github.com/knadh/goyesql v2.0.0+incompatible
|
||||
github.com/knadh/koanf v0.4.3
|
||||
github.com/knadh/koanf v0.4.4
|
||||
github.com/knadh/stuffbin v1.0.0
|
||||
github.com/kr/pretty v0.1.0 // indirect
|
||||
github.com/labstack/echo v3.3.10+incompatible
|
||||
github.com/labstack/gommon v0.2.7 // indirect
|
||||
github.com/lib/pq v1.0.0
|
||||
|
|
8
go.sum
8
go.sum
|
@ -28,10 +28,17 @@ github.com/knadh/koanf v0.4.2 h1:A/bb9+eRoHHHQ57O6y66vzRCYui915CK3FdDYzNs56Q=
|
|||
github.com/knadh/koanf v0.4.2/go.mod h1:Qd5yvXN39ZzjoRJdXMKN2QqHzQKhSx/K8fU5gyn4LPs=
|
||||
github.com/knadh/koanf v0.4.3 h1:aeCEnL10SVOIxnhhS3FeFtfvzC3RBphdhhrESE9qfCI=
|
||||
github.com/knadh/koanf v0.4.3/go.mod h1:Qd5yvXN39ZzjoRJdXMKN2QqHzQKhSx/K8fU5gyn4LPs=
|
||||
github.com/knadh/koanf v0.4.4 h1:Pg+eR7wuJtCGHLeip31K20eJojjZ3lXE8ILQQGj2PTM=
|
||||
github.com/knadh/koanf v0.4.4/go.mod h1:Qd5yvXN39ZzjoRJdXMKN2QqHzQKhSx/K8fU5gyn4LPs=
|
||||
github.com/knadh/stuffbin v0.0.0-20190103171338-6379e949be48 h1:lRb28d0+iiVwqF7Li25IJXjNRaVCQPH6n/fHwk9Qo+E=
|
||||
github.com/knadh/stuffbin v0.0.0-20190103171338-6379e949be48/go.mod h1:afUOPBWr6bZ09aS3wbSOqXVGaO6rKcyvXYTcuG9LYpI=
|
||||
github.com/knadh/stuffbin v1.0.0 h1:NQon6PTpLXies4bRFhS3VpLCf6y+jn6YVXU3i2wPQ+M=
|
||||
github.com/knadh/stuffbin v1.0.0/go.mod h1:yVCFaWaKPubSNibBsTAJ939q2ABHudJQxRWZWV5yh+4=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/labstack/echo v3.3.10+incompatible h1:pGRcYk231ExFAyoAjAfD85kQzRJCRI8bbnE7CX5OEgg=
|
||||
github.com/labstack/echo v3.3.10+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s=
|
||||
github.com/labstack/gommon v0.2.7 h1:2qOPq/twXDrQ6ooBGrn3mrmVOC+biLlatwgIu8lbzRM=
|
||||
|
@ -75,6 +82,7 @@ google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO50
|
|||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/volatiletech/null.v6 v6.0.0-20170828023728-0bef4e07ae1b h1:P+3+n9hUbqSDkSdtusWHVPQRrpRpLiLFzlZ02xXskM0=
|
||||
gopkg.in/volatiletech/null.v6 v6.0.0-20170828023728-0bef4e07ae1b/go.mod h1:0LRKfykySnChgQpG3Qpk+bkZFWazQ+MMfc5oldQCwnY=
|
||||
|
|
|
@ -8,7 +8,6 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
|
||||
"github.com/labstack/echo"
|
||||
)
|
||||
|
||||
|
@ -45,6 +44,7 @@ func registerHandlers(e *echo.Echo) {
|
|||
e.GET("/api/dashboard/stats", handleGetDashboardStats)
|
||||
|
||||
e.GET("/api/subscribers/:id", handleGetSubscriber)
|
||||
e.GET("/api/subscribers/:id/export", handleExportSubscriberData)
|
||||
e.POST("/api/subscribers", handleCreateSubscriber)
|
||||
e.PUT("/api/subscribers/:id", handleUpdateSubscriber)
|
||||
e.PUT("/api/subscribers/blacklist", handleBlacklistSubscribers)
|
||||
|
@ -59,7 +59,6 @@ func registerHandlers(e *echo.Echo) {
|
|||
e.POST("/api/subscribers/query/delete", handleDeleteSubscribersByQuery)
|
||||
e.PUT("/api/subscribers/query/blacklist", handleBlacklistSubscribersByQuery)
|
||||
e.PUT("/api/subscribers/query/lists", handleManageSubscriberListsByQuery)
|
||||
|
||||
e.GET("/api/subscribers", handleQuerySubscribers)
|
||||
|
||||
e.GET("/api/import/subscribers", handleGetImportSubscribers)
|
||||
|
@ -98,8 +97,10 @@ func registerHandlers(e *echo.Echo) {
|
|||
e.DELETE("/api/templates/:id", handleDeleteTemplate)
|
||||
|
||||
// Subscriber facing views.
|
||||
e.GET("/unsubscribe/:campUUID/:subUUID", handleUnsubscribePage)
|
||||
e.POST("/unsubscribe/:campUUID/:subUUID", handleUnsubscribePage)
|
||||
e.GET("/subscription/:campUUID/:subUUID", handleSubscriptionPage)
|
||||
e.POST("/subscription/:campUUID/:subUUID", handleSubscriptionPage)
|
||||
e.POST("/subscription/export/:subUUID", handleSelfExportSubscriberData)
|
||||
e.POST("/subscription/wipe/:subUUID", handleWipeSubscriberData)
|
||||
e.GET("/link/:linkUUID/:campUUID/:subUUID", handleLinkRedirect)
|
||||
e.GET("/campaign/:campUUID/:subUUID/px.png", handleRegisterCampaignView)
|
||||
|
||||
|
|
26
main.go
26
main.go
|
@ -13,6 +13,7 @@ import (
|
|||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/goyesql"
|
||||
"github.com/knadh/koanf"
|
||||
"github.com/knadh/koanf/maps"
|
||||
"github.com/knadh/koanf/parsers/toml"
|
||||
"github.com/knadh/koanf/providers/file"
|
||||
"github.com/knadh/koanf/providers/posflag"
|
||||
|
@ -25,13 +26,20 @@ import (
|
|||
)
|
||||
|
||||
type constants struct {
|
||||
RootURL string `koanf:"root"`
|
||||
LogoURL string `koanf:"logo_url"`
|
||||
FaviconURL string `koanf:"favicon_url"`
|
||||
UploadPath string `koanf:"upload_path"`
|
||||
UploadURI string `koanf:"upload_uri"`
|
||||
FromEmail string `koanf:"from_email"`
|
||||
NotifyEmails []string `koanf:"notify_emails"`
|
||||
RootURL string `koanf:"root"`
|
||||
LogoURL string `koanf:"logo_url"`
|
||||
FaviconURL string `koanf:"favicon_url"`
|
||||
UploadPath string `koanf:"upload_path"`
|
||||
UploadURI string `koanf:"upload_uri"`
|
||||
FromEmail string `koanf:"from_email"`
|
||||
NotifyEmails []string `koanf:"notify_emails"`
|
||||
Privacy privacyOptions `koanf:"privacy"`
|
||||
}
|
||||
|
||||
type privacyOptions struct {
|
||||
AllowExport bool `koanf:"allow_export"`
|
||||
AllowWipe bool `koanf:"allow_wipe"`
|
||||
Exportable map[string]bool `koanf:"-"`
|
||||
}
|
||||
|
||||
// App contains the "global" components that are
|
||||
|
@ -183,9 +191,11 @@ func main() {
|
|||
|
||||
var c constants
|
||||
ko.Unmarshal("app", &c)
|
||||
ko.Unmarshal("privacy", &c.Privacy)
|
||||
c.RootURL = strings.TrimRight(c.RootURL, "/")
|
||||
c.UploadURI = filepath.Clean(c.UploadURI)
|
||||
c.UploadPath = filepath.Clean(c.UploadPath)
|
||||
c.Privacy.Exportable = maps.StringSliceToLookupMap(ko.Strings("privacy.exportable"))
|
||||
|
||||
// Initialize the static file system into which all
|
||||
// required static assets (.sql, .js files etc.) are loaded.
|
||||
|
@ -253,7 +263,7 @@ func main() {
|
|||
FromEmail: app.Constants.FromEmail,
|
||||
|
||||
// url.com/unsubscribe/{campaign_uuid}/{subscriber_uuid}
|
||||
UnsubscribeURL: fmt.Sprintf("%s/unsubscribe/%%s/%%s", app.Constants.RootURL),
|
||||
UnsubscribeURL: fmt.Sprintf("%s/subscription/%%s/%%s", app.Constants.RootURL),
|
||||
|
||||
// url.com/link/{campaign_uuid}/{subscriber_uuid}/{link_uuid}
|
||||
LinkTrackURL: fmt.Sprintf("%s/link/%%s/%%s/%%s", app.Constants.RootURL),
|
||||
|
|
|
@ -31,3 +31,18 @@ func sendNotification(tpl, subject string, data map[string]interface{}, app *App
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getNotificationTemplate(tpl string, data map[string]interface{}, app *App) ([]byte, error) {
|
||||
if data == nil {
|
||||
data = make(map[string]interface{})
|
||||
}
|
||||
data["RootURL"] = app.Constants.RootURL
|
||||
|
||||
var b bytes.Buffer
|
||||
err := app.NotifTpls.ExecuteTemplate(&b, tpl, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b.Bytes(), err
|
||||
}
|
||||
|
|
116
public.go
116
public.go
|
@ -10,7 +10,9 @@ import (
|
|||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"github.com/knadh/listmonk/messenger"
|
||||
"github.com/labstack/echo"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// tplRenderer wraps a template.tplRenderer for echo.
|
||||
|
@ -37,8 +39,11 @@ type publicTpl struct {
|
|||
|
||||
type unsubTpl struct {
|
||||
publicTpl
|
||||
SubUUID string
|
||||
Unsubscribe bool
|
||||
Blacklist bool
|
||||
AllowExport bool
|
||||
AllowWipe bool
|
||||
}
|
||||
|
||||
type msgTpl struct {
|
||||
|
@ -62,20 +67,23 @@ func (t *tplRenderer) Render(w io.Writer, name string, data interface{}, c echo.
|
|||
})
|
||||
}
|
||||
|
||||
// handleUnsubscribePage unsubscribes a subscriber and renders a view.
|
||||
func handleUnsubscribePage(c echo.Context) error {
|
||||
// handleSubscriptionPage renders the subscription management page and
|
||||
// handles unsubscriptions.
|
||||
func handleSubscriptionPage(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
campUUID = c.Param("campUUID")
|
||||
subUUID = c.Param("subUUID")
|
||||
unsub, _ = strconv.ParseBool(c.FormValue("unsubscribe"))
|
||||
blacklist, _ = strconv.ParseBool(c.FormValue("blacklist"))
|
||||
|
||||
out = unsubTpl{}
|
||||
out = unsubTpl{}
|
||||
)
|
||||
out.Unsubscribe = unsub
|
||||
out.SubUUID = subUUID
|
||||
out.Blacklist = blacklist
|
||||
out.Title = "Unsubscribe from mailing list"
|
||||
out.AllowExport = app.Constants.Privacy.AllowExport
|
||||
out.AllowWipe = app.Constants.Privacy.AllowWipe
|
||||
|
||||
if !regexValidUUID.MatchString(campUUID) ||
|
||||
!regexValidUUID.MatchString(subUUID) {
|
||||
|
@ -105,7 +113,7 @@ func handleUnsubscribePage(c echo.Context) error {
|
|||
}
|
||||
}
|
||||
|
||||
return c.Render(http.StatusOK, "unsubscribe", out)
|
||||
return c.Render(http.StatusOK, "subscription", out)
|
||||
}
|
||||
|
||||
// handleLinkRedirect handles link UUID to real link redirection.
|
||||
|
@ -154,6 +162,104 @@ func handleRegisterCampaignView(c echo.Context) error {
|
|||
return c.Blob(http.StatusOK, "image/png", pixelPNG)
|
||||
}
|
||||
|
||||
// handleSelfExportSubscriberData pulls the subscriber's profile,
|
||||
// list subscriptions, campaign views and clicks and produces
|
||||
// a JSON report. This is a privacy feature and depends on the
|
||||
// configuration in app.Constants.Privacy.
|
||||
func handleSelfExportSubscriberData(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
subUUID = c.Param("subUUID")
|
||||
)
|
||||
if !regexValidUUID.MatchString(subUUID) {
|
||||
return c.Render(http.StatusInternalServerError, "message",
|
||||
makeMsgTpl("Invalid request", "",
|
||||
"The subscriber ID is invalid."))
|
||||
}
|
||||
|
||||
// Is export allowed?
|
||||
if !app.Constants.Privacy.AllowExport {
|
||||
return c.Render(http.StatusBadRequest, "message",
|
||||
makeMsgTpl("Invalid request", "",
|
||||
"The feature is not available."))
|
||||
}
|
||||
|
||||
// Get the subscriber's data. A single query that gets the profile,
|
||||
// list subscriptions, campaign views, and link clicks. Names of
|
||||
// private lists are replaced with "Private list".
|
||||
data, b, err := exportSubscriberData(0, subUUID, app.Constants.Privacy.Exportable, app)
|
||||
if err != nil {
|
||||
app.Logger.Printf("error exporting subscriber data: %s", err)
|
||||
return c.Render(http.StatusInternalServerError, "message",
|
||||
makeMsgTpl("Error processing request", "",
|
||||
"There was an error processing your request. Please try later."))
|
||||
}
|
||||
|
||||
// Send the data out to the subscriber as an atachment.
|
||||
msg, err := getNotificationTemplate("subscriber-data", nil, app)
|
||||
if err != nil {
|
||||
app.Logger.Printf("error preparing subscriber data e-mail template: %s", err)
|
||||
return c.Render(http.StatusInternalServerError, "message",
|
||||
makeMsgTpl("Error preparing data", "",
|
||||
"There was an error preparing your data. Please try later."))
|
||||
}
|
||||
|
||||
const fname = "profile.json"
|
||||
if err := app.Messenger.Push(app.Constants.FromEmail,
|
||||
[]string{data.Email},
|
||||
"Your profile data",
|
||||
msg,
|
||||
[]*messenger.Attachment{
|
||||
&messenger.Attachment{
|
||||
Name: fname,
|
||||
Content: b,
|
||||
Header: messenger.MakeAttachmentHeader(fname, "base64"),
|
||||
},
|
||||
},
|
||||
); err != nil {
|
||||
app.Logger.Printf("error e-mailing subscriber profile: %s", err)
|
||||
return c.Render(http.StatusInternalServerError, "message",
|
||||
makeMsgTpl("Error e-mailing data", "",
|
||||
"There was an error e-mailing your data. Please try later."))
|
||||
}
|
||||
return c.Render(http.StatusOK, "message",
|
||||
makeMsgTpl("Data e-mailed", "",
|
||||
`Your data has been e-mailed to you as an attachment.`))
|
||||
}
|
||||
|
||||
// handleWipeSubscriberData allows a subscriber to self-delete their data. The
|
||||
// profile and subscriptions are deleted, while the campaign_views and link
|
||||
// clicks remain as orphan data unconnected to any subscriber.
|
||||
func handleWipeSubscriberData(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
subUUID = c.Param("subUUID")
|
||||
)
|
||||
if !regexValidUUID.MatchString(subUUID) {
|
||||
return c.Render(http.StatusInternalServerError, "message",
|
||||
makeMsgTpl("Invalid request", "",
|
||||
"The subscriber ID is invalid."))
|
||||
}
|
||||
|
||||
// Is wiping allowed?
|
||||
if !app.Constants.Privacy.AllowExport {
|
||||
return c.Render(http.StatusBadRequest, "message",
|
||||
makeMsgTpl("Invalid request", "",
|
||||
"The feature is not available."))
|
||||
}
|
||||
|
||||
if _, err := app.Queries.DeleteSubscribers.Exec(nil, pq.StringArray{subUUID}); err != nil {
|
||||
app.Logger.Printf("error wiping subscriber data: %s", err)
|
||||
return c.Render(http.StatusInternalServerError, "message",
|
||||
makeMsgTpl("Error processing request", "",
|
||||
"There was an error processing your request. Please try later."))
|
||||
}
|
||||
|
||||
return c.Render(http.StatusOK, "message",
|
||||
makeMsgTpl("Data removed", "",
|
||||
`Your subscriptions and all associated data has been removed.`))
|
||||
}
|
||||
|
||||
// drawTransparentImage draws a transparent PNG of given dimensions
|
||||
// and returns the PNG bytes.
|
||||
func drawTransparentImage(h, w int) []byte {
|
||||
|
|
|
@ -1,78 +1,273 @@
|
|||
/* Flexit grid */
|
||||
.container{position:relative;width:100%;max-width:960px;margin:0 auto;padding:0 10px;box-sizing:border-box}.row{box-sizing:border-box;display:flex;flex:0 1 auto;flex-flow:row wrap}.columns,.column{box-sizing:border-box;flex-grow:1;flex-shrink:1;flex-basis:1;margin:10px 0 10px 4%}.column:first-child,.columns:first-child{margin-left:0}.one{max-width:4.6666666667%}.two{max-width:13.3333333333%}.three{max-width:22%}.four{max-width:30.6666666667%}.five{max-width:39.3333333333%}.six{max-width:48%}.seven{max-width:56.6666666667%}.eight{max-width:65.3333333333%}.nine{max-width:74%}.ten{max-width:82.6666666667%}.eleven{max-width:91.3333333333%}.twelve{max-width:100%;margin-left:0}.column-offset-0{margin-left:0}.column-offset-1{margin-left:8.33333333%}.column-offset-2{margin-left:16.66666667%}.column-offset-3{margin-left:25%}.column-offset-4{margin-left:33.33333333%}.column-offset-5{margin-left:41.66666667%}.column-offset-6{margin-left:50%}.column-offset-7{margin-left:58.33333333%}.column-offset-8{margin-left:66.66666667%}.column-offset-9{margin-left:75%}.column-offset-10{margin-left:83.33333333%}.column-offset-11{margin-left:91.66666667%}.between{justify-content:space-between}.evenly{justify-content:space-evenly}.around{justify-content:space-around}.center{justify-content:center;text-align:center}.start{justify-content:flex-start}.end{justify-content:flex-end}.top{align-items:flex-start}.bottom{align-items:flex-end}.middle{align-items:center}.first{order:-1}.last{order:1}.vertical{flex-flow:column wrap}.row-align-center{align-items:center}.space-right{margin-right:10px}.space-left{margin-left:10px}.space-bottom{margin-bottom:10px}.space-top{margin-top:10px}@media screen and (max-width: 768px){.container{overflow:auto}.columns,.column{min-width:100%;margin:10px 0}.column-offset-0,.column-offset-1,.column-offset-2,.column-offset-3,.column-offset-4,.column-offset-5,.column-offset-6,.column-offset-7,.column-offset-8,.column-offset-9,.column-offset-10,.column-offset-11{margin:unset}}/*# sourceMappingURL=dist/flexit.min.css.map */
|
||||
.container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 0 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.row {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
flex-flow: row wrap;
|
||||
}
|
||||
.columns,
|
||||
.column {
|
||||
box-sizing: border-box;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
flex-basis: 1;
|
||||
margin: 10px 0 10px 4%;
|
||||
}
|
||||
.column:first-child,
|
||||
.columns:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
.one {
|
||||
max-width: 4.6666666667%;
|
||||
}
|
||||
.two {
|
||||
max-width: 13.3333333333%;
|
||||
}
|
||||
.three {
|
||||
max-width: 22%;
|
||||
}
|
||||
.four {
|
||||
max-width: 30.6666666667%;
|
||||
}
|
||||
.five {
|
||||
max-width: 39.3333333333%;
|
||||
}
|
||||
.six {
|
||||
max-width: 48%;
|
||||
}
|
||||
.seven {
|
||||
max-width: 56.6666666667%;
|
||||
}
|
||||
.eight {
|
||||
max-width: 65.3333333333%;
|
||||
}
|
||||
.nine {
|
||||
max-width: 74%;
|
||||
}
|
||||
.ten {
|
||||
max-width: 82.6666666667%;
|
||||
}
|
||||
.eleven {
|
||||
max-width: 91.3333333333%;
|
||||
}
|
||||
.twelve {
|
||||
max-width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
.column-offset-0 {
|
||||
margin-left: 0;
|
||||
}
|
||||
.column-offset-1 {
|
||||
margin-left: 8.33333333%;
|
||||
}
|
||||
.column-offset-2 {
|
||||
margin-left: 16.66666667%;
|
||||
}
|
||||
.column-offset-3 {
|
||||
margin-left: 25%;
|
||||
}
|
||||
.column-offset-4 {
|
||||
margin-left: 33.33333333%;
|
||||
}
|
||||
.column-offset-5 {
|
||||
margin-left: 41.66666667%;
|
||||
}
|
||||
.column-offset-6 {
|
||||
margin-left: 50%;
|
||||
}
|
||||
.column-offset-7 {
|
||||
margin-left: 58.33333333%;
|
||||
}
|
||||
.column-offset-8 {
|
||||
margin-left: 66.66666667%;
|
||||
}
|
||||
.column-offset-9 {
|
||||
margin-left: 75%;
|
||||
}
|
||||
.column-offset-10 {
|
||||
margin-left: 83.33333333%;
|
||||
}
|
||||
.column-offset-11 {
|
||||
margin-left: 91.66666667%;
|
||||
}
|
||||
.between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.evenly {
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
.around {
|
||||
justify-content: space-around;
|
||||
}
|
||||
.center {
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
.start {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.top {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.bottom {
|
||||
align-items: flex-end;
|
||||
}
|
||||
.middle {
|
||||
align-items: center;
|
||||
}
|
||||
.first {
|
||||
order: -1;
|
||||
}
|
||||
.last {
|
||||
order: 1;
|
||||
}
|
||||
.vertical {
|
||||
flex-flow: column wrap;
|
||||
}
|
||||
.row-align-center {
|
||||
align-items: center;
|
||||
}
|
||||
.space-right {
|
||||
margin-right: 10px;
|
||||
}
|
||||
.space-left {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.space-bottom {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.space-top {
|
||||
margin-top: 10px;
|
||||
}
|
||||
@media screen and (max-width: 768px) {
|
||||
.container {
|
||||
overflow: auto;
|
||||
}
|
||||
.columns,
|
||||
.column {
|
||||
min-width: 100%;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.column-offset-0,
|
||||
.column-offset-1,
|
||||
.column-offset-2,
|
||||
.column-offset-3,
|
||||
.column-offset-4,
|
||||
.column-offset-5,
|
||||
.column-offset-6,
|
||||
.column-offset-7,
|
||||
.column-offset-8,
|
||||
.column-offset-9,
|
||||
.column-offset-10,
|
||||
.column-offset-11 {
|
||||
margin: unset;
|
||||
}
|
||||
} /*# sourceMappingURL=dist/flexit.min.css.map */
|
||||
|
||||
body {
|
||||
background: #f9f9f9;
|
||||
font-family: "Open Sans", "Helvetica Neue", sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 28px;
|
||||
color: #111;
|
||||
background: #f9f9f9;
|
||||
font-family: "Open Sans", "Helvetica Neue", sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 28px;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #000;
|
||||
color: #7f2aff;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
font-weight: 400;
|
||||
a:hover {
|
||||
color: #111;
|
||||
}
|
||||
label {
|
||||
cursor: pointer;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
font-weight: 400;
|
||||
}
|
||||
section {
|
||||
margin-bottom: 45px;
|
||||
}
|
||||
|
||||
.button {
|
||||
background: #7f2aff;
|
||||
padding: 10px 30px;
|
||||
border-radius: 3px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: #ffff;
|
||||
display: inline-block;
|
||||
background: #7f2aff;
|
||||
padding: 10px 30px;
|
||||
border-radius: 3px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: #ffff;
|
||||
display: inline-block;
|
||||
min-width: 150px;
|
||||
}
|
||||
.button:hover {
|
||||
background: #333;
|
||||
}
|
||||
.button.button-outline {
|
||||
background: #fff;
|
||||
border: 1px solid #7f2aff;
|
||||
color: #7f2aff;
|
||||
}
|
||||
.button.button-outline:hover {
|
||||
background-color: #7f2aff;
|
||||
color: #fff;
|
||||
}
|
||||
.button:hover {
|
||||
background: #333;
|
||||
}
|
||||
.button .button-outline {
|
||||
background: transparent;
|
||||
border: 1px solid #ddd;
|
||||
color: #444'
|
||||
}
|
||||
.button .button-outline:hover {
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
background: #fff;
|
||||
margin-top: 60px;
|
||||
max-width: 600px;
|
||||
padding: 45px;
|
||||
box-shadow: 2px 2px 0 #f3f3f3;
|
||||
border: 1px solid #eee;
|
||||
background: #fff;
|
||||
margin-top: 60px;
|
||||
max-width: 600px;
|
||||
padding: 45px;
|
||||
box-shadow: 2px 2px 0 #f3f3f3;
|
||||
border: 1px solid #eee;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 60px;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
.header .logo img {
|
||||
width: auto;
|
||||
max-width: 150px;
|
||||
}
|
||||
.header .logo img {
|
||||
width: auto;
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.unsub-all {
|
||||
margin-top: 30px;
|
||||
padding-top: 30px;
|
||||
border-top: 1px solid #eee;
|
||||
margin-top: 30px;
|
||||
padding-top: 30px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
color: #aaa;
|
||||
font-size: 0.775em;
|
||||
margin-top: 30px;
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
color: #aaa;
|
||||
font-size: 0.775em;
|
||||
margin-top: 30px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.footer a {
|
||||
color: #aaa;
|
||||
text-decoration: none;
|
||||
}
|
||||
.footer a:hover {
|
||||
color: #111;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 650px) {
|
||||
.wrap {
|
||||
margin: 0;
|
||||
}
|
||||
.header {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
}
|
||||
.footer a {
|
||||
color: #aaa;
|
||||
text-decoration: none;
|
||||
}
|
||||
.footer a:hover {
|
||||
color: #111;
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
{{ define "error" }}
|
||||
{{ template "header" .}}
|
||||
|
||||
<h2>{{ .Data.ErrorTitle }}</h2>
|
||||
<div>
|
||||
{{ .Data.ErrorMessage }}
|
||||
</div>
|
||||
|
||||
{{ template "footer" .}}
|
||||
{{ end }}
|
|
@ -0,0 +1,85 @@
|
|||
{{ define "subscription" }}
|
||||
{{ template "header" .}}
|
||||
<section>
|
||||
{{ if not .Data.Unsubscribe }}
|
||||
<h2>Unsubscribe</h2>
|
||||
<p>Do you wish to unsubscribe from this mailing list?</p>
|
||||
<form method="post">
|
||||
<div>
|
||||
<input type="hidden" name="unsubscribe" value="true" />
|
||||
<button type="submit" class="button" id="btn-unsub">Unsubscribe</button>
|
||||
</div>
|
||||
</form>
|
||||
{{ else }}
|
||||
<h2>You have been unsubscribed</h2>
|
||||
{{ if not .Data.Blacklist }}
|
||||
<div class="unsub-all">
|
||||
<p>Unsubscribe from all future communications?</p>
|
||||
<form method="post">
|
||||
<div>
|
||||
<input type="hidden" name="unsubscribe" value="true" />
|
||||
<input type="hidden" name="blacklist" value="true" />
|
||||
<button type="submit" class="button button-inline" id="btn-unsuball">
|
||||
Unsubscribe all
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{ else }}
|
||||
<p>You've been unsubscribed from all future communications.</p>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</section>
|
||||
|
||||
{{ if or .Data.AllowExport .Data.AllowWipe }}
|
||||
<form id="data-form" method="post" action="" onsubmit="return handleData()">
|
||||
<section>
|
||||
<h2>Privacy and data</h2>
|
||||
{{ if .Data.AllowExport }}
|
||||
<div class="row">
|
||||
<div class="one columns">
|
||||
<input id="privacy-export" type="radio" name="data-action" value="export" required />
|
||||
</div>
|
||||
<div class="ten columns">
|
||||
<label for="privacy-export"><strong>Export your data</strong></label>
|
||||
<br />
|
||||
A copy of your data will be e-mailed to you.
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ if .Data.AllowWipe }}
|
||||
<div class="row">
|
||||
<div class="one columns">
|
||||
<input id="privacy-wipe" type="radio" name="data-action" value="wipe" required />
|
||||
</div>
|
||||
<div class="ten columns">
|
||||
<label for="privacy-wipe"><strong>Wipe your data</strong></label>
|
||||
<br />
|
||||
Delete all your subscriptions and related data from our database permanently.
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
<p>
|
||||
<input type="submit" value="Continue" class="button button-outline" />
|
||||
</p>
|
||||
</section>
|
||||
</form>
|
||||
<script>
|
||||
function handleData() {
|
||||
var a = document.querySelector('input[name="data-action"]:checked').value,
|
||||
f = document.querySelector("#data-form");
|
||||
if (a == "export") {
|
||||
f.action = "/subscription/export/{{ .Data.SubUUID }}";
|
||||
return true;
|
||||
} else if (confirm("Are you sure you want to delete all your subscription data permanently?")) {
|
||||
f.action = "/subscription/wipe/{{ .Data.SubUUID }}";
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
</script>
|
||||
{{ end }}
|
||||
|
||||
{{ template "footer" .}}
|
||||
{{ end }}
|
|
@ -1,35 +0,0 @@
|
|||
{{ define "unsubscribe" }}
|
||||
{{ template "header" .}}
|
||||
|
||||
{{ if not .Data.Unsubscribe }}
|
||||
<h2>Unsubscribe</h2>
|
||||
<p>Do you wish to unsubscribe from this mailing list?</p>
|
||||
<form method="post">
|
||||
<div>
|
||||
<input type="hidden" name="unsubscribe" value="true" />
|
||||
<button type="submit" class="button" id="btn-unsub">Unsubscribe</button>
|
||||
</div>
|
||||
</form>
|
||||
{{ else }}
|
||||
<h2>You have been unsubscribed</h2>
|
||||
|
||||
{{ if not .Data.Blacklist }}
|
||||
<div class="unsub-all">
|
||||
<p>
|
||||
Unsubscribe from all future communications?
|
||||
</p>
|
||||
<form method="post">
|
||||
<div>
|
||||
<input type="hidden" name="unsubscribe" value="true" />
|
||||
<input type="hidden" name="blacklist" value="true" />
|
||||
<button type="submit" class="button button-inline" id="btn-unsuball">Unsubscribe all</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{ else }}
|
||||
<p>You've been unsubscribed from all future communications.</p>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{ template "footer" .}}
|
||||
{{ end }}
|
|
@ -26,6 +26,7 @@ type Queries struct {
|
|||
UnsubscribeSubscribersFromLists *sqlx.Stmt `query:"unsubscribe-subscribers-from-lists"`
|
||||
DeleteSubscribers *sqlx.Stmt `query:"delete-subscribers"`
|
||||
Unsubscribe *sqlx.Stmt `query:"unsubscribe"`
|
||||
ExportSubscriberData *sqlx.Stmt `query:"export-subscriber-data"`
|
||||
|
||||
// Non-prepared arbitrary subscriber queries.
|
||||
QuerySubscribers string `query:"query-subscribers"`
|
||||
|
|
41
queries.sql
41
queries.sql
|
@ -3,7 +3,6 @@
|
|||
-- Get a single subscriber by id or UUID.
|
||||
SELECT * FROM subscribers WHERE CASE WHEN $1 > 0 THEN id = $1 ELSE uuid = $2 END;
|
||||
|
||||
-- subscribers
|
||||
-- name: get-subscribers-by-emails
|
||||
-- Get subscribers by emails.
|
||||
SELECT * FROM subscribers WHERE email=ANY($1);
|
||||
|
@ -149,6 +148,46 @@ UPDATE subscriber_lists SET status = 'unsubscribed' WHERE
|
|||
-- If $3 is false, unsubscribe from the campaign's lists, otherwise all lists.
|
||||
CASE WHEN $3 IS FALSE THEN list_id = ANY(SELECT list_id FROM lists) ELSE list_id != 0 END;
|
||||
|
||||
-- privacy
|
||||
-- name: export-subscriber-data
|
||||
WITH prof AS (
|
||||
SELECT uuid, email, name, attribs, status, created_at, updated_at FROM subscribers WHERE
|
||||
CASE WHEN $1 > 0 THEN id = $1 ELSE uuid = $2 END
|
||||
),
|
||||
subs AS (
|
||||
SELECT JSON_AGG(
|
||||
ROW_TO_JSON(
|
||||
(SELECT l FROM (
|
||||
SELECT subscriber_lists.status AS subscription_status,
|
||||
(CASE WHEN lists.type = 'private' THEN 'Private list' ELSE lists.name END) as name,
|
||||
lists.type, subscriber_lists.created_at
|
||||
) l)
|
||||
)
|
||||
) AS lists FROM lists
|
||||
LEFT JOIN subscriber_lists ON (subscriber_lists.list_id = lists.id)
|
||||
WHERE subscriber_lists.subscriber_id = (SELECT id FROM prof)
|
||||
GROUP BY subscriber_id
|
||||
),
|
||||
views AS (
|
||||
SELECT JSON_AGG(t) AS views FROM
|
||||
(SELECT subject as campaign, COUNT(subscriber_id) as views FROM campaign_views
|
||||
LEFT JOIN campaigns ON (campaigns.id = campaign_views.campaign_id)
|
||||
WHERE subscriber_id = (SELECT id FROM prof)
|
||||
GROUP BY campaigns.id ORDER BY id) t
|
||||
),
|
||||
clicks AS (
|
||||
SELECT JSON_AGG(t) AS views FROM
|
||||
(SELECT url, COUNT(subscriber_id) as clicks FROM link_clicks
|
||||
LEFT JOIN links ON (links.id = link_clicks.link_id)
|
||||
WHERE subscriber_id = (SELECT id FROM prof)
|
||||
GROUP BY links.id ORDER BY id) t
|
||||
)
|
||||
SELECT (SELECT email FROM prof) as email,
|
||||
COALESCE((SELECT JSON_AGG(t) AS profile FROM prof t), '{}') AS profile,
|
||||
COALESCE((SELECT * FROM subs), '[]') AS subscriptions,
|
||||
COALESCE((SELECT * FROM views), '[]') AS campaign_views,
|
||||
COALESCE((SELECT * FROM clicks), '[]') AS link_clicks;
|
||||
|
||||
-- Partial and RAW queries used to construct arbitrary subscriber
|
||||
-- queries for segmentation follow.
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ package main
|
|||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
@ -35,6 +36,16 @@ type subsWrap struct {
|
|||
Page int `json:"page"`
|
||||
}
|
||||
|
||||
// subProfileData represents a subscriber's collated data in JSON
|
||||
// for export.
|
||||
type subProfileData struct {
|
||||
Email string `db:"email" json:"-"`
|
||||
Profile json.RawMessage `db:"profile" json:"profile,omitempty"`
|
||||
Subscriptions json.RawMessage `db:"subscriptions" json:"subscriptions,omitempty"`
|
||||
CampaignViews json.RawMessage `db:"campaign_views" json:"campaign_views,omitempty"`
|
||||
LinkClicks json.RawMessage `db:"link_clicks" json:"link_clicks,omitempty"`
|
||||
}
|
||||
|
||||
var dummySubscriber = models.Subscriber{
|
||||
Email: "dummy@listmonk.app",
|
||||
Name: "Dummy Subscriber",
|
||||
|
@ -418,6 +429,77 @@ func handleManageSubscriberListsByQuery(c echo.Context) error {
|
|||
return c.JSON(http.StatusOK, okResp{true})
|
||||
}
|
||||
|
||||
// handleExportSubscriberData pulls the subscriber's profile,
|
||||
// list subscriptions, campaign views and clicks and produces
|
||||
// a JSON report. This is a privacy feature and depends on the
|
||||
// configuration in app.Constants.Privacy.
|
||||
func handleExportSubscriberData(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
pID = c.Param("id")
|
||||
)
|
||||
id, _ := strconv.ParseInt(pID, 10, 64)
|
||||
if id < 1 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
|
||||
}
|
||||
|
||||
// Get the subscriber's data. A single query that gets the profile,
|
||||
// list subscriptions, campaign views, and link clicks. Names of
|
||||
// private lists are replaced with "Private list".
|
||||
_, b, err := exportSubscriberData(id, "", app.Constants.Privacy.Exportable, app)
|
||||
if err != nil {
|
||||
app.Logger.Printf("error exporting subscriber data: %s", err)
|
||||
return echo.NewHTTPError(http.StatusBadRequest,
|
||||
"Error exporting subscriber data.")
|
||||
}
|
||||
|
||||
c.Response().Header().Set("Cache-Control", "no-cache")
|
||||
c.Response().Header().Set("Content-Disposition", `attachment; filename="profile.json"`)
|
||||
return c.Blob(http.StatusOK, "application/json", b)
|
||||
}
|
||||
|
||||
// exportSubscriberData collates the data of a subscriber including profile,
|
||||
// subscriptions, campaign_views, link_clicks (if they're enabled in the config)
|
||||
// and returns a formatted, indented JSON payload. Either takes a numeric id
|
||||
// and an empty subUUID or takes 0 and a string subUUID.
|
||||
func exportSubscriberData(id int64, subUUID string, exportables map[string]bool, app *App) (subProfileData, []byte, error) {
|
||||
// Get the subscriber's data. A single query that gets the profile,
|
||||
// list subscriptions, campaign views, and link clicks. Names of
|
||||
// private lists are replaced with "Private list".
|
||||
var (
|
||||
data subProfileData
|
||||
uu interface{}
|
||||
)
|
||||
// UUID should be a valid value or a nil.
|
||||
if subUUID != "" {
|
||||
uu = subUUID
|
||||
}
|
||||
if err := app.Queries.ExportSubscriberData.Get(&data, id, uu); err != nil {
|
||||
return data, nil, err
|
||||
}
|
||||
|
||||
// Filter out the non-exportable items.
|
||||
if _, ok := exportables["profile"]; !ok {
|
||||
data.Profile = nil
|
||||
}
|
||||
if _, ok := exportables["subscriptions"]; !ok {
|
||||
data.Subscriptions = nil
|
||||
}
|
||||
if _, ok := exportables["campaign_views"]; !ok {
|
||||
data.CampaignViews = nil
|
||||
}
|
||||
if _, ok := exportables["link_clicks"]; !ok {
|
||||
data.LinkClicks = nil
|
||||
}
|
||||
|
||||
// Marshal the data into an indented payload.
|
||||
b, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return data, nil, err
|
||||
}
|
||||
return data, b, nil
|
||||
}
|
||||
|
||||
// sanitizeSQLExp does basic sanitisation on arbitrary
|
||||
// SQL query expressions coming from the frontend.
|
||||
func sanitizeSQLExp(q string) string {
|
||||
|
|
Loading…
Reference in New Issue