Add api type user.

This commit is contained in:
Kailash Nadh 2024-05-07 11:08:31 +05:30
parent bf0b500bb0
commit 1516bf216f
17 changed files with 300 additions and 120 deletions

View file

@ -2,12 +2,19 @@ package main
import (
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/knadh/listmonk/internal/utils"
"github.com/knadh/listmonk/models"
"github.com/labstack/echo/v4"
"gopkg.in/volatiletech/null.v6"
)
var (
reUsername = regexp.MustCompile("^[a-zA-Z0-9_\\-\\.]+$")
)
// handleGetUsers retrieves users.
@ -53,22 +60,33 @@ func handleCreateUser(c echo.Context) error {
u.Username = strings.TrimSpace(u.Username)
u.Name = strings.TrimSpace(u.Name)
u.Email = strings.TrimSpace(u.Email)
email := strings.TrimSpace(u.Email.String)
// Validate fields.
if !strHasLen(u.Username, 1, stdInputMaxLen) {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username"))
}
if !reUsername.MatchString(u.Username) {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username"))
}
if u.Type != models.UserTypeAPI {
if !utils.ValidateEmail(email) {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "email"))
}
if u.PasswordLogin {
if !strHasLen(u.Password.String, 8, stdInputMaxLen) {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password"))
}
}
u.Email = null.String{String: email, Valid: true}
}
if u.Name == "" {
u.Name = u.Username
}
if !strHasLen(u.Username, 1, stdInputMaxLen) {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username"))
}
if u.PasswordLogin {
if !strHasLen(u.Password.String, 8, stdInputMaxLen) {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password"))
}
}
// Create the user in the database.
out, err := app.core.CreateUser(u)
if err != nil {
return err
@ -97,34 +115,49 @@ func handleUpdateUser(c echo.Context) error {
// Validate.
u.Username = strings.TrimSpace(u.Username)
u.Name = strings.TrimSpace(u.Name)
u.Email = strings.TrimSpace(u.Email)
if u.Name == "" {
u.Name = u.Username
}
email := strings.TrimSpace(u.Email.String)
// Validate fields.
if !strHasLen(u.Username, 1, stdInputMaxLen) {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username"))
}
if !reUsername.MatchString(u.Username) {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username"))
}
if u.PasswordLogin {
if u.Password.String != "" {
if u.Type != models.UserTypeAPI {
if !utils.ValidateEmail(email) {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "email"))
}
if u.PasswordLogin && u.Password.String != "" {
if !strHasLen(u.Password.String, 8, stdInputMaxLen) {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password"))
}
} else {
// Get the existing user for password validation.
user, err := app.core.GetUser(id)
if err != nil {
return err
}
// If password login is enabled, but there's no password in the DB and there's no incoming
// password, throw an error.
if !user.HasPassword {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password"))
if u.Password.String != "" {
if !strHasLen(u.Password.String, 8, stdInputMaxLen) {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password"))
}
} else {
// Get the existing user for password validation.
user, err := app.core.GetUser(id)
if err != nil {
return err
}
// If password login is enabled, but there's no password in the DB and there's no incoming
// password, throw an error.
if !user.HasPassword {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password"))
}
}
}
u.Email = null.String{String: email, Valid: true}
}
if u.Name == "" {
u.Name = u.Username
}
out, err := app.core.UpdateUser(id, u)

View file

@ -600,6 +600,20 @@
"code": 59431,
"src": "typicons"
},
{
"uid": "77025195d19e048302e8943e2da4cc75",
"css": "account-outline",
"code": 983059,
"src": "custom_icons",
"selected": true,
"svg": {
"path": "M500 166Q568.4 166 617.2 214.8T666 333 617.2 451.2 500 500 382.8 451.2 334 333 382.8 214.8 500 166ZM500 250Q464.8 250 440.4 274.4T416 333 440.4 391.6 500 416 559.6 391.6 584 333 559.6 274.4 500 250ZM500 541Q562.5 541 636.7 560.5 720.7 582 771.5 615.2 834 656.3 834 709V834H166V709Q166 656.3 228.5 615.2 279.3 582 363.3 560.5 437.5 541 500 541ZM500 621.1Q441.4 621.1 378.9 636.7 324.2 652.3 285.2 673.8T246.1 709V753.9H753.9V709Q753.9 695.3 714.8 673.8T621.1 636.7Q558.6 621.1 500 621.1Z",
"width": 1000
},
"search": [
"account-outline"
]
},
{
"uid": "f4ad3f6d071a0bfb3a8452b514ed0892",
"css": "vector-square",
@ -838,20 +852,6 @@
"account-off"
]
},
{
"uid": "77025195d19e048302e8943e2da4cc75",
"css": "account-outline",
"code": 983059,
"src": "custom_icons",
"selected": false,
"svg": {
"path": "M500 166Q568.4 166 617.2 214.8T666 333 617.2 451.2 500 500 382.8 451.2 334 333 382.8 214.8 500 166ZM500 250Q464.8 250 440.4 274.4T416 333 440.4 391.6 500 416 559.6 391.6 584 333 559.6 274.4 500 250ZM500 541Q562.5 541 636.7 560.5 720.7 582 771.5 615.2 834 656.3 834 709V834H166V709Q166 656.3 228.5 615.2 279.3 582 363.3 560.5 437.5 541 500 541ZM500 621.1Q441.4 621.1 378.9 636.7 324.2 652.3 285.2 673.8T246.1 709V753.9H753.9V709Q753.9 695.3 714.8 673.8T621.1 636.7Q558.6 621.1 500 621.1Z",
"width": 1000
},
"search": [
"account-outline"
]
},
{
"uid": "571120b7ff63feb71df85710d019302c",
"css": "account-plus",

View file

@ -75,6 +75,7 @@
/* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */
}
.mdi-view-dashboard-variant-outline:before { content: '\e800'; } /* '' */
.mdi-format-list-bulleted-square:before { content: '\e801'; } /* '' */
.mdi-newspaper-variant-outline:before { content: '\e802'; } /* '' */
@ -115,6 +116,7 @@
.mdi-email-bounce:before { content: '\e825'; } /* '' */
.mdi-speedometer:before { content: '\e826'; } /* '' */
.mdi-warning-empty:before { content: '\e827'; } /* '' */
.mdi-account-outline:before { content: '󰀓'; } /* '\f0013' */
.mdi-code:before { content: '󰅩'; } /* '\f0169' */
.mdi-logout-variant:before { content: '󰗽'; } /* '\f05fd' */
.mdi-wrench-outline:before { content: '󰯠'; } /* '\f0be0' */

View file

@ -563,21 +563,21 @@ body.is-noscroll {
color: $grey;
}
&.private, &.scheduled, &.paused, &.tx {
&.private, &.scheduled, &.paused, &.tx, &.api {
$color: #ed7b00;
color: $color;
background: #fff7e6;
border: 1px solid lighten($color, 37%);
box-shadow: 1px 1px 0 lighten($color, 37%);
}
&.public, &.running, &.list, &.campaign, &.super {
&.public, &.running, &.list, &.campaign, &.user {
$color: $primary;
color: lighten($color, 20%);;
background: #e6f7ff;
border: 1px solid lighten($color, 42%);
box-shadow: 1px 1px 0 lighten($color, 42%);
}
&.finished, &.enabled, &.status-confirmed {
&.finished, &.enabled, &.status-confirmed, &.super {
$color: $green;
color: $color;
background: #f6ffed;
@ -897,6 +897,15 @@ section.users {
min-width: 100px !important;
}
}
.user-api-token .copy-text {
background: rgba($green, .1);
display: block;
width: 100%;
border-radius: 3px;
padding: 15px;
font-size: 1.2rem;
color: $green;
}
/* C3 charting lib */

View file

@ -33,7 +33,8 @@
<div class="column is-4">
<b-field :label="$t('globals.fields.status')" label-position="on-border"
:message="$t('subscribers.blocklistedHelp')">
<b-select v-model="form.status" name="status" :placeholder="$t('globals.fields.status')" required expanded>
<b-select v-model="form.status" name="status" :placeholder="$t('globals.fields.status')" required
expanded>
<option value="enabled">
{{ $t('subscribers.status.enabled') }}
</option>

View file

@ -13,14 +13,28 @@
</h4>
</header>
<section expanded class="modal-card-body">
<b-field :label="$t('users.username')" label-position="on-border">
<b-input :maxlength="200" v-model="form.username" name="username" :ref="'focus'"
:placeholder="$t('users.username')" required :message="$t('users.usernameHelp')" autocomplete="off"
pattern="[a-zA-Z0-9_\-\.]+$" />
</b-field>
<div class="columns">
<div class="column is-8">
<b-field :label="$t('users.username')" label-position="on-border">
<b-input :maxlength="200" v-model="form.username" name="username" :ref="'focus'"
:placeholder="$t('users.username')" required :message="$t('users.usernameHelp')" autocomplete="off" />
<div class="column is-6">
<b-field :label="$t('users.type')" label-position="on-border">
<b-select v-model="form.type" name="status" required expanded>
<option v-if="hasType('user')" value="user">
{{ $t('users.type.user') }}
</option>
<option v-if="hasType('super')" value="super">
{{ $t('users.type.super') }}
</option>
<option v-if="hasType('api')" value="api">
{{ $t('users.type.api') }}
</option>
</b-select>
</b-field>
</div>
<div class="column is-4">
<div class="column is-6">
<b-field :label="$t('globals.fields.status')" label-position="on-border">
<b-select v-model="form.status" name="status" required expanded>
<option value="enabled">
@ -29,15 +43,12 @@
<option value="disabled">
{{ $t('users.status.disabled') }}
</option>
<option value="super">
{{ $t('users.status.super') }}
</option>
</b-select>
</b-field>
</div>
</div>
<b-field :label="$t('subscribers.email')" label-position="on-border">
<b-field v-if="form.type !== 'api'" :label="$t('subscribers.email')" label-position="on-border">
<b-input :maxlength="200" v-model="form.email" name="email" :placeholder="$t('subscribers.email')" required />
</b-field>
@ -45,33 +56,39 @@
<b-input :maxlength="200" v-model="form.name" name="name" :placeholder="$t('globals.fields.name')" />
</b-field>
<b-field>
<b-checkbox v-model="form.passwordLogin" :native-value="true">
{{ $t('users.passwordEnable') }}
</b-checkbox>
</b-field>
<template v-if="form.type !== 'api'">
<b-field>
<b-checkbox v-model="form.passwordLogin" :native-value="true">
{{ $t('users.passwordEnable') }}
</b-checkbox>
</b-field>
<div class="columns">
<div class="column is-6">
<b-field :label="$t('users.password')" label-position="on-border">
<b-input :disabled="!form.passwordLogin" minlength="8" :maxlength="200" v-model="form.password"
type="password" name="password" :placeholder="$t('users.password')"
:required="form.passwordLogin && !isEditing" />
</b-field>
</div>
<div class="column is-6">
<b-field :label="$t('users.passwordRepeat')" label-position="on-border">
<b-input :disabled="!form.passwordLogin" minlength="8" :maxlength="200" v-model="form.password2"
type="password" name="password" :required="form.passwordLogin && !isEditing && form.password" />
</b-field>
<div class="columns">
<div class="column is-6">
<b-field :label="$t('users.password')" label-position="on-border">
<b-input :disabled="!form.passwordLogin" minlength="8" :maxlength="200" v-model="form.password"
type="password" name="password" :placeholder="$t('users.password')"
:required="form.passwordLogin && !isEditing" />
</b-field>
</div>
<div class="column is-6">
<b-field :label="$t('users.passwordRepeat')" label-position="on-border">
<b-input :disabled="!form.passwordLogin" minlength="8" :maxlength="200" v-model="form.password2"
type="password" name="password" :required="form.passwordLogin && !isEditing && form.password" />
</b-field>
</div>
</div>
</template>
<div v-if="apiToken" class="user-api-token">
<p>{{ $t('users.apiOneTimeToken') }}</p>
<copy-text :text="apiToken" />
</div>
</section>
<footer class="modal-card-foot has-text-right">
<b-button @click="$parent.close()">
{{ $t('globals.buttons.close') }}
</b-button>
<b-button native-type="submit" type="is-primary" :loading="loading.lists" data-cy="btn-save">
<b-button v-if="!apiToken" native-type="submit" type="is-primary" :loading="loading.lists" data-cy="btn-save">
{{ $t('globals.buttons.save') }}
</b-button>
</footer>
@ -105,8 +122,10 @@ export default Vue.extend({
name: '',
password: '',
passwordLogin: false,
type: 'user',
status: 'enabled',
},
apiToken: null,
};
},
@ -118,7 +137,7 @@ export default Vue.extend({
}
if (this.isEditing) {
if (this.form.passwordLogin && this.form.password && this.form.password !== this.form.password2) {
if (this.form.type !== 'api' && this.form.passwordLogin && this.form.password && this.form.password !== this.form.password2) {
this.$utils.toast(this.$t('users.passwordMismatch'), 'is-danger');
return;
}
@ -127,7 +146,7 @@ export default Vue.extend({
return;
}
if (this.form.passwordLogin && this.form.password !== this.form.password2) {
if (this.form.type !== 'api' && this.form.passwordLogin && this.form.password !== this.form.password2) {
this.$utils.toast(this.$t('users.passwordMismatch'), 'is-danger');
return;
}
@ -139,8 +158,15 @@ export default Vue.extend({
const form = { ...this.form, password_login: this.form.passwordLogin };
this.$api.createUser(form).then((data) => {
this.$emit('finished');
this.$parent.close();
this.$utils.toast(this.$t('globals.messages.created', { name: data.name }));
// If the user is an API user, show the one-time token.
if (form.type === 'api') {
this.apiToken = data.password;
return;
}
this.$parent.close();
});
},
@ -152,6 +178,12 @@ export default Vue.extend({
this.$utils.toast(this.$t('globals.messages.updated', { name: data.name }));
});
},
hasType(t) {
// If the user being edited is API, then the only valid field is API.
// Otherwise, all fields are valid except API.
return !this.$props.isEditing || (this.form.type === 'api' ? t === 'api' : t !== 'api');
},
},
computed: {

View file

@ -36,35 +36,37 @@
</div>
</template>
<b-table-column v-slot="props" field="type" :label="$t('users.type')" sortable>
<b-tag :class="{ [props.row.type]: props.row.status === 'enabled' }">
{{ $t(`users.type.${props.row.type}`) }}
</b-tag>
</b-table-column>
<b-table-column v-slot="props" field="username" :label="$t('users.username')" header-class="cy-username" sortable
:td-attrs="$utils.tdID">
<a :href="`/users/${props.row.id}`" @click.prevent="showEditForm(props.row)"
:class="{ 'has-text-grey': props.row.status === 'disabled' }">
{{ props.row.username }}
<div class="has-text-grey is-size-7">{{ props.row.name }}</div>
</a>
</b-table-column>
<b-table-column v-slot="props" field="status" :label="$t('globals.fields.status')" header-class="cy-status"
sortable :td-attrs="$utils.tdID">
<b-tag :class="{ 'is-small': true, [props.row.status]: true }">
<b-tag :class="{ [props.row.status]: true }">
{{ $t(`users.status.${props.row.status}`) }}
</b-tag>
</b-table-column>
<b-table-column v-slot="props" field="name" :label="$t('globals.fields.name')" header-class="cy-name" sortable
:td-attrs="$utils.tdID">
<div>
<a :href="`/users/${props.row.id}`" @click.prevent="showEditForm(props.row)">
{{ props.row.name }}
</a>
</div>
</b-table-column>
<b-table-column v-slot="props" field="name" :label="$t('subscribers.email')" header-class="cy-name" sortable
:td-attrs="$utils.tdID">
<div>
<a :href="`/users/${props.row.id}`" @click.prevent="showEditForm(props.row)">
<a v-if="props.row.email" :href="`/users/${props.row.id}`" @click.prevent="showEditForm(props.row)"
:class="{ 'has-text-grey': props.row.status === 'disabled' }">
{{ props.row.email }}
</a>
<template v-else>
</template>
</div>
</b-table-column>
@ -119,6 +121,12 @@ import { mapState } from 'vuex';
import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
import UserForm from './UserForm.vue';
const TYPE_ICONS = {
user: 'account-outline',
super: 'account-check-outline',
api: 'link-variant',
};
export default Vue.extend({
components: {
EmptyPlaceholder,
@ -201,6 +209,8 @@ export default Vue.extend({
},
);
},
getTypeIcon: (typ) => TYPE_ICONS[typ],
},
computed: {

View file

@ -599,15 +599,19 @@
"users.logout": "Logout",
"users.lastLogin": "Last login",
"users.newUser": "New user",
"users.type": "Type",
"users.type.user": "User",
"users.type.super": "Super Admin",
"users.type.api": "API",
"users.status.enabled": "Enabled",
"users.status.disabled": "Disabled",
"users.status.super": "Super admin",
"users.username": "Username",
"users.usernameHelp": "Used with password login",
"users.password": "Password",
"users.invalidLogin": "Invalid username or password",
"users.passwordRepeat": "Repeat password",
"users.passwordEnable": "Enable logging in with password",
"users.passwordEnable": "Enable password login",
"users.passwordMismatch": "Passwords don't match",
"users.apiOneTimeToken": "Copy the API access token now. It will not be shown again.",
"users.cantDelete": "User(s) couldn't be deleted. There has to be at least one 'super' user."
}

View file

@ -3,6 +3,7 @@ package auth
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"fmt"
"io"
@ -49,8 +50,8 @@ type Config struct {
}
type Auth struct {
tokens map[string]struct{}
mut sync.RWMutex
tokens map[string][]byte
sync.RWMutex
cfg oauth2.Config
verifier *oidc.IDTokenVerifier
@ -81,22 +82,25 @@ func New(cfg Config) *Auth {
}
}
// SetTokens remembers a list of string API tokens that are used for authenticating
// API queries.
func (o *Auth) SetTokens(tokens []string) {
o.mut.Lock()
defer o.mut.Unlock()
// SetTokens caches tokens for authenticating API client calls.
func (o *Auth) SetAPITokens(tokens map[string][]byte) {
o.Lock()
defer o.Unlock()
o.tokens = make(map[string]struct{}, len(tokens))
for _, t := range tokens {
o.tokens[t] = struct{}{}
o.tokens = make(map[string][]byte, len(tokens))
for user, token := range tokens {
o.tokens[user] = []byte{}
copy(o.tokens[user], token)
}
}
// CheckToken validates an API token.
func (o *Auth) CheckToken(token string) bool {
_, ok := o.tokens[token]
return ok
// CheckAPIToken validates an API user+token.
func (o *Auth) CheckAPIToken(user string, token []byte) bool {
o.RLock()
t, ok := o.tokens[user]
o.RUnlock()
return ok && subtle.ConstantTimeCompare(t, token) == 1
}
// HandleOIDCCallback is the HTTP handler that handles the post-OIDC provider redirect callback.

View file

@ -4,9 +4,11 @@ import (
"database/sql"
"net/http"
"github.com/knadh/listmonk/internal/utils"
"github.com/knadh/listmonk/models"
"github.com/labstack/echo/v4"
"github.com/lib/pq"
"gopkg.in/volatiletech/null.v6"
)
// GetUsers retrieves all users.
@ -21,10 +23,14 @@ func (c *Core) GetUsers() ([]models.User, error) {
if u.Password.String != "" {
u.HasPassword = true
u.PasswordLogin = true
u.Password.String = ""
u.Password.Valid = false
u.Password = null.String{}
out[n] = u
}
if u.Type == models.UserTypeAPI {
out[n].Email = null.String{}
}
}
return out, nil
@ -50,17 +56,38 @@ func (c *Core) GetUser(id int) (models.User, error) {
// CreateUser creates a new user.
func (c *Core) CreateUser(u models.User) (models.User, error) {
var out models.User
if err := c.q.CreateUser.Get(&out, u.Username, u.PasswordLogin, u.Password, u.Email, u.Name, u.Status); err != nil {
// If it's an API user, generate a random token for password
// and set the e-mail to default.
if u.Type == models.UserTypeAPI {
// Generate a random admin password.
tk, err := utils.GenerateRandomString(32)
if err != nil {
return out, err
}
u.Email = null.String{String: u.Username + "@api", Valid: true}
u.PasswordLogin = false
u.Password = null.String{String: tk, Valid: true}
}
if err := c.q.CreateUser.Get(&out, u.Username, u.PasswordLogin, u.Password, u.Email, u.Name, u.Type, u.Status); err != nil {
return models.User{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.user}", "error", pqErrMsg(err)))
}
// Hide the password field in the response except for when the user type is an API token,
// where the frontend shows the token on the UI just once.
if u.Type != models.UserTypeAPI {
u.Password = null.String{Valid: false}
}
return out, nil
}
// UpdateUser updates a given user.
func (c *Core) UpdateUser(id int, u models.User) (models.User, error) {
res, err := c.q.UpdateUser.Exec(id, u.Username, u.PasswordLogin, u.Password, u.Email, u.Name, u.Status)
res, err := c.q.UpdateUser.Exec(id, u.Username, u.PasswordLogin, u.Password, u.Email, u.Name, u.Type, u.Status)
if err != nil {
return models.User{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}", "error", pqErrMsg(err)))

View file

@ -15,8 +15,12 @@ func V3_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger
CREATE EXTENSION IF NOT EXISTS pgcrypto;
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_type') THEN
CREATE TYPE user_type AS ENUM ('user', 'super', 'api');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_status') THEN
CREATE TYPE user_status AS ENUM ('enabled', 'disabled', 'super');
CREATE TYPE user_status AS ENUM ('enabled', 'disabled');
END IF;
END$$;

34
internal/utils/utils.go Normal file
View file

@ -0,0 +1,34 @@
package utils
import (
"crypto/rand"
"net/mail"
)
// ValidateEmail validates whether the given string is a correctly formed e-mail address.
func ValidateEmail(email string) bool {
// Since `mail.ParseAddress` parses an email address which can also contain an optional name component,
// here we check if incoming email string is same as the parsed email.Address. So this eliminates
// any valid email address with name and also valid address with empty name like `<abc@example.com>`.
em, err := mail.ParseAddress(email)
if err != nil || em.Address != email {
return false
}
return true
}
// GenerateRandomString generates a cryptographically random, alphanumeric string of length n.
func GenerateRandomString(n int) (string, error) {
const dictionary = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
var bytes = make([]byte, n)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
for k, v := range bytes {
bytes[k] = dictionary[v%byte(len(dictionary))]
}
return string(bytes), nil
}

View file

@ -56,8 +56,9 @@ const (
ListOptinDouble = "double"
// User.
UserTypeSuperadmin = "superadmin"
UserTypeSuperadmin = "super"
UserTypeUser = "user"
UserTypeAPI = "api"
UserStatusEnabled = "enabled"
UserStatusDisabled = "disabled"
@ -151,8 +152,9 @@ type User struct {
Username string `db:"username" json:"username"`
Password null.String `db:"password" json:"password,omitempty"`
PasswordLogin bool `db:"password_login" json:"password_login"`
Email string `db:"email" json:"email"`
Email null.String `db:"email" json:"email"`
Name string `db:"name" json:"name"`
Type string `db:"type" json:"type"`
Status string `db:"status" json:"status"`
LoggedInAt null.Time `db:"loggedin_at" json:"loggedin_at"`

View file

@ -108,11 +108,12 @@ type Queries struct {
DeleteBouncesBySubscriber *sqlx.Stmt `query:"delete-bounces-by-subscriber"`
GetDBInfo string `query:"get-db-info"`
CreateUser *sqlx.Stmt `query:"create-user"`
UpdateUser *sqlx.Stmt `query:"update-user"`
DeleteUsers *sqlx.Stmt `query:"delete-users"`
GetUsers *sqlx.Stmt `query:"get-users"`
LoginUser *sqlx.Stmt `query:"login-user"`
CreateUser *sqlx.Stmt `query:"create-user"`
UpdateUser *sqlx.Stmt `query:"update-user"`
DeleteUsers *sqlx.Stmt `query:"delete-users"`
GetUsers *sqlx.Stmt `query:"get-users"`
GetAPITokens *sqlx.Stmt `query:"get-api-tokens"`
LoginUser *sqlx.Stmt `query:"login-user"`
}
// CompileSubscriberQueryTpl takes an arbitrary WHERE expressions

View file

@ -1028,7 +1028,18 @@ SELECT JSON_BUILD_OBJECT('version', (SELECT VERSION()),
'size_mb', (SELECT ROUND(pg_database_size((SELECT CURRENT_DATABASE()))/(1024^2)))) AS info;
-- name: create-user
INSERT INTO users (username, password_login, password, email, name, status) VALUES($1, $2, (CASE WHEN $2 AND $3 != '' THEN CRYPT($3, GEN_SALT('bf')) ELSE NULL END), $4, $5, $6) RETURNING *;
INSERT INTO users (username, password_login, password, email, name, type, status)
VALUES($1, $2, (
CASE
-- For user types with password_login enabled, bcrypt and store the hash of the password.
WHEN $6::user_type != 'api' AND $2 AND $3 != ''
THEN CRYPT($3, GEN_SALT('bf'))
WHEN $6 = 'api'
-- For APIs, store the password (token) as-is.
THEN $3
ELSE NULL
END
), $4, $5, $6, $7) RETURNING *;
-- name: update-user
UPDATE users SET
@ -1037,18 +1048,22 @@ UPDATE users SET
password=(CASE WHEN $3 = TRUE THEN (CASE WHEN $4 != '' THEN CRYPT($4, GEN_SALT('bf')) ELSE password END) ELSE NULL END),
email=(CASE WHEN $5 != '' THEN $5 ELSE email END),
name=(CASE WHEN $6 != '' THEN $6 ELSE name END),
status=(CASE WHEN $7 != '' THEN $7::user_status ELSE status END)
type=(CASE WHEN $7 != '' THEN $7::user_type ELSE type END),
status=(CASE WHEN $8 != '' THEN $8::user_status ELSE status END)
WHERE id=$1;
-- name: delete-users
WITH u AS (
SELECT COUNT(*) AS num FROM users WHERE NOT(id = ANY($1)) AND status='super'
SELECT COUNT(*) AS num FROM users WHERE NOT(id = ANY($1)) AND type='super'
)
DELETE FROM users WHERE id = ALL($1) AND (SELECT num FROM u) > 0;
-- name: get-users
SELECT * FROM users WHERE $1=0 OR id=$1 ORDER BY created_at;
-- name: get-api-tokens
SELECT username, password FROM users WHERE status='enabled' AND type='api';
-- name: login-user
WITH u AS (
SELECT * FROM users WHERE username=$1 AND status != 'disabled' AND password_login = TRUE

View file

@ -7,9 +7,10 @@ DROP TYPE IF EXISTS campaign_type CASCADE; CREATE TYPE campaign_type AS ENUM ('r
DROP TYPE IF EXISTS content_type CASCADE; CREATE TYPE content_type AS ENUM ('richtext', 'html', 'plain', 'markdown');
DROP TYPE IF EXISTS bounce_type CASCADE; CREATE TYPE bounce_type AS ENUM ('soft', 'hard', 'complaint');
DROP TYPE IF EXISTS template_type CASCADE; CREATE TYPE template_type AS ENUM ('campaign', 'tx');
DROP TYPE IF EXISTS user_status CASCADE; CREATE TYPE user_status AS ENUM ('enabled', 'disabled', 'super');
DROP TYPE IF EXISTS user_type CASCADE; CREATE TYPE user_type AS ENUM ('user', 'super', 'api');
DROP TYPE IF EXISTS user_status CASCADE; CREATE TYPE user_status AS ENUM ('enabled', 'disabled');
CREATE EXTENSION pgcrypto;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- subscribers
DROP TABLE IF EXISTS subscribers CASCADE;
@ -308,6 +309,7 @@ CREATE TABLE users (
password TEXT NULL,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
type user_type NOT NULL DEFAULT 'user',
status user_status NOT NULL DEFAULT 'disabled',
loggedin_at TIMESTAMP WITH TIME ZONE NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),