view pkg/controllers/user.go @ 5499:a30b6c6541e0 deactivate-users

Moved logic to delete deactivate users into plpgsql function.
author Sascha L. Teichmann <sascha.teichmann@intevation.de>
date Tue, 21 Sep 2021 22:06:43 +0200
parents 59cbd1b48c3a
children f0c668bc4082
line wrap: on
line source

// This is Free Software under GNU Affero General Public License v >= 3.0
// without warranty, see README.md and license for details.
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// License-Filename: LICENSES/AGPL-3.0.txt
//
// Copyright (C) 2018 by via donau
//   – Österreichische Wasserstraßen-Gesellschaft mbH
// Software engineering by Intevation GmbH
//
// Author(s):
//  * Sascha L. Teichmann <sascha.teichmann@intevation.de>
//  * Tom Gottfried <tom.gottfried@intevation.de>
//  * Sascha Wilde <sascha.wilde@intevation.de>

package controllers

import (
	"bytes"
	"database/sql"
	"fmt"
	"net/http"
	"strconv"
	"strings"
	"text/template"
	"time"

	"github.com/gorilla/mux"

	"gemma.intevation.de/gemma/pkg/auth"
	"gemma.intevation.de/gemma/pkg/log"
	"gemma.intevation.de/gemma/pkg/misc"
	"gemma.intevation.de/gemma/pkg/models"
	"gemma.intevation.de/gemma/pkg/pgxutils"
	"gemma.intevation.de/gemma/pkg/scheduler"

	mw "gemma.intevation.de/gemma/pkg/middleware"
)

const (
	createUserSQL = `INSERT INTO users.list_users
  VALUES ($1, $2, $3, $4, NULL, $5, $6, true)`
	createUserExtentSQL = `INSERT INTO users.list_users
  VALUES ($1, $2, $3, $4,
  ST_MakeBox2D(ST_Point($5, $6), ST_Point($7, $8)), $9, $10, true)`

	updateUserUnprivSQL = `UPDATE users.list_users
  SET (pw, map_extent, email_address)
  = ($2, ST_MakeBox2D(ST_Point($3, $4), ST_Point($5, $6)), $7)
  WHERE username = $1`
	updateUserSQL = `UPDATE users.list_users
  SET (rolname, username, pw, country, map_extent, email_address, report_reciever)
  = ($2, $3, $4, $5, NULL, $6, $7)
  WHERE username = $1`
	updateUserExtentSQL = `UPDATE users.list_users
  SET (rolname, username, pw, country, map_extent, email_address, report_reciever)
  = ($2, $3, $4, $5, ST_MakeBox2D(ST_Point($6, $7), ST_Point($8, $9)), $10, $11)
  WHERE username = $1`

	deleteUserSQL = `SELECT delete_user($1)`

	listUsersSQL = `SELECT
  rolname,
  username,
  country,
  email_address,
  ST_XMin(map_extent), ST_YMin(map_extent),
  ST_XMax(map_extent), ST_YMax(map_extent),
  report_reciever,
  active
FROM users.list_users`

	listUserSQL = `SELECT
  rolname,
  country,
  email_address,
  ST_XMin(map_extent), ST_YMin(map_extent),
  ST_XMax(map_extent), ST_YMax(map_extent),
  report_reciever
FROM users.list_users
WHERE username = $1`
)

var (
	testSysadminNotifyMailTmpl = template.Must(
		template.New("sysadmin").Parse(`Dear {{ .User }},

this is a test email from the Gemma System Errors notification service.  You
recieved this mail, because a System Administrator triggered the test mail
sending function at {{ .Timestamp }}.

When a critical system error is detected an automated mail will be sent to
{{ .Email }} with details on the error condition.`))

	testWWAdminNotifyMailTmpl = template.Must(
		template.New("waterwayadmin").Parse(`Dear {{ .User }},

this is a test email from the Gemma System Mail notification service.  You
recieved this mail, because a System Administrator triggered the test mail
sending function at {{ .Timestamp }}.

When the status of a data import managed by you changes an automated mail will
be sent to {{ .Email }} with details on the new import status
(including import errors) and details on the corresponding import.`))

	testWWUserNotifyMailTmpl = template.Must(
		template.New("waterwayuser").Parse(`Dear {{ .User }},

this is a test email from the Gemma System Mail notification service.  You
recieved this mail, because a System Administrator triggered the test mail
sending function at {{ .Timestamp }}.`))
)

func deleteUser(req *http.Request) (jr mw.JSONResult, err error) {

	user := mux.Vars(req)["user"]
	if !models.UserName(user).IsValid() {
		err = mw.JSONError{
			Code:    http.StatusBadRequest,
			Message: "error: user invalid",
		}
		return
	}

	session, _ := auth.GetSession(req)
	if session.User == user {
		err = mw.JSONError{
			Code:    http.StatusBadRequest,
			Message: "error: cannot delete yourself",
		}
		return
	}

	ctx := req.Context()

	db := mw.JSONConn(req)

	// Remove scheduled tasks.
	ids, err2 := scheduler.ScheduledUserIDs(ctx, db, user)
	if err2 == nil {
		if len(ids) > 0 {
			go func() { scheduler.UnbindByIDs(ids) }()
		}
	} else {
		log.Errorf("%v\n", err2)
	}

	var result int64
	if err = db.QueryRowContext(ctx, deleteUserSQL, user).Scan(&result); err != nil {
		return
	}

	switch result {
	case 0:
		err = mw.JSONError{
			Code:    http.StatusNotFound,
			Message: fmt.Sprintf("Cannot find user %s.", user),
		}
		return
	case 1:
		log.Debugf("user '%s' was deleted.\n", user)
	case 2:
		log.Debugf("user '%s' was deactivated.\n", user)
	}

	// Running in a go routine should not be necessary.
	go func() { auth.Sessions.Logout(user) }()

	jr = mw.JSONResult{Code: http.StatusNoContent}
	return
}

func updateUser(req *http.Request) (jr mw.JSONResult, err error) {

	user := models.UserName(mux.Vars(req)["user"])
	if !user.IsValid() {
		err = mw.JSONError{
			Code:    http.StatusBadRequest,
			Message: "error: user invalid",
		}
		return
	}

	newUser := mw.JSONInput(req).(*models.User)
	var res sql.Result

	db := mw.JSONConn(req)

	if s, _ := auth.GetSession(req); s.Roles.Has("sys_admin") {
		if newUser.Extent == nil {
			res, err = db.ExecContext(
				req.Context(),
				updateUserSQL,
				user,
				newUser.Role,
				newUser.User,
				newUser.Password,
				newUser.Country,
				newUser.Email,
				newUser.Reports,
			)
		} else {
			res, err = db.ExecContext(
				req.Context(),
				updateUserExtentSQL,
				user,
				newUser.Role,
				newUser.User,
				newUser.Password,
				newUser.Country,
				newUser.Extent.X1, newUser.Extent.Y1,
				newUser.Extent.X2, newUser.Extent.Y2,
				newUser.Email,
				newUser.Reports,
			)
		}
	} else {
		if newUser.Extent == nil {
			err = mw.JSONError{
				Code:    http.StatusBadRequest,
				Message: "extent is mandatory",
			}
			return
		}
		res, err = db.ExecContext(
			req.Context(),
			updateUserUnprivSQL,
			user,
			newUser.Password,
			newUser.Extent.X1, newUser.Extent.Y1,
			newUser.Extent.X2, newUser.Extent.Y2,
			newUser.Email,
		)
	}

	if err != nil {
		return
	}

	if n, err2 := res.RowsAffected(); err2 == nil && n == 0 {
		err = mw.JSONError{
			Code:    http.StatusNotFound,
			Message: fmt.Sprintf("Cannot find user %s.", user),
		}
		return
	}

	if user != newUser.User {
		// Running in a go routine should not be necessary.
		go func() { auth.Sessions.Logout(string(user)) }()
	}

	jr = mw.JSONResult{
		Code: http.StatusCreated,
		Result: struct {
			Result string `json:"result"`
		}{"success"},
	}
	return
}

func patchUser(req *http.Request) (jr mw.JSONResult, err error) {

	user := models.UserName(mux.Vars(req)["user"])
	if !user.IsValid() {
		err = mw.JSONError{
			Code:    http.StatusBadRequest,
			Message: "error: user invalid",
		}
		return
	}

	s, ok := auth.GetSession(req)
	if !ok {
		err = mw.JSONError{
			Code:    http.StatusUnauthorized,
			Message: "error: not logged in",
		}
		return
	}

	priv := s.Roles.Has("sys_admin")

	if !priv && s.User != string(user) {
		err = mw.JSONError{
			Code:    http.StatusUnauthorized,
			Message: "error: not allowed to modify someone else",
		}
		return
	}

	var (
		columns   []string
		positions []string
		args      []interface{}
	)

	update := func(column string, value interface{}) {
		columns = append(columns, column)
		positions = append(positions, "$"+strconv.Itoa(len(positions)+1))
		args = append(args, value)
	}

	updateBox := func(column string, extent *models.BoundingBox) {
		columns = append(columns, column)
		pos := len(positions)
		position := fmt.Sprintf("ST_MakeBox2D(ST_Point($%d, $%d), ST_Point($%d, $%d))",
			pos+1, pos+2, pos+3, pos+4)
		positions = append(positions, position)
		args = append(args, extent.X1, extent.Y1, extent.X2, extent.Y2)
	}

	patch := mw.JSONInput(req).(*models.UserPatch)

	if patch.User != nil && priv {
		update("user", *patch.User)
	}
	if patch.Role != nil && priv {
		update("rolname", *patch.Role)
	}
	if patch.Password != nil {
		update("pw", *patch.Password)
	}
	if patch.Email != nil {
		update("email_address", *patch.Email)
	}
	if patch.Country != nil && priv {
		update("country", *patch.Country)
	}
	if patch.Reports != nil && priv {
		update("report_reciever", *patch.Reports)
	}
	if patch.Extent != nil {
		updateBox("map_extent", patch.Extent)
	}

	var colsS, posS string

	switch len(columns) {
	case 0: // Nothing to do
		jr = mw.JSONResult{
			Code: http.StatusCreated,
			Result: struct {
				Result string `json:"result"`
			}{"success"},
		}
		return
	case 1: // No brackets if there is only one argument.
		colsS = columns[0]
		posS = positions[0]
	default:
		colsS = "(" + strings.Join(columns, ",") + ")"
		posS = "(" + strings.Join(positions, ",") + ")"
	}

	stmt := fmt.Sprintf(
		`UPDATE users.list_users SET %s = %s WHERE username = $%d`,
		colsS,
		posS,
		len(positions)+1)

	args = append(args, user)

	db := mw.JSONConn(req)

	var res sql.Result
	if res, err = db.ExecContext(req.Context(), stmt, args...); err != nil {
		return
	}

	if n, err2 := res.RowsAffected(); err2 == nil && n == 0 {
		err = mw.JSONError{
			Code:    http.StatusNotFound,
			Message: fmt.Sprintf("Cannot find user %s.", user),
		}
		return
	}

	if patch.User != nil && *patch.User != user {
		// Running in a go routine should not be necessary.
		go func() { auth.Sessions.Logout(string(user)) }()
	}

	jr = mw.JSONResult{
		Code: http.StatusCreated,
		Result: struct {
			Result string `json:"result"`
		}{"success"},
	}
	return
}

func createUser(req *http.Request) (jr mw.JSONResult, err error) {

	user := mw.JSONInput(req).(*models.User)

	db := mw.JSONConn(req)

	if user.Extent == nil {
		_, err = db.ExecContext(
			req.Context(),
			createUserSQL,
			user.Role,
			user.User,
			user.Password,
			user.Country,
			user.Email,
			user.Reports,
		)
	} else {
		_, err = db.ExecContext(
			req.Context(),
			createUserExtentSQL,
			user.Role,
			user.User,
			user.Password,
			user.Country,
			user.Extent.X1, user.Extent.Y1,
			user.Extent.X2, user.Extent.Y2,
			user.Email,
			user.Reports,
		)
	}

	if err != nil {
		m, c := pgxutils.ReadableError{Err: err}.MessageAndCode()
		err = mw.JSONError{Code: c, Message: m}
		return
	}

	jr = mw.JSONResult{
		Code: http.StatusCreated,
		Result: struct {
			Result string `json:"result"`
		}{"success"},
	}
	return
}

func listUsers(req *http.Request) (jr mw.JSONResult, err error) {

	var rows *sql.Rows

	rows, err = mw.JSONConn(req).QueryContext(req.Context(), listUsersSQL)
	if err != nil {
		return
	}
	defer rows.Close()

	var users []*models.User

	for rows.Next() {
		user := &models.User{Extent: &models.BoundingBox{}}
		if err = rows.Scan(
			&user.Role,
			&user.User,
			&user.Country,
			&user.Email,
			&user.Extent.X1, &user.Extent.Y1,
			&user.Extent.X2, &user.Extent.Y2,
			&user.Reports,
			&user.Active,
		); err != nil {
			return
		}
		users = append(users, user)
	}

	jr = mw.JSONResult{
		Result: struct {
			Users []*models.User `json:"users"`
		}{users},
	}
	return
}

func listUser(req *http.Request) (jr mw.JSONResult, err error) {

	user := models.UserName(mux.Vars(req)["user"])
	if !user.IsValid() {
		err = mw.JSONError{
			Code:    http.StatusBadRequest,
			Message: "error: user invalid",
		}
		return
	}

	result := &models.User{
		User:   user,
		Extent: &models.BoundingBox{},
	}

	err = mw.JSONConn(req).QueryRowContext(req.Context(), listUserSQL, user).Scan(
		&result.Role,
		&result.Country,
		&result.Email,
		&result.Extent.X1, &result.Extent.Y1,
		&result.Extent.X2, &result.Extent.Y2,
		&result.Reports,
	)

	switch {
	case err == sql.ErrNoRows:
		err = mw.JSONError{
			Code:    http.StatusNotFound,
			Message: fmt.Sprintf("Cannot find user %s.", user),
		}
		return
	case err != nil:
		return
	}

	jr.Result = result
	return
}

func sendTestMail(req *http.Request) (jr mw.JSONResult, err error) {

	user := models.UserName(mux.Vars(req)["user"])
	if !user.IsValid() {
		err = mw.JSONError{
			Code:    http.StatusBadRequest,
			Message: "error: user invalid",
		}
		return
	}

	userData := &models.User{
		User:   user,
		Extent: &models.BoundingBox{},
	}

	err = mw.JSONConn(req).QueryRowContext(req.Context(), listUserSQL, user).Scan(
		&userData.Role,
		&userData.Country,
		&userData.Email,
		&userData.Extent.X1, &userData.Extent.Y1,
		&userData.Extent.X2, &userData.Extent.Y2,
		&userData.Reports,
	)

	switch {
	case err == sql.ErrNoRows:
		err = mw.JSONError{
			Code:    http.StatusNotFound,
			Message: fmt.Sprintf("Cannot find user %s.", user),
		}
		return
	case err != nil:
		return
	}

	var subject string

	var tmplVars = struct {
		User      string
		Timestamp string
		Email     string
	}{
		User:      string(user),
		Timestamp: time.Now().Format("2006-01-02 15:04:05"),
		Email:     string(userData.Email),
	}

	var bodyTmpl *template.Template
	switch userData.Role {
	case "sys_admin":
		subject = "Gemma: Sysadmin Notification TEST"
		bodyTmpl = testSysadminNotifyMailTmpl
	case "waterway_admin":
		subject = "Gemma: Waterway Admin Notification TEST"
		bodyTmpl = testWWAdminNotifyMailTmpl
	default:
		subject = "Gemma: Waterway User Notification TEST"
		bodyTmpl = testWWUserNotifyMailTmpl
	}

	var buf bytes.Buffer
	if err := bodyTmpl.Execute(&buf, &tmplVars); err != nil {
		log.Errorf("%v\n", err)
	}

	err = misc.SendMail(string(userData.Email), subject, buf.String())
	if err != nil {
		return
	}

	jr.Result = &struct {
		Message string `json:"message"`
	}{"Sending test mail."}

	return
}