diff --git a/cmd/users.go b/cmd/users.go index 54ddb3c8d..fbcf470df 100644 --- a/cmd/users.go +++ b/cmd/users.go @@ -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) - - 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 u.PasswordLogin { - if !strHasLen(u.Password.String, 8, stdInputMaxLen) { - return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password")) + 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 } + // 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) diff --git a/frontend/fontello/config.json b/frontend/fontello/config.json index 90bcca42a..9cb2d79c2 100755 --- a/frontend/fontello/config.json +++ b/frontend/fontello/config.json @@ -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", diff --git a/frontend/src/assets/icons/fontello.css b/frontend/src/assets/icons/fontello.css index 41a0f96e3..ee4874e2e 100644 --- a/frontend/src/assets/icons/fontello.css +++ b/frontend/src/assets/icons/fontello.css @@ -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' */ diff --git a/frontend/src/assets/icons/fontello.woff2 b/frontend/src/assets/icons/fontello.woff2 index caa51743a..5f729eea5 100755 Binary files a/frontend/src/assets/icons/fontello.woff2 and b/frontend/src/assets/icons/fontello.woff2 differ diff --git a/frontend/src/assets/style.scss b/frontend/src/assets/style.scss index 3fd67bb74..73fb48609 100644 --- a/frontend/src/assets/style.scss +++ b/frontend/src/assets/style.scss @@ -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 */ diff --git a/frontend/src/views/SubscriberForm.vue b/frontend/src/views/SubscriberForm.vue index bb027688b..89c28ff70 100644 --- a/frontend/src/views/SubscriberForm.vue +++ b/frontend/src/views/SubscriberForm.vue @@ -33,7 +33,8 @@
- + diff --git a/frontend/src/views/UserForm.vue b/frontend/src/views/UserForm.vue index 22d9b2ac0..8dea4b882 100644 --- a/frontend/src/views/UserForm.vue +++ b/frontend/src/views/UserForm.vue @@ -13,14 +13,28 @@ @@ -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: { diff --git a/frontend/src/views/Users.vue b/frontend/src/views/Users.vue index fd4a54e41..91055188c 100644 --- a/frontend/src/views/Users.vue +++ b/frontend/src/views/Users.vue @@ -36,35 +36,37 @@
+ + + {{ $t(`users.type.${props.row.type}`) }} + + {{ props.row.username }} +
{{ props.row.name }}
- + {{ $t(`users.status.${props.row.status}`) }} - -
- - {{ props.row.name }} - -
-
- + {{ props.row.email }} +
@@ -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: { diff --git a/i18n/en.json b/i18n/en.json index 2920121dd..27553d40e 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -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." } diff --git a/internal/auth/auth.go b/internal/auth/auth.go index cca8d420d..0cd505977 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -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. diff --git a/internal/core/users.go b/internal/core/users.go index 16e1c5b56..5fd09c979 100644 --- a/internal/core/users.go +++ b/internal/core/users.go @@ -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))) diff --git a/internal/migrations/v3.1.0.go b/internal/migrations/v3.1.0.go index 8b893b0f5..eda08832c 100644 --- a/internal/migrations/v3.1.0.go +++ b/internal/migrations/v3.1.0.go @@ -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$$; diff --git a/internal/utils/utils.go b/internal/utils/utils.go new file mode 100644 index 000000000..a6b5f40cf --- /dev/null +++ b/internal/utils/utils.go @@ -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 ``. + 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 +} diff --git a/models/models.go b/models/models.go index 09547f27b..0ab7e5015 100644 --- a/models/models.go +++ b/models/models.go @@ -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"` diff --git a/models/queries.go b/models/queries.go index 5b4a900fc..90f6343b0 100644 --- a/models/queries.go +++ b/models/queries.go @@ -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 diff --git a/queries.sql b/queries.sql index 22bba4d10..02f1dd79e 100644 --- a/queries.sql +++ b/queries.sql @@ -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 diff --git a/schema.sql b/schema.sql index af29a0d88..d06c7d587 100644 --- a/schema.sql +++ b/schema.sql @@ -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(),