changeset 3982:e0751da6272c diagram-cleanup

merge with default
author Thomas Junk <thomas.junk@intevation.de>
date Wed, 17 Jul 2019 13:00:19 +0200
parents ea4e1ea04e44 (current diff) 5396581cf203 (diff)
children ca448aff3a17
files schema/updates_3.0.1/01.dismar-wwname.sql schema/updates_3.0.1/02.search_functions.sql
diffstat 27 files changed, 745 insertions(+), 292 deletions(-) [+]
line wrap: on
line diff
--- a/client/package.json	Fri Jul 12 16:18:49 2019 +0200
+++ b/client/package.json	Wed Jul 17 13:00:19 2019 +0200
@@ -1,6 +1,6 @@
 {
   "name": "gemmajs",
-  "version": "3.1.0",
+  "version": "4.0.0-dev",
   "license": "AGPL-3.0-or-later",
   "repository": {
     "type": "hg",
--- a/client/src/components/Pdftool.vue	Fri Jul 12 16:18:49 2019 +0200
+++ b/client/src/components/Pdftool.vue	Wed Jul 17 13:00:19 2019 +0200
@@ -209,6 +209,12 @@
     close() {
       this.$store.commit("application/showPdfTool", false);
     },
+    millimeter2pixels(length, dpi) {
+      return (dpi * length) / 25.4;
+    },
+    pixel2millimeter(pixels, dpi) {
+      return (pixels * 25.4) / dpi;
+    },
     // When a template is chosen from the dropdown, its propoerties are
     // applied to the rest of the form.
     applyTemplateToForm() {
@@ -309,144 +315,186 @@
           1000 * pixelsPerMapMillimeter * metersPerPixel
         );
         console.log("scaleDenominator = ", scaleDenominator);
-
-        var data = canvas.toDataURL("image/jpeg");
-        this.pdf.doc.addImage(
-          data,
-          "JPEG",
-          0,
-          0,
+        const width80DPI = this.millimeter2pixels(this.pdf.width, 80);
+        const height80DPI = this.millimeter2pixels(this.pdf.height, 80);
+        const currentHeight = this.millimeter2pixels(
+          this.pdf.height,
+          this.form.resolution
+        );
+        const currentWidth = this.millimeter2pixels(
           this.pdf.width,
-          this.pdf.height
+          this.form.resolution
         );
+        //const scaleFactor = this.form.resolution / 80;
+        var snapshot = canvas.toDataURL("image/jpeg");
+        const offscreen = document.createElement("canvas");
+        offscreen.width = currentWidth;
+        offscreen.height = currentHeight;
+        const image = new Image();
+        image.src = snapshot;
+        image.onload = () => {
+          offscreen
+            .getContext("2d")
+            .drawImage(
+              image,
+              (currentWidth - width80DPI) / 2,
+              (currentHeight - height80DPI) / 2,
+              width80DPI,
+              height80DPI,
+              0,
+              0,
+              currentWidth,
+              currentHeight
+            );
+          const data = offscreen.toDataURL("image/jpeg");
+          this.pdf.doc.addImage(
+            data,
+            "JPEG",
+            0,
+            0,
+            this.pdf.width,
+            this.pdf.height
+          );
 
-        if (this.templateData) {
-          this.pdf.doc.setFont("linbiolinum", "normal");
-          let defaultFontSize = 11,
-            defaultRounding = 2,
-            defaultTextColor = "black",
-            defaultBgColor = "white",
-            defaultPadding = 3,
-            defaultOffset = { x: 0, y: 0 },
-            defaultBorderColor = "white";
-          this.templateData.elements.forEach(e => {
-            switch (e.type) {
-              case "text": {
-                this.addText(
-                  e.position,
-                  e.offset || defaultOffset,
-                  e.width,
-                  e.fontSize || defaultFontSize,
-                  e.color || defaultTextColor,
-                  e.text
-                );
-                break;
-              }
-              case "box": {
-                this.addBox(
-                  e.position,
-                  e.offset || defaultOffset,
-                  e.width,
-                  e.height,
-                  // handling the case when the rectangle  not rounded (rounding = 0)
-                  e.rounding === 0 || e.rounding ? e.rounding : defaultRounding,
-                  e.color || defaultBgColor,
-                  e.brcolor || defaultBorderColor
-                );
-                break;
+          if (this.templateData) {
+            this.pdf.doc.setFont("linbiolinum", "normal");
+            let defaultFontSize = 11,
+              defaultRounding = 2,
+              defaultTextColor = "black",
+              defaultBgColor = "white",
+              defaultPadding = 3,
+              defaultOffset = { x: 0, y: 0 },
+              defaultBorderColor = "white",
+              defaultWidth = 100;
+            this.templateData.elements.forEach(e => {
+              switch (e.type) {
+                case "text": {
+                  this.addText(
+                    e.position,
+                    e.offset || defaultOffset,
+                    e.width || defaultWidth,
+                    e.fontSize || defaultFontSize,
+                    e.color || defaultTextColor,
+                    e.text
+                  );
+                  break;
+                }
+                case "box": {
+                  this.addBox(
+                    e.position,
+                    e.offset || defaultOffset,
+                    e.width,
+                    e.height,
+                    // handling the case when the rectangle  not rounded (rounding = 0)
+                    e.rounding === 0 || e.rounding
+                      ? e.rounding
+                      : defaultRounding,
+                    e.color || defaultBgColor,
+                    e.brcolor || defaultBorderColor
+                  );
+                  break;
+                }
+                case "textbox": {
+                  this.addTextBox(
+                    e.position,
+                    e.offset || defaultOffset,
+                    e.width,
+                    e.height,
+                    e.rounding === 0 || e.rounding
+                      ? e.rounding
+                      : defaultRounding,
+                    e.padding || defaultPadding,
+                    e.fontSize || defaultFontSize,
+                    e.color || defaultTextColor,
+                    e.background || defaultBgColor,
+                    e.text,
+                    e.brcolor || defaultBorderColor
+                  );
+                  break;
+                }
+                case "image": {
+                  this.addImage(
+                    e.url,
+                    e.format,
+                    e.position,
+                    e.offset || defaultOffset,
+                    e.width,
+                    e.height
+                  );
+                  break;
+                }
+                case "bottleneck": {
+                  this.addBottleneckInfo(
+                    e.position,
+                    e.offset || defaultOffset,
+                    e.rounding === 0 || e.rounding
+                      ? e.rounding
+                      : defaultRounding,
+                    e.color || defaultTextColor,
+                    e.brcolor || defaultBorderColor
+                  );
+                  break;
+                }
+                case "legend": {
+                  this.addLegend(
+                    e.position,
+                    e.offset || defaultOffset,
+                    e.rounding === 0 || e.rounding
+                      ? e.rounding
+                      : defaultRounding,
+                    e.brcolor || defaultBorderColor
+                  );
+                  break;
+                }
+                case "scalebar": {
+                  this.addScaleBar(
+                    scaleDenominator,
+                    e.position,
+                    e.offset || defaultOffset,
+                    e.rounding === 0 || e.rounding
+                      ? e.rounding
+                      : defaultRounding,
+                    e.brcolor || defaultBorderColor
+                  );
+                  break;
+                }
+                case "scale": {
+                  this.addScale(
+                    scaleDenominator,
+                    e.position,
+                    e.width,
+                    e.offset || defaultOffset,
+                    e.fontSize || defaultFontSize,
+                    e.color || defaultTextColor
+                  );
+                  break;
+                }
+                case "northarrow": {
+                  this.addNorthArrow(
+                    e.position,
+                    e.offset || defaultOffset,
+                    e.size
+                  );
+                  break;
+                }
               }
-              case "textbox": {
-                this.addTextBox(
-                  e.position,
-                  e.offset || defaultOffset,
-                  e.width,
-                  e.height,
-                  e.rounding === 0 || e.rounding ? e.rounding : defaultRounding,
-                  e.padding || defaultPadding,
-                  e.fontSize || defaultFontSize,
-                  e.color || defaultTextColor,
-                  e.background || defaultBgColor,
-                  e.text,
-                  e.brcolor || defaultBorderColor
-                );
-                break;
-              }
-              case "image": {
-                this.addImage(
-                  e.url,
-                  e.format,
-                  e.position,
-                  e.offset || defaultOffset,
-                  e.width,
-                  e.height
-                );
-                break;
-              }
-              case "bottleneck": {
-                this.addBottleneckInfo(
-                  e.position,
-                  e.offset || defaultOffset,
-                  e.rounding === 0 || e.rounding ? e.rounding : defaultRounding,
-                  e.color || defaultTextColor,
-                  e.brcolor || defaultBorderColor
-                );
-                break;
-              }
-              case "legend": {
-                this.addLegend(
-                  e.position,
-                  e.offset || defaultOffset,
-                  e.rounding === 0 || e.rounding ? e.rounding : defaultRounding,
-                  e.brcolor || defaultBorderColor
-                );
-                break;
-              }
-              case "scalebar": {
-                this.addScaleBar(
-                  scaleDenominator,
-                  e.position,
-                  e.offset || defaultOffset,
-                  e.rounding === 0 || e.rounding ? e.rounding : defaultRounding,
-                  e.brcolor || defaultBorderColor
-                );
-                break;
-              }
-              case "scale": {
-                this.addScale(
-                  scaleDenominator,
-                  e.position,
-                  e.width,
-                  e.offset || defaultOffset,
-                  e.fontSize || defaultFontSize,
-                  e.color || defaultTextColor
-                );
-                break;
-              }
-              case "northarrow": {
-                this.addNorthArrow(
-                  e.position,
-                  e.offset || defaultOffset,
-                  e.size
-                );
-                break;
-              }
-            }
+            });
+
+            this.pdf.doc.save(this.filename);
+          }
+
+          // reset to original size
+          map.setSize(this.mapSize);
+          map.getView().fit(this.mapExtent, {
+            size: this.mapSize,
+            // necessary to get to the previous zoom level in all cases
+            // details see https://github.com/openlayers/openlayers/issues/9235
+            constrainResolution: false
           });
 
-          this.pdf.doc.save(this.filename);
-        }
-
-        // reset to original size
-        map.setSize(this.mapSize);
-        map.getView().fit(this.mapExtent, {
-          size: this.mapSize,
-          // necessary to get to the previous zoom level in all cases
-          // details see https://github.com/openlayers/openlayers/issues/9235
-          constrainResolution: false
-        });
-
-        // as we are done: re-enable button
-        this.readyToGenerate = true;
+          // as we are done: re-enable button
+          this.readyToGenerate = true;
+        };
       });
 
       // trigger rendering
--- a/client/src/components/importconfiguration/types/Soundingresults.vue	Fri Jul 12 16:18:49 2019 +0200
+++ b/client/src/components/importconfiguration/types/Soundingresults.vue	Wed Jul 17 13:00:19 2019 +0200
@@ -106,6 +106,21 @@
             </small>
           </span>
         </div>
+        <div class="ml-3 text-left d-flex flex-column">
+          <div class="text-left">
+            <small class="text-muted">
+              <translate>Negate Z values in XYZ files</translate>
+            </small>
+          </div>
+          <div class="ml-4 mt-2 text-left">
+            <input
+              id="negatez"
+              type="checkbox"
+              class="form-check-input"
+              v-model="negateZ"
+            />
+          </div>
+        </div>
       </div>
     </div>
     <div class="mt-2">
@@ -219,7 +234,8 @@
       disableUpload: false,
       token: null,
       messages: [],
-      eMailNotification: false
+      eMailNotification: false,
+      negateZ: false
     };
   },
   methods: {
@@ -280,6 +296,7 @@
             const { bottleneck, date, epsg } = response.data.meta;
             const depthReference = response.data.meta["depth-reference"];
             const singlebeam = response.data.meta["single-beam"];
+            this.negateZ = response.data.meta["negate-z"];
             this.bottleneck = this.bottlenecks.find(
               bn => bn.properties.objnam === bottleneck
             );
@@ -318,7 +335,7 @@
           "single-beam",
           this.beamType === this.$options.BEAMTYPES.SINGLEBEAM
         );
-
+      formData.append("negate-z", this.negateZ == true);
       HTTP.post("/imports/sr", formData, {
         headers: {
           "X-Gemma-Auth": localStorage.getItem("token"),
@@ -432,7 +449,8 @@
               date: this.importDate,
               "single-beam":
                 this.beamType === this.$options.BEAMTYPES.SINGLEBEAM,
-              epsg: Number(this.projection)
+              epsg: Number(this.projection),
+              "negate-z": this.negateZ == true
             })
           )
         );
--- a/cmd/gemma/main.go	Fri Jul 12 16:18:49 2019 +0200
+++ b/cmd/gemma/main.go	Wed Jul 17 13:00:19 2019 +0200
@@ -91,7 +91,7 @@
 		done <- server.ListenAndServe()
 	}()
 
-	sigChan := make(chan os.Signal)
+	sigChan := make(chan os.Signal, 1)
 	signal.Notify(sigChan, os.Interrupt, os.Kill, syscall.SIGTERM)
 
 	select {
--- a/pkg/controllers/bottlenecks.go	Fri Jul 12 16:18:49 2019 +0200
+++ b/pkg/controllers/bottlenecks.go	Wed Jul 17 13:00:19 2019 +0200
@@ -519,7 +519,6 @@
 		// Too late for HTTP status message.
 		log.Printf("error: %v\n", err)
 	}
-	return
 }
 
 func bottleneckAvailableFairwayDepth(rw http.ResponseWriter, req *http.Request) {
--- a/pkg/controllers/diff.go	Fri Jul 12 16:18:49 2019 +0200
+++ b/pkg/controllers/diff.go	Wed Jul 17 13:00:19 2019 +0200
@@ -268,7 +268,7 @@
 			tree.Min.Z, tree.Max.Z, contourStep)
 	} else {
 		heights = octree.ExtrapolateClassBreaks(heights, tree.Min.Z, tree.Max.Z)
-		heights = octree.InBetweenClassBreaks(heights, 0.05, 2)
+		// heights = octree.InBetweenClassBreaks(heights, 0.05, 2)
 	}
 
 	log.Printf("info: num heights: %d\n", len(heights))
--- a/pkg/controllers/importconfig.go	Fri Jul 12 16:18:49 2019 +0200
+++ b/pkg/controllers/importconfig.go	Wed Jul 17 13:00:19 2019 +0200
@@ -105,7 +105,7 @@
 	oldPasswd, ok := pc.Attributes["password"]
 	pc.User = session.User
 	pc.Attributes = common.Attributes{}
-	if ok == true {
+	if ok {
 		pc.Attributes["password"] = oldPasswd
 	}
 	pc.Attributes.Marshal(config) // Marshal only overwrites keys present in config
--- a/pkg/controllers/pwreset.go	Fri Jul 12 16:18:49 2019 +0200
+++ b/pkg/controllers/pwreset.go	Wed Jul 17 13:00:19 2019 +0200
@@ -21,14 +21,17 @@
 	"database/sql"
 	"encoding/hex"
 	"errors"
+	"io"
 	"log"
 	"net/http"
 	"os/exec"
 	"strconv"
 	"strings"
-	"text/template"
 	"time"
 
+	htmlTemplate "html/template"
+	textTemplate "text/template"
+
 	"github.com/gorilla/mux"
 
 	"gemma.intevation.de/gemma/pkg/auth"
@@ -40,17 +43,15 @@
 
 const (
 	insertRequestSQL = `INSERT INTO sys_admin.password_reset_requests
-    (hash, username) VALUES ($1, $2)`
+    (hash, username) VALUES ($1, $2)
+	ON CONFLICT (username) DO UPDATE SET hash = $1`
 
 	countRequestsSQL = `SELECT count(*) FROM sys_admin.password_reset_requests`
 
-	countRequestsUserSQL = `SELECT count(*) FROM sys_admin.password_reset_requests
-    WHERE username = $1`
-
 	deleteRequestSQL = `DELETE FROM sys_admin.password_reset_requests
     WHERE hash = $1`
 
-	findRequestSQL = `SELECT lu.email_address, lu.username
+	findRequestSQL = `SELECT lu.username
     FROM sys_admin.password_reset_requests prr
     JOIN users.list_users lu on prr.username = lu.username
     WHERE prr.hash = $1`
@@ -63,6 +64,10 @@
 
 	updatePasswordSQL = `UPDATE users.list_users
     SET pw = $1 WHERE username = $2`
+
+	deletePasswordResetRequestSQL = `
+    DELETE FROM sys_admin.password_reset_requests
+    WHERE username = $1`
 )
 
 const (
@@ -77,41 +82,44 @@
 const pwResetRole = "sys_admin"
 
 var (
-	errTooMuchPasswordResets        = errors.New("Too many password resets")
-	errTooMuchPasswordResetsPerUser = errors.New("Too many password resets per user")
-	errNoSuchUser                   = errors.New("User does not exist")
-	errInvalidUser                  = errors.New("Invalid user")
+	errTooMuchPasswordResets = errors.New("too many password resets")
+	errNoSuchUser            = errors.New("user does not exist")
+	errInvalidUser           = errors.New("invalid user")
 )
 
 var (
-	passwordResetRequestMailTmpl = template.Must(
-		template.New("request").Parse(`You or someone else has requested a password change
+	passwordResetRequestMailTmpl = textTemplate.Must(
+		textTemplate.New("request").Parse(`You or someone else has requested a password change
 for your account {{ .User }} on
-{{ .HTTPS }}://{{ .Server }}
+{{ .Server }}
 
-Please follow this link to have a new password generated and mailed to you:
+Please follow this link to have a new password generated:
 
-{{ .HTTPS }}://{{ .Server }}/api/users/passwordreset/{{ .Hash }}
+{{ .Server }}/api/users/passwordreset/{{ .Hash }}
 
 The link is only valid for 12 hours.
 
 If you did not initiate this password reset or do not want to reset the
-password, just ignore this email.
+password, just ignore this email. Logging in with your old password
+before following the link will cancel this password reset request, too.
 
 Best regards
     Your service team`))
 
-	passwordResetMailTmpl = template.Must(
-		template.New("reset").Parse(`Your password for your account {{ .User }} on
-{{ .HTTPS }}://{{ .Server }}
-
-has been changed to
-    {{ .Password }}
-
-Change it as soon as possible.
-
-Best regards
-    Your service team`))
+	passwordResetPage = htmlTemplate.Must(
+		htmlTemplate.New("page").Parse(`<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8" />
+    <title>Password reset done</title>
+  </head>
+  <body>
+    <p>The password reset for user <strong><tt>{{ .User }}</tt></strong> successfully done.</p>
+    <p>New password: <strong><tt>{{ .Password }}</tt></strong></p>
+    <p><a href="/">Go to login page.</a></p>
+  </body>
+</html>
+`))
 )
 
 func init() {
@@ -137,15 +145,13 @@
 	}
 }
 
-func requestMessageBody(https, user, hash, server string) string {
+func requestMessageBody(user, hash, server string) string {
 	var content = struct {
 		User   string
-		HTTPS  string
 		Server string
 		Hash   string
 	}{
 		User:   user,
-		HTTPS:  https,
 		Server: server,
 		Hash:   hash,
 	}
@@ -156,31 +162,15 @@
 	return buf.String()
 }
 
-func changedMessageBody(https, user, password, server string) string {
+func changedMessageBody(w io.Writer, user, password string) error {
 	var content = struct {
 		User     string
-		HTTPS    string
-		Server   string
 		Password string
 	}{
 		User:     user,
-		HTTPS:    https,
-		Server:   server,
 		Password: password,
 	}
-	var buf bytes.Buffer
-	if err := passwordResetMailTmpl.Execute(&buf, &content); err != nil {
-		log.Printf("error: %v\n", err)
-	}
-	return buf.String()
-}
-
-func useHTTPS(req *http.Request) string {
-	if req.Header.Get("X-Use-Protocol") == "https" ||
-		req.URL.Scheme == "https" {
-		return "https"
-	}
-	return "http"
+	return passwordResetPage.Execute(w, &content)
 }
 
 func generateHash() string {
@@ -198,7 +188,7 @@
 	return common.RandomString(passwordLength)
 }
 
-func backgroundRequest(https, host string, user *models.PWResetUser) error {
+func backgroundRequest(host string, user *models.PWResetUser) error {
 
 	if user.User == "" {
 		return errInvalidUser
@@ -232,16 +222,6 @@
 				return err
 			}
 
-			if err := conn.QueryRowContext(
-				ctx, countRequestsUserSQL, user.User).Scan(&count); err != nil {
-				return err
-			}
-
-			// Limit requests per user
-			if count >= maxPasswordRequestsPerUser {
-				return errTooMuchPasswordResetsPerUser
-			}
-
 			hash = generateHash()
 			_, err = conn.ExecContext(ctx, insertRequestSQL, hash, user.User)
 			return err
@@ -250,20 +230,11 @@
 		return err
 	}
 
-	body := requestMessageBody(https, user.User, hash, host)
+	body := requestMessageBody(user.User, hash, host)
 
 	return misc.SendMail(email, "Password Reset Link", body)
 }
 
-// host checks if we are behind a proxy and returns the name
-// of the up-front server.
-func host(req *http.Request) string {
-	if fwd := req.Header.Get("X-Forwarded-Host"); fwd != "" {
-		return fwd
-	}
-	return req.Host
-}
-
 func passwordResetRequest(
 	input interface{},
 	req *http.Request,
@@ -272,11 +243,13 @@
 
 	// We do the checks and the emailing in background
 	// no reduce the risks of timing attacks.
-	go func(https, host string, user *models.PWResetUser) {
-		if err := backgroundRequest(https, host, user); err != nil {
+	go func(user *models.PWResetUser) {
+		config.WaitReady()
+		host := config.ExternalURL()
+		if err := backgroundRequest(host, user); err != nil {
 			log.Printf("error: %v\n", err)
 		}
-	}(useHTTPS(req), host(req), input.(*models.PWResetUser))
+	}(input.(*models.PWResetUser))
 
 	// Send a neutral message to avoid being an user oracle.
 	const neutralMessage = "If this account exists, a reset link will be mailed."
@@ -296,42 +269,65 @@
 		return
 	}
 
-	var email, user, password string
+	var user, password string
 
 	ctx := req.Context()
 
-	if err := auth.RunAs(
-		ctx, pwResetRole, func(conn *sql.Conn) error {
-			err := conn.QueryRowContext(ctx, findRequestSQL, hash).Scan(&email, &user)
+	err := auth.RunAs(
+		ctx, pwResetRole,
+		func(conn *sql.Conn) error {
+			tx, err := conn.BeginTx(ctx, nil)
+			if err != nil {
+				return err
+			}
+			defer tx.Rollback()
+
+			err = tx.QueryRowContext(ctx, findRequestSQL, hash).Scan(&user)
 			switch {
 			case err == sql.ErrNoRows:
-				return JSONError{http.StatusNotFound, "No such hash"}
+				return errors.New("this URL is no longer valid")
 			case err != nil:
 				return err
 			}
 			password = generateNewPassword()
-			res, err := conn.ExecContext(ctx, updatePasswordSQL, password, user)
+			res, err := tx.ExecContext(ctx, updatePasswordSQL, password, user)
 			if err != nil {
 				return err
 			}
 			if n, err2 := res.RowsAffected(); err2 == nil && n == 0 {
-				return JSONError{http.StatusNotFound, "User not found"}
+				return errors.New("user not found")
+			}
+			if _, err = tx.ExecContext(ctx, deleteRequestSQL, hash); err != nil {
+				return err
 			}
-			_, err = conn.ExecContext(ctx, deleteRequestSQL, hash)
-			return err
-		}); err == nil {
-		https := useHTTPS(req)
-		server := host(req)
-		body := changedMessageBody(https, user, password, server)
-		if err = misc.SendMail(email, "Password Reset Done", body); err != nil {
-			log.Printf("error: %v\n", err)
-			http.Error(
-				rw,
-				http.StatusText(http.StatusInternalServerError),
-				http.StatusInternalServerError)
-			return
-		}
-		var url = https + "://" + server
-		http.Redirect(rw, req, url, http.StatusSeeOther)
+			return tx.Commit()
+		},
+	)
+
+	switch {
+	case err == sql.ErrNoRows:
+		http.Error(rw, "No such request", http.StatusNotFound)
+		return
+	case err != nil:
+		http.Error(rw, "Error: "+err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	if err := changedMessageBody(rw, user, password); err != nil {
+		log.Printf("error: %v\n", err)
 	}
 }
+
+func deletePasswordResetRequest(user string) {
+	ctx := context.Background()
+	if err := auth.RunAs(
+		ctx,
+		pwResetRole,
+		func(conn *sql.Conn) error {
+			_, err := conn.ExecContext(ctx, deletePasswordResetRequestSQL, user)
+			return err
+		},
+	); err != nil {
+		log.Printf("error: %v\n", err)
+	}
+}
--- a/pkg/controllers/search.go	Fri Jul 12 16:18:49 2019 +0200
+++ b/pkg/controllers/search.go	Wed Jul 17 13:00:19 2019 +0200
@@ -17,7 +17,6 @@
 import (
 	"database/sql"
 	"net/http"
-	"regexp"
 	"strings"
 
 	"gemma.intevation.de/gemma/pkg/models"
@@ -39,10 +38,6 @@
 `
 )
 
-var rkmRegex = regexp.MustCompile(
-	"^[[:space:]]*([0-9]+)([,.]([0-9]))?[[:space:]]*$",
-)
-
 func searchFeature(
 	input interface{},
 	req *http.Request,
--- a/pkg/controllers/srimports.go	Fri Jul 12 16:18:49 2019 +0200
+++ b/pkg/controllers/srimports.go	Wed Jul 17 13:00:19 2019 +0200
@@ -97,6 +97,19 @@
 		sr.Bottleneck = &v
 	}
 
+	if v := req.FormValue("negate-z"); v != "" {
+		var negateZ bool
+		switch strings.ToLower(v) {
+		case "true", "1":
+			negateZ = true
+		case "false", "0":
+			negateZ = false
+		default:
+			return fmt.Errorf("unknown negate-z '%s'", v)
+		}
+		sr.NegateZ = &negateZ
+	}
+
 	if v := req.FormValue("single-beam"); v != "" {
 		var singleBeam bool
 		switch strings.ToLower(v) {
@@ -105,7 +118,7 @@
 		case "false", "0", "multibeam", "multi-beam":
 			singleBeam = false
 		default:
-			return fmt.Errorf("Unknown single-beam '%s'", v)
+			return fmt.Errorf("unknown single-beam '%s'", v)
 		}
 		sr.SingleBeam = &singleBeam
 	}
--- a/pkg/controllers/token.go	Fri Jul 12 16:18:49 2019 +0200
+++ b/pkg/controllers/token.go	Wed Jul 17 13:00:19 2019 +0200
@@ -102,5 +102,7 @@
 		Roles:   session.Roles,
 	}
 
+	go deletePasswordResetRequest(session.User)
+
 	SendJSON(rw, http.StatusCreated, &result)
 }
--- a/pkg/imports/sr.go	Fri Jul 12 16:18:49 2019 +0200
+++ b/pkg/imports/sr.go	Wed Jul 17 13:00:19 2019 +0200
@@ -60,6 +60,9 @@
 	DepthReference *string `json:"depth-reference,omitempty"`
 	// SingleBeam indicates that the sounding is a single beam scan.
 	SingleBeam *bool `json:"single-beam,omitempty"`
+	// NegateZ indicated that the Z values of thy XYZ input should be
+	// multiplied by -1.
+	NegateZ *bool `json:"negate-z,omitempty"`
 }
 
 const (
@@ -138,9 +141,6 @@
   ST_AsBinary(ST_Transform(area::geometry, best_utm(area)))
 `
 
-	reprojectPointsSQL = `
-SELECT ST_AsBinary(ST_Transform(ST_GeomFromWKB($1, $2::integer), $3::integer))`
-
 	reprojectPointsBufferedSQL = `
 SELECT
   ST_AsBinary(ST_Transform(ST_GeomFromWKB($1, $2::integer), $3::integer)),
@@ -206,10 +206,14 @@
 `
 )
 
-func (sr *SoundingResult) isSingleBeam() bool {
+func (sr *SoundingResult) singleBeam() bool {
 	return sr.SingleBeam != nil && *sr.SingleBeam
 }
 
+func (sr *SoundingResult) negateZ() bool {
+	return sr.NegateZ != nil && *sr.NegateZ
+}
+
 // Do executes the actual sounding result import.
 func (sr *SoundingResult) Do(
 	ctx context.Context,
@@ -249,6 +253,12 @@
 
 	var xform vertexTransform
 
+	if sr.negateZ() {
+		xform = negateZTransform
+	} else {
+		xform = identityTransform
+	}
+
 	if m.DepthReference == "ZPG" {
 		feedback.Info("Found ZPG as reference system -> translating Z values to LDC")
 		var ldc float64
@@ -270,9 +280,11 @@
 
 		// LDC is cm. The data is in m.
 		ldc /= 100
-		xform = func(v octree.Vertex) octree.Vertex {
-			return octree.Vertex{X: v.X, Y: v.Y, Z: v.Z + ldc}
-		}
+		xform = chainTransforms(
+			xform,
+			func(v octree.Vertex) octree.Vertex {
+				return octree.Vertex{X: v.X, Y: v.Y, Z: v.Z + ldc}
+			})
 		m.DepthReference = depthReference
 	}
 
@@ -319,7 +331,7 @@
 
 	var summary interface{}
 
-	if sr.isSingleBeam() {
+	if sr.singleBeam() {
 		summary, err = sr.processScan(
 			ctx,
 			tx,
@@ -541,8 +553,6 @@
 		feedback.Info("Number triangles: %d.", len(tri.Triangles)/3)
 		feedback.Info("Clipping triangles from new mesh.")
 
-	} else { // multi beam
-		// Nothing special
 	}
 
 	start = time.Now()
@@ -655,7 +665,8 @@
 	return sr.Bottleneck != nil &&
 		sr.Date != nil &&
 		sr.DepthReference != nil &&
-		sr.SingleBeam != nil
+		sr.SingleBeam != nil &&
+		sr.NegateZ != nil
 }
 
 func (sr *SoundingResult) loadMeta(f *zip.File) (*models.SoundingResultMeta, error) {
@@ -671,6 +682,8 @@
 			Bottleneck:     *sr.Bottleneck,
 			EPSG:           epsg,
 			DepthReference: *sr.DepthReference,
+			SingleBeam:     sr.singleBeam(),
+			NegateZ:        sr.negateZ(),
 		}, nil
 	}
 	r, err := f.Open()
@@ -699,18 +712,29 @@
 	if sr.SingleBeam != nil {
 		m.SingleBeam = *sr.SingleBeam
 	}
+	if sr.NegateZ != nil {
+		m.NegateZ = *sr.NegateZ
+	}
 
 	return &m, nil
 }
 
 type vertexTransform func(octree.Vertex) octree.Vertex
 
+func identityTransform(v octree.Vertex) octree.Vertex { return v }
+
+func negateZTransform(v octree.Vertex) octree.Vertex {
+	return octree.Vertex{X: v.X, Y: v.Y, Z: -v.Z}
+}
+
+func chainTransforms(a, b vertexTransform) vertexTransform {
+	return func(v octree.Vertex) octree.Vertex { return b(a(v)) }
+}
+
 func loadXYZReader(r io.Reader, feedback Feedback, xform vertexTransform) (octree.MultiPointZ, error) {
 	mpz := make(octree.MultiPointZ, 0, 250000)
 	s := bufio.NewScanner(r)
 
-	var hasNegZ bool
-
 	warnLimiter := misc.WarningLimiter{Log: feedback.Warn, MaxWarnings: 100}
 	warn := warnLimiter.Warn
 	defer warnLimiter.Close()
@@ -743,16 +767,7 @@
 			warn("format error in line %d: %v", line, err)
 			continue
 		}
-		if p.Z < 0 {
-			p.Z = -p.Z
-			if !hasNegZ {
-				hasNegZ = true
-				warn("Negative Z value found: Using -Z")
-			}
-		}
-		if xform != nil {
-			p = xform(p)
-		}
+		p = xform(p)
 		mpz = append(mpz, p)
 	}
 
@@ -844,7 +859,12 @@
 		}
 	} else {
 		heights = octree.ExtrapolateClassBreaks(heights, tree.Min.Z, tree.Max.Z)
-		heights = octree.InBetweenClassBreaks(heights, 0.05, 2)
+		// We set steps for InBetweenClassBreaks to 1, so it
+		// becomes a null operation.  The extra class breaks
+		// were considered unexpected and confusing by the
+		// users.  Once we get filled polygones the visual will
+		// be considerably different anyway. -- sw
+		// heights = octree.InBetweenClassBreaks(heights, 0.05, 1)
 	}
 
 	/*
--- a/pkg/models/sr.go	Fri Jul 12 16:18:49 2019 +0200
+++ b/pkg/models/sr.go	Wed Jul 17 13:00:19 2019 +0200
@@ -32,6 +32,7 @@
 		EPSG           uint   `json:"epsg"`
 		DepthReference string `json:"depth-reference"`
 		SingleBeam     bool   `json:"single-beam"`
+		NegateZ        bool   `json:"negate-z,omitempty"`
 	}
 )
 
--- a/pkg/octree/builder.go	Fri Jul 12 16:18:49 2019 +0200
+++ b/pkg/octree/builder.go	Wed Jul 17 13:00:19 2019 +0200
@@ -37,7 +37,7 @@
 
 type buildStep func(chan buildStep)
 
-var cubes = [8][2]Vertex{
+var cubes = [8]Box{
 	makeCube(0),
 	makeCube(1),
 	makeCube(2),
@@ -48,7 +48,7 @@
 	makeCube(7),
 }
 
-func makeCube(i int) [2]Vertex {
+func makeCube(i int) Box {
 	var d Vertex
 	if i&1 == 1 {
 		d.X = 0.5
@@ -59,12 +59,19 @@
 	if i&4 == 4 {
 		d.Z = 0.5
 	}
-	return [2]Vertex{
+	return Box{
 		Vertex{0.0, 0.0, 0.0}.Add(d),
 		Vertex{0.5, 0.5, 0.5}.Add(d),
 	}
 }
 
+func twoElseOne(b bool) int {
+	if b {
+		return 2
+	}
+	return 1
+}
+
 // NewBuilder creates a new Builder for a TIN.
 func NewBuilder(t *Tin) *Builder {
 	return &Builder{t: t}
@@ -143,15 +150,28 @@
 			parent(tb.buildRecursive(triangles, min, max, depth))
 			return
 		}
+		box := Box{min, max}
 
-		bbox := Interpolate(min, max)
+		xLimit := twoElseOne(box.HasX())
+		yLimit := twoElseOne(box.HasY())
+		zLimit := twoElseOne(box.HasZ())
 
-		bboxes := make([][2]Vertex, len(cubes))
+		indices := make([]byte, 0, 8)
+
+		bbox := box.Interpolate()
 
-		for i := range cubes {
-			bboxes[i] = [2]Vertex{
-				bbox(cubes[i][0]),
-				bbox(cubes[i][1]),
+		var bboxes [8]Box
+
+		for x := 0; x < xLimit; x++ {
+			for y := 0; y < yLimit; y++ {
+				for z := 0; z < zLimit; z++ {
+					idx := byte(z<<2 | y<<1 | x)
+					bboxes[idx] = Box{
+						bbox(cubes[idx][0]),
+						bbox(cubes[idx][1]),
+					}
+					indices = append(indices, idx)
+				}
 			}
 		}
 
@@ -171,7 +191,7 @@
 			h.Maximize(v1)
 			h.Maximize(v2)
 
-			for i := range bboxes {
+			for _, i := range indices {
 				if !(h.Less(bboxes[i][0]) || bboxes[i][1].Less(l)) {
 					quandrants[i] = append(quandrants[i], tri)
 				}
@@ -179,7 +199,7 @@
 		}
 
 		used := new(int32)
-		for i := range quandrants {
+		for _, i := range indices {
 			if len(quandrants[i]) > 0 {
 				*used++
 			}
@@ -187,7 +207,12 @@
 
 		pos := tb.allocNode()
 
-		for i := range quandrants {
+		if *used == 0 {
+			parent(pos)
+			return
+		}
+
+		for _, i := range indices {
 			if len(quandrants[i]) > 0 {
 				j := int32(i)
 				parent := func(v int32) {
@@ -239,14 +264,28 @@
 		return int32(-(pos + 1))
 	}
 
-	bbox := Interpolate(min, max)
+	box := Box{min, max}
 
-	bboxes := make([][2]Vertex, len(cubes))
+	xLimit := twoElseOne(box.HasX())
+	yLimit := twoElseOne(box.HasY())
+	zLimit := twoElseOne(box.HasZ())
+
+	indices := make([]byte, 0, 8)
+
+	bbox := box.Interpolate()
 
-	for i := range cubes {
-		bboxes[i] = [2]Vertex{
-			bbox(cubes[i][0]),
-			bbox(cubes[i][1]),
+	var bboxes [8]Box
+
+	for x := 0; x < xLimit; x++ {
+		for y := 0; y < yLimit; y++ {
+			for z := 0; z < zLimit; z++ {
+				idx := byte(z<<2 | y<<1 | x)
+				bboxes[idx] = Box{
+					bbox(cubes[idx][0]),
+					bbox(cubes[idx][1]),
+				}
+				indices = append(indices, idx)
+			}
 		}
 	}
 
@@ -266,7 +305,7 @@
 		h.Maximize(v1)
 		h.Maximize(v2)
 
-		for i := range bboxes {
+		for _, i := range indices {
 			if !(h.Less(bboxes[i][0]) || bboxes[i][1].Less(l)) {
 				quandrants[i] = append(quandrants[i], tri)
 			}
@@ -275,7 +314,7 @@
 
 	pos := tb.allocNode()
 
-	for i := range quandrants {
+	for _, i := range indices {
 		if len(quandrants[i]) > 0 {
 			child := tb.buildRecursive(
 				quandrants[i],
--- a/pkg/octree/vertex.go	Fri Jul 12 16:18:49 2019 +0200
+++ b/pkg/octree/vertex.go	Wed Jul 17 13:00:19 2019 +0200
@@ -45,6 +45,9 @@
 	// and the second being the direction.
 	Line [2]Vertex
 
+	// Box is a 3D box.
+	Box [2]Vertex
+
 	// MultiPointZ is a set of vertices.
 	MultiPointZ []Vertex
 
@@ -288,9 +291,10 @@
 	}
 }
 
-// Interpolate returns a function that return s*v2 + v1
+// Interpolate returns a function that return s*b[1] + b[0]
 // component-wise.
-func Interpolate(v1, v2 Vertex) func(Vertex) Vertex {
+func (b Box) Interpolate() func(Vertex) Vertex {
+	v1, v2 := b[0], b[1]
 	v2 = v2.Sub(v1)
 	return func(s Vertex) Vertex {
 		return Vertex{
@@ -301,6 +305,10 @@
 	}
 }
 
+func (b Box) HasX() bool { return math.Abs(b[0].X-b[1].X) > epsPlane }
+func (b Box) HasY() bool { return math.Abs(b[0].Y-b[1].Y) > epsPlane }
+func (b Box) HasZ() bool { return math.Abs(b[0].Z-b[1].Z) > epsPlane }
+
 // Less returns if one of v component is less than the
 // corresponing component in w.
 func (v Vertex) Less(w Vertex) bool {
--- a/schema/auth.sql	Fri Jul 12 16:18:49 2019 +0200
+++ b/schema/auth.sql	Wed Jul 17 13:00:19 2019 +0200
@@ -66,7 +66,7 @@
 GRANT INSERT, UPDATE ON sys_admin.system_config TO sys_admin;
 GRANT UPDATE ON systemconf.feature_colours TO sys_admin;
 GRANT UPDATE ON sys_admin.published_services TO sys_admin;
-GRANT INSERT, DELETE ON sys_admin.password_reset_requests TO sys_admin;
+GRANT INSERT, DELETE, UPDATE ON sys_admin.password_reset_requests TO sys_admin;
 
 --
 -- Privileges assigned directly to metamorph
--- a/schema/gemma.sql	Fri Jul 12 16:18:49 2019 +0200
+++ b/schema/gemma.sql	Wed Jul 17 13:00:19 2019 +0200
@@ -206,6 +206,23 @@
 
 
 --
+-- GEMA meta data
+--
+CREATE TABLE gemma_schema_version (
+    version int PRIMARY KEY,
+    update_date timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE OR REPLACE FUNCTION get_schema_version() RETURNS int
+    LANGUAGE plpgsql
+    AS $$
+    BEGIN
+        RETURN ( SELECT max(version) FROM gemma_schema_version );
+    END;
+$$;
+
+
+--
 -- GEMMA data
 --
 
@@ -232,7 +249,7 @@
     CREATE TABLE password_reset_requests (
         hash varchar(32) PRIMARY KEY,
         issued timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
-        username varchar NOT NULL
+        username varchar NOT NULL UNIQUE
             REFERENCES internal.user_profiles(username)
                 ON DELETE CASCADE ON UPDATE CASCADE
     )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/schema/update-db.sh	Wed Jul 17 13:00:19 2019 +0200
@@ -0,0 +1,109 @@
+#!/bin/bash -e
+# 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) 2019 by via donau
+#   – Österreichische Wasserstraßen-Gesellschaft mbH
+# Software engineering by Intevation GmbH
+#
+# Author(s):
+#  * Sascha Wilde <wilde@intevation.de>
+
+ME=`basename "$0"`
+BASEDIR=`dirname "$0"`
+
+usage()
+{
+  cat <<EOF
+$ME [OPTION]...
+
+Options:
+  -d, --db=NAME    create the database NAME.  Default: "gemma"
+  -p, --port=PORT  connect do the postgresql cluster at PORT.
+                   Default is the postgresql standard port 5432
+      --help       display this help and exit
+
+EOF
+}
+
+fatal()
+{
+  echo >&2 "$1"
+  exit 23
+}
+
+genpw()
+# $1 - length
+{
+  PW=''
+  until [ "$(grep '[^[:alnum:]]' <<<$PW)" -a "$(grep '[[:digit:]]' <<<$PW)" ]
+  do
+    PW=$(dd count=1 if=/dev/urandom 2>/dev/null \
+           | tr -cd '[:alnum:],._!?-' | tail -c "$1")
+  done
+  echo "$PW"
+}
+
+# Defaults:
+
+db=gemma
+port=5432
+
+# Parse options:
+
+OPTS=`getopt \
+      -l help,db:,port: \
+      -o d:p: -n "$ME" -- "$@"`
+[ $? -eq 0 ] || { usage ; exit 1 ; }
+
+eval set -- "$OPTS"
+
+while true ; do
+  case "$1" in
+    --db|-d)
+      db="$2"
+      shift 2
+      ;;
+    --port|-p)
+      port="$2"
+      shift 2
+      ;;
+    --help)
+      { usage ; exit 0 ; }
+      ;;
+    --)
+      shift
+      break
+      ;;
+  esac
+done
+
+
+get_version()
+{
+  local ver
+  if ver=$( psql -qtA -p "$port" -d "$db" \
+                 -c 'SELECT get_schema_version()' 2>/dev/null )
+  then
+    echo $ver
+  else
+    echo '-1'
+  fi
+}
+
+# Main ------------------------------------------------------------
+
+current_ver=$( get_version )
+
+for d in $BASEDIR/updates/* ; do
+  new_ver=$( basename $d )
+  if [ -d "$d" -a "$new_ver" -gt $current_ver ] ; then
+    echo "Running updates for $new_ver ..."
+
+    sql=$( cat `echo "$d/"* | sort -n` )
+    psql -1 -q -p "$port" -d "$db" -c "$sql"
+  fi
+done
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/schema/updates/0000/01.add_schema_version.sql	Wed Jul 17 13:00:19 2019 +0200
@@ -0,0 +1,12 @@
+CREATE TABLE gemma_schema_version (
+    version int PRIMARY KEY,
+    update_date timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE OR REPLACE FUNCTION get_schema_version() RETURNS int
+    LANGUAGE plpgsql
+    AS $$
+    BEGIN
+        RETURN ( SELECT max(version) FROM gemma_schema_version );
+    END;
+$$;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/schema/updates/0000/99.set_version.sql	Wed Jul 17 13:00:19 2019 +0200
@@ -0,0 +1,1 @@
+INSERT INTO gemma_schema_version(version) VALUES (0);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/schema/updates/0301/01.dismar-wwname.sql	Wed Jul 17 13:00:19 2019 +0200
@@ -0,0 +1,2 @@
+ALTER TABLE waterway.distance_marks_virtual
+  ADD COLUMN IF NOT EXISTS wwname varchar;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/schema/updates/0301/02.search_functions.sql	Wed Jul 17 13:00:19 2019 +0200
@@ -0,0 +1,170 @@
+-- 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,2019 by via donau
+--   – Österreichische Wasserstraßen-Gesellschaft mbH
+-- Software engineering by Intevation GmbH
+
+-- Author(s):
+--  * Sascha Wilde <wilde@intevation.de>
+
+CREATE OR REPLACE FUNCTION search_hectometre(search_string text) RETURNS jsonb
+  LANGUAGE plpgsql STABLE PARALLEL SAFE
+  AS $$
+DECLARE
+  _result jsonb;
+BEGIN
+  IF search_string SIMILAR TO '[0-9]+' THEN
+    SELECT COALESCE(json_agg(r),'[]')
+      INTO _result
+      FROM (SELECT (location_code).hectometre || ' rhm' AS name,
+                        isrs_asText(location_code) AS locationcode,
+                        wwname AS location,
+               ST_AsGeoJSON(geom)::json AS geom, 'rhm' AS type
+        FROM waterway.distance_marks_virtual
+        WHERE (location_code).hectometre = search_string::int) r;
+  ELSIF is_ISRSstring(search_string) THEN
+    SELECT COALESCE(json_agg(r),'[]')
+      INTO _result
+      FROM (SELECT (location_code).hectometre || ' rhm' AS name,
+                        isrs_asText(location_code) AS locationcode,
+                        wwname AS location,
+               ST_AsGeoJSON(geom)::json AS geom, 'rhm' AS type
+        FROM waterway.distance_marks_virtual
+        WHERE location_code = isrs_fromText(search_string)) r;
+  ELSE
+    _result='[]';
+  END IF;
+  RETURN _result;
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION search_bottlenecks(search_string text) RETURNS jsonb
+  LANGUAGE plpgsql STABLE PARALLEL SAFE
+  AS $$
+DECLARE
+  _result jsonb;
+BEGIN
+  SELECT COALESCE(json_agg(r),'[]')
+    INTO _result
+    FROM (SELECT objnam AS name,
+                 ST_AsGeoJSON(ST_Envelope(area::geometry))::json AS geom,
+                 'bottleneck' AS type
+            FROM waterway.bottlenecks
+            WHERE objnam ILIKE '%' || search_string || '%'
+          ORDER BY name) r;
+  RETURN _result;
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION search_cities(search_string text) RETURNS jsonb
+  LANGUAGE plpgsql STABLE PARALLEL SAFE
+  AS $$
+DECLARE
+  _result jsonb;
+BEGIN
+  SELECT COALESCE(json_agg(r),'[]')
+    INTO _result
+    FROM (SELECT name,
+                 country_code AS location,
+                 ST_AsGeoJSON(location)::json AS geom,
+                 'city' AS type
+            FROM waterway.geonames
+            WHERE feature_code IN ('PPLA', 'PPLA1', 'PPLA2', 'PPLA3', 'PPLC')
+                  AND (name ILIKE '%' || search_string || '%'
+                       OR alternatenames ~* ('(^|,)' || search_string || '($|,)'))
+            ORDER BY array_position(ARRAY['PPLC', 'PPLA', 'PPLA1', 'PPLA2', 'PPLA3'],
+                                          feature_code::text),
+                     name) r;
+  RETURN _result;
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION search_gauges(search_string text) RETURNS jsonb
+  LANGUAGE plpgsql STABLE PARALLEL SAFE
+  AS $$
+DECLARE
+  _result jsonb;
+BEGIN
+  IF is_ISRSstring(search_string) THEN
+    SELECT COALESCE(json_agg(r),'[]')
+      INTO _result
+      FROM (
+         SELECT objname AS name,
+                   ST_AsGeoJSON(geom)::json AS geom,
+                   'gauge' AS type,
+                   isrs_asText(location) AS locationcode
+              FROM waterway.gauges
+              WHERE NOT erased AND location = isrs_fromText(search_string)
+            ORDER BY name) r;
+  ELSE
+    SELECT COALESCE(json_agg(r),'[]')
+      INTO _result
+      FROM (
+         SELECT objname AS name,
+                   ST_AsGeoJSON(geom)::json AS geom,
+                   'gauge' AS type,
+                   isrs_asText(location) AS locationcode
+              FROM waterway.gauges
+              WHERE NOT erased AND objname ILIKE '%' || search_string || '%'
+            ORDER BY name) r;
+  END IF;
+  RETURN _result;
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION search_sections(search_string text) RETURNS jsonb
+  LANGUAGE plpgsql STABLE PARALLEL SAFE
+  AS $$
+DECLARE
+  _result jsonb;
+BEGIN
+  SELECT COALESCE(json_agg(r),'[]')
+    INTO _result
+    FROM (SELECT id,
+                 objnam AS name,
+                 ST_AsGeoJSON(ST_Envelope(area::geometry))::json AS geom,
+                 'section' AS type
+            FROM waterway.sections
+            WHERE objnam ILIKE '%' || search_string || '%'
+               OR nobjnam ILIKE '%' || search_string || '%'
+          ORDER BY name) r;
+  RETURN _result;
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION search_stretches(search_string text) RETURNS jsonb
+  LANGUAGE plpgsql STABLE PARALLEL SAFE
+  AS $$
+DECLARE
+  _result jsonb;
+BEGIN
+  SELECT COALESCE(json_agg(r),'[]')
+    INTO _result
+    FROM (SELECT id,
+                 objnam AS name,
+                 ST_AsGeoJSON(ST_Envelope(area::geometry))::json AS geom,
+                 'stretch' AS type
+            FROM waterway.stretches
+            WHERE objnam ILIKE '%' || search_string || '%'
+               OR nobjnam ILIKE '%' || search_string || '%'
+          ORDER BY name) r;
+  RETURN _result;
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION search_most(search_string text) RETURNS jsonb
+  LANGUAGE plpgsql STABLE PARALLEL SAFE
+  AS $$
+BEGIN
+  RETURN search_hectometre(search_string)
+         || search_bottlenecks(search_string)
+         || search_gauges(search_string)
+         || search_sections(search_string)
+         || search_stretches(search_string)
+         || search_cities(search_string);
+END;
+$$;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/schema/updates/0301/99.set_version.sql	Wed Jul 17 13:00:19 2019 +0200
@@ -0,0 +1,1 @@
+INSERT INTO gemma_schema_version(version) VALUES (301);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/schema/updates/1000/01.pwreset.sql	Wed Jul 17 13:00:19 2019 +0200
@@ -0,0 +1,4 @@
+GRANT INSERT, DELETE, UPDATE ON sys_admin.password_reset_requests TO sys_admin;
+
+ALTER TABLE sys_admin.password_reset_requests
+  ADD CONSTRAINT password_reset_requests_username_key UNIQUE(username);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/schema/updates/1000/99.set_version.sql	Wed Jul 17 13:00:19 2019 +0200
@@ -0,0 +1,1 @@
+INSERT INTO gemma_schema_version(version) VALUES (1000);
--- a/schema/updates_3.0.1/01.dismar-wwname.sql	Fri Jul 12 16:18:49 2019 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,2 +0,0 @@
-ALTER TABLE waterway.distance_marks_virtual
-  ADD COLUMN IF NOT EXISTS wwname varchar;
--- a/schema/updates_3.0.1/02.search_functions.sql	Fri Jul 12 16:18:49 2019 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-../search_functions.sql
\ No newline at end of file