Search: Use parameterized queries for user search input #5587

This commit is contained in:
Michael Mayer 2026-05-17 13:46:20 +00:00
parent 5ada2aa40a
commit 85221460e9
8 changed files with 361 additions and 262 deletions

View file

@ -183,23 +183,26 @@ func UserAlbums(frm form.SearchAlbums, sess *entity.Session) (results AlbumResul
if txt.NotEmpty(frm.Year) {
// Filter by the pictures included if it is a manually managed album, as these do not have an explicit
// year assigned to them, unlike calendar albums and moments for example.
w, v := AnyInt("albums.album_year", frm.Year, txt.Or, entity.UnknownYear, txt.YearMax)
if frm.Type == entity.AlbumManual {
s = s.Where("? OR albums.album_uid IN (SELECT DISTINCT pay.album_uid FROM photos_albums pay "+
"JOIN photos py ON pay.photo_uid = py.photo_uid WHERE py.photo_year IN (?) AND pay.hidden = 0 AND pay.missing = 0)",
gorm.Expr(AnyInt("albums.album_year", frm.Year, txt.Or, entity.UnknownYear, txt.YearMax)), strings.Split(frm.Year, txt.Or))
gorm.Expr(w, v...), strings.Split(frm.Year, txt.Or))
} else {
s = s.Where(AnyInt("albums.album_year", frm.Year, txt.Or, entity.UnknownYear, txt.YearMax))
s = s.Where(w, v...)
}
}
// Filter by month?
if txt.NotEmpty(frm.Month) {
s = s.Where(AnyInt("albums.album_month", frm.Month, txt.Or, entity.UnknownMonth, txt.MonthMax))
w, v := AnyInt("albums.album_month", frm.Month, txt.Or, entity.UnknownMonth, txt.MonthMax)
s = s.Where(w, v...)
}
// Filter by day?
if txt.NotEmpty(frm.Day) {
s = s.Where(AnyInt("albums.album_day", frm.Day, txt.Or, entity.UnknownDay, txt.DayMax))
w, v := AnyInt("albums.album_day", frm.Day, txt.Or, entity.UnknownDay, txt.DayMax)
s = s.Where(w, v...)
}
// Limit result count.

View file

@ -10,19 +10,22 @@ import (
"github.com/jinzhu/inflection"
)
// Like sanitizes user input so it can be safely interpolated into SQL LIKE
// expressions. It strips operators that we don't expect to persist in the
// statement and lets callers provide their own surrounding wildcards.
func Like(s string) string {
return strings.Trim(clean.SqlString(s), " |&*%")
// SqlParam sanitizes user input for use as a LIKE-clause bind value. The
// surrounding pre/post strings are concatenated verbatim so callers can add
// SQL wildcards (e.g. "%") without exposing the underlying value to string
// interpolation.
func SqlParam(s, pre, post string) string {
return pre + strings.Trim(clean.SqlClean(s), " |&*%") + post
}
// LikeAny builds OR-chained LIKE predicates for a text column. The input string
// may contain AND / OR separators; keywords trigger stemming and plural
// normalization while exact mode disables wildcard suffixes.
func LikeAny(col, s string, keywords, exact bool) (wheres []string) {
// The returned wheres and values are aligned 1:1; callers feed each pair into
// gorm.Expr(wheres[i], values[i]...).
func LikeAny(col, s string, keywords, exact bool) (wheres []string, values [][]any) {
if s == "" {
return wheres
return wheres, values
}
s = txt.StripOr(clean.SearchQuery(s))
@ -39,6 +42,7 @@ func LikeAny(col, s string, keywords, exact bool) (wheres []string) {
for _, k := range txt.UnTrimmedSplitWithEscape(s, txt.AndRune, txt.EscapeRune) {
var orWheres []string
var orValues []any
var words []string
if keywords {
@ -53,9 +57,11 @@ func LikeAny(col, s string, keywords, exact bool) (wheres []string) {
for _, w := range words {
if wildcardThreshold > 0 && len(w) >= wildcardThreshold {
orWheres = append(orWheres, fmt.Sprintf("%s LIKE '%s%%'", col, Like(w)))
orWheres = append(orWheres, fmt.Sprintf("%s LIKE ?", col))
orValues = append(orValues, SqlParam(w, "", "%"))
} else {
orWheres = append(orWheres, fmt.Sprintf("%s LIKE '%s'", col, Like(w)))
orWheres = append(orWheres, fmt.Sprintf("%s LIKE ?", col))
orValues = append(orValues, SqlParam(w, "", ""))
}
if !keywords || !txt.ContainsASCIILetters(w) {
@ -65,35 +71,39 @@ func LikeAny(col, s string, keywords, exact bool) (wheres []string) {
singular := inflection.Singular(w)
if singular != w {
orWheres = append(orWheres, fmt.Sprintf("%s LIKE '%s'", col, Like(singular)))
orWheres = append(orWheres, fmt.Sprintf("%s LIKE ?", col))
orValues = append(orValues, SqlParam(singular, "", ""))
}
}
if len(orWheres) > 0 {
wheres = append(wheres, strings.Join(orWheres, " OR "))
values = append(values, orValues)
}
}
return wheres
return wheres, values
}
// LikeAnyKeyword is a keyword-optimized wrapper around LikeAny.
func LikeAnyKeyword(col, s string) (wheres []string) {
func LikeAnyKeyword(col, s string) (wheres []string, values [][]any) {
return LikeAny(col, s, true, false)
}
// LikeAnyWord matches whole words and keeps wildcard thresholds tuned for
// free-form text search instead of keyword lists.
func LikeAnyWord(col, s string) (wheres []string) {
func LikeAnyWord(col, s string) (wheres []string, values [][]any) {
return LikeAny(col, s, false, false)
}
// LikeAll produces AND-chained LIKE predicates for every significant token in
// the search string. When exact is false, longer words receive a suffix
// wildcard to support prefix matches.
func LikeAll(col, s string, keywords, exact bool) (wheres []string) {
// The returned wheres and values are aligned 1:1; callers feed each pair into
// gorm.Expr(wheres[i], values[i]...).
func LikeAll(col, s string, keywords, exact bool) (wheres []string, values [][]any) {
if s == "" {
return wheres
return wheres, values
}
var words []string
@ -108,42 +118,45 @@ func LikeAll(col, s string, keywords, exact bool) (wheres []string) {
}
if len(words) == 0 {
return wheres
return wheres, values
} else if exact {
wildcardThreshold = -1
}
for _, w := range words {
if wildcardThreshold > 0 && len(w) >= wildcardThreshold {
wheres = append(wheres, fmt.Sprintf("%s LIKE '%s%%'", col, Like(w)))
wheres = append(wheres, fmt.Sprintf("%s LIKE ?", col))
values = append(values, []any{SqlParam(w, "", "%")})
} else {
wheres = append(wheres, fmt.Sprintf("%s LIKE '%s'", col, Like(w)))
wheres = append(wheres, fmt.Sprintf("%s LIKE ?", col))
values = append(values, []any{SqlParam(w, "", "")})
}
}
return wheres
return wheres, values
}
// LikeAllKeywords is LikeAll specialized for keyword search.
func LikeAllKeywords(col, s string) (wheres []string) {
func LikeAllKeywords(col, s string) (wheres []string, values [][]any) {
return LikeAll(col, s, true, false)
}
// LikeAllWords is LikeAll specialized for general word search.
func LikeAllWords(col, s string) (wheres []string) {
func LikeAllWords(col, s string) (wheres []string, values [][]any) {
return LikeAll(col, s, false, false)
}
// LikeAllNames splits a name query into AND-separated groups and generates
// prefix or substring matches against each provided column, keeping multi-word
// tokens intact so "John Doe" still matches full-name columns.
func LikeAllNames(cols Cols, s string) (wheres []string) {
func LikeAllNames(cols Cols, s string) (wheres []string, values [][]any) {
if len(cols) == 0 || len(s) < 1 {
return wheres
return wheres, values
}
for _, k := range txt.UnTrimmedSplitWithEscape(s, txt.AndRune, txt.EscapeRune) {
var orWheres []string
var orValues []any
for _, w := range txt.UnTrimmedSplitWithEscape(k, txt.OrRune, txt.EscapeRune) {
w = strings.TrimSpace(w)
@ -154,27 +167,30 @@ func LikeAllNames(cols Cols, s string) (wheres []string) {
for _, c := range cols {
if strings.Contains(w, txt.Space) {
orWheres = append(orWheres, fmt.Sprintf("%s LIKE '%s%%'", c, Like(w)))
orWheres = append(orWheres, fmt.Sprintf("%s LIKE ?", c))
orValues = append(orValues, SqlParam(w, "", "%"))
} else {
orWheres = append(orWheres, fmt.Sprintf("%s LIKE '%%%s%%'", c, Like(w)))
orWheres = append(orWheres, fmt.Sprintf("%s LIKE ?", c))
orValues = append(orValues, SqlParam(w, "%", "%"))
}
}
}
if len(orWheres) > 0 {
wheres = append(wheres, strings.Join(orWheres, " OR "))
values = append(values, orValues)
}
}
return wheres
return wheres, values
}
// AnySlug converts human-friendly search terms into slugs and matches them
// against the provided slug column, including the singularized variant for
// plural words (e.g. "Cats" -> "cat").
func AnySlug(col, search, sep string) (where string) {
func AnySlug(col, search, sep string) (where string, values []any) {
if search == "" {
return ""
return "", values
}
if sep == "" {
@ -201,21 +217,23 @@ func AnySlug(col, search, sep string) (where string) {
}
if len(words) == 0 {
return ""
return "", values
}
for _, w := range words {
wheres = append(wheres, fmt.Sprintf("%s = '%s'", col, Like(w)))
wheres = append(wheres, fmt.Sprintf("%s = ?", col))
values = append(values, SqlParam(w, "", ""))
}
return strings.Join(wheres, " OR ")
return strings.Join(wheres, " OR "), values
}
// AnyInt filters user-specified integers through an allowed range and returns
// an OR-chained equality predicate for the values that remain.
func AnyInt(col, numbers, sep string, min, max int) (where string) {
// an OR-chained equality predicate for the values that remain. Named low/high
// to avoid shadowing the predeclared min/max identifiers added in Go 1.21.
func AnyInt(col, numbers, sep string, low, high int) (where string, values []any) {
if numbers == "" {
return ""
return "", values
}
if sep == "" {
@ -228,7 +246,7 @@ func AnyInt(col, numbers, sep string, min, max int) (where string) {
for n := range strings.SplitSeq(numbers, sep) {
i := txt.Int(n)
if i == 0 || i < min || i > max {
if i == 0 || i < low || i > high {
continue
}
@ -236,17 +254,18 @@ func AnyInt(col, numbers, sep string, min, max int) (where string) {
}
if len(matches) == 0 {
return ""
return "", values
}
for _, n := range matches {
wheres = append(wheres, fmt.Sprintf("%s = %d", col, n))
wheres = append(wheres, fmt.Sprintf("%s = ?", col))
values = append(values, n)
}
return strings.Join(wheres, " OR ")
return strings.Join(wheres, " OR "), values
}
// OrLike prepares a parameterised OR/LIKE clause for a single column. Star (* )
// OrLike prepares a parameterized OR/LIKE clause for a single column. Star (* )
// wildcards are mapped to SQL percent wildcards before returning the query and
// bind values.
func OrLike(col, s string) (where string, values []any) {

View file

@ -11,309 +11,307 @@ import (
"github.com/photoprism/photoprism/pkg/txt"
)
func TestLike(t *testing.T) {
func TestSqlParam(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
assert.Equal(t, "", Like(""))
assert.Equal(t, "", SqlParam("", "", ""))
})
t.Run("Special", func(t *testing.T) {
s := " ' \" \t \n %_''"
exp := "'' \"\" %_''''"
result := Like(s)
t.Logf("String..: %s", s)
t.Logf("Expected: %s", exp)
t.Logf("Result..: %s", result)
assert.Equal(t, exp, result)
t.Run("Wildcards", func(t *testing.T) {
assert.Equal(t, "%foo%", SqlParam("foo", "%", "%"))
})
t.Run("Alnum", func(t *testing.T) {
assert.Equal(t, "123ABCabc", Like(" 123ABCabc%* "))
t.Run("StripsTrimChars", func(t *testing.T) {
// Leading/trailing operators and wildcards are stripped before the value is bound.
assert.Equal(t, "spoon", SqlParam(" |&*%spoon%*&| ", "", ""))
})
t.Run("KeepsQuotes", func(t *testing.T) {
// Quotes are preserved unchanged; the parameter binder is responsible for escaping.
assert.Equal(t, "O'Reilly", SqlParam("O'Reilly", "", ""))
})
t.Run("StripsControl", func(t *testing.T) {
assert.Equal(t, "ab", SqlParam("a\tb\n", "", ""))
})
t.Run("PrePostWrappers", func(t *testing.T) {
assert.Equal(t, "<table%", SqlParam("table", "<", "%"))
})
}
func TestLikeAny(t *testing.T) {
t.Run("AndOrSearch", func(t *testing.T) {
if w := LikeAny("k.keyword", "table spoon & usa | img json", true, false); len(w) != 2 {
t.Fatal("two where conditions expected")
} else {
assert.Equal(t, "k.keyword LIKE 'spoon%' OR k.keyword LIKE 'table%'", w[0])
assert.Equal(t, "k.keyword LIKE 'json%' OR k.keyword LIKE 'usa'", w[1])
}
w, v := LikeAny("k.keyword", "table spoon & usa | img json", true, false)
assert.Equal(t, []string{"k.keyword LIKE ? OR k.keyword LIKE ?", "k.keyword LIKE ? OR k.keyword LIKE ?"}, w)
assert.Equal(t, [][]any{{"spoon%", "table%"}, {"json%", "usa"}}, v)
})
t.Run("ExactAndOrSearch", func(t *testing.T) {
if w := LikeAny("k.keyword", "table spoon & usa | img json", true, true); len(w) != 2 {
t.Fatal("two where conditions expected")
} else {
assert.Equal(t, "k.keyword LIKE 'spoon' OR k.keyword LIKE 'table'", w[0])
assert.Equal(t, "k.keyword LIKE 'json' OR k.keyword LIKE 'usa'", w[1])
}
w, v := LikeAny("k.keyword", "table spoon & usa | img json", true, true)
assert.Equal(t, []string{"k.keyword LIKE ? OR k.keyword LIKE ?", "k.keyword LIKE ? OR k.keyword LIKE ?"}, w)
assert.Equal(t, [][]any{{"spoon", "table"}, {"json", "usa"}}, v)
})
t.Run("AndOrSearchEn", func(t *testing.T) {
if w := LikeAny("k.keyword", "table spoon and usa or img json", true, false); len(w) != 2 {
t.Fatal("two where conditions expected")
} else {
assert.Equal(t, "k.keyword LIKE 'spoon%' OR k.keyword LIKE 'table%'", w[0])
assert.Equal(t, "k.keyword LIKE 'json%' OR k.keyword LIKE 'usa'", w[1])
}
w, v := LikeAny("k.keyword", "table spoon and usa or img json", true, false)
assert.Equal(t, []string{"k.keyword LIKE ? OR k.keyword LIKE ?", "k.keyword LIKE ? OR k.keyword LIKE ?"}, w)
assert.Equal(t, [][]any{{"spoon%", "table%"}, {"json%", "usa"}}, v)
})
t.Run("TableSpoonUsaImgJson", func(t *testing.T) {
if w := LikeAny("k.keyword", "table spoon usa img json", true, false); len(w) != 1 {
t.Fatal("one where condition expected")
} else {
assert.Equal(t, "k.keyword LIKE 'json%' OR k.keyword LIKE 'spoon%' OR k.keyword LIKE 'table%' OR k.keyword LIKE 'usa'", w[0])
}
w, v := LikeAny("k.keyword", "table spoon usa img json", true, false)
assert.Equal(t, []string{"k.keyword LIKE ? OR k.keyword LIKE ? OR k.keyword LIKE ? OR k.keyword LIKE ?"}, w)
assert.Equal(t, [][]any{{"json%", "spoon%", "table%", "usa"}}, v)
})
t.Run("CatDog", func(t *testing.T) {
if w := LikeAny("k.keyword", "cat dog", true, false); len(w) != 1 {
t.Fatal("one where condition expected")
} else {
assert.Equal(t, "k.keyword LIKE 'cat' OR k.keyword LIKE 'dog'", w[0])
}
w, v := LikeAny("k.keyword", "cat dog", true, false)
assert.Equal(t, []string{"k.keyword LIKE ? OR k.keyword LIKE ?"}, w)
assert.Equal(t, [][]any{{"cat", "dog"}}, v)
})
t.Run("CatsDogs", func(t *testing.T) {
if w := LikeAny("k.keyword", "cats dogs", true, false); len(w) != 1 {
t.Fatal("one where condition expected")
} else {
assert.Equal(t, "k.keyword LIKE 'cats%' OR k.keyword LIKE 'cat' OR k.keyword LIKE 'dogs%' OR k.keyword LIKE 'dog'", w[0])
}
w, v := LikeAny("k.keyword", "cats dogs", true, false)
assert.Equal(t, []string{"k.keyword LIKE ? OR k.keyword LIKE ? OR k.keyword LIKE ? OR k.keyword LIKE ?"}, w)
assert.Equal(t, [][]any{{"cats%", "cat", "dogs%", "dog"}}, v)
})
t.Run("Spoon", func(t *testing.T) {
if w := LikeAny("k.keyword", "spoon", true, false); len(w) != 1 {
t.Fatal("one where condition expected")
} else {
assert.Equal(t, "k.keyword LIKE 'spoon%'", w[0])
}
w, v := LikeAny("k.keyword", "spoon", true, false)
assert.Equal(t, []string{"k.keyword LIKE ?"}, w)
assert.Equal(t, [][]any{{"spoon%"}}, v)
})
t.Run("Img", func(t *testing.T) {
if w := LikeAny("k.keyword", "img", true, false); len(w) > 0 {
t.Fatal("no where condition expected")
}
w, v := LikeAny("k.keyword", "img", true, false)
assert.Empty(t, w)
assert.Empty(t, v)
})
t.Run("Empty", func(t *testing.T) {
if w := LikeAny("k.keyword", "", true, false); len(w) > 0 {
t.Fatal("no where condition expected")
}
w, v := LikeAny("k.keyword", "", true, false)
assert.Empty(t, w)
assert.Empty(t, v)
})
t.Run("LengthAlignment", func(t *testing.T) {
// Callers rely on wheres[i] and values[i] being aligned; assert it explicitly.
w, v := LikeAny("k.keyword", "table spoon & usa | img json", true, false)
assert.Equal(t, len(w), len(v))
})
}
func TestLikeAnyKeyword(t *testing.T) {
t.Run("AndOrSearch", func(t *testing.T) {
if w := LikeAnyKeyword("k.keyword", "table spoon & usa | img json"); len(w) != 2 {
t.Fatal("two where conditions expected")
} else {
assert.Equal(t, "k.keyword LIKE 'spoon%' OR k.keyword LIKE 'table%'", w[0])
assert.Equal(t, "k.keyword LIKE 'json%' OR k.keyword LIKE 'usa'", w[1])
}
w, v := LikeAnyKeyword("k.keyword", "table spoon & usa | img json")
assert.Equal(t, []string{"k.keyword LIKE ? OR k.keyword LIKE ?", "k.keyword LIKE ? OR k.keyword LIKE ?"}, w)
assert.Equal(t, [][]any{{"spoon%", "table%"}, {"json%", "usa"}}, v)
})
t.Run("AndOrSearchEn", func(t *testing.T) {
if w := LikeAnyKeyword("k.keyword", "table spoon and usa or img json"); len(w) != 2 {
t.Fatal("two where conditions expected")
} else {
assert.Equal(t, "k.keyword LIKE 'spoon%' OR k.keyword LIKE 'table%'", w[0])
assert.Equal(t, "k.keyword LIKE 'json%' OR k.keyword LIKE 'usa'", w[1])
}
w, v := LikeAnyKeyword("k.keyword", "table spoon and usa or img json")
assert.Equal(t, []string{"k.keyword LIKE ? OR k.keyword LIKE ?", "k.keyword LIKE ? OR k.keyword LIKE ?"}, w)
assert.Equal(t, [][]any{{"spoon%", "table%"}, {"json%", "usa"}}, v)
})
}
func TestLikeAnyWord(t *testing.T) {
t.Run("SearchAndOr", func(t *testing.T) {
if w := LikeAnyWord("k.keyword", "table spoon & usa | img json"); len(w) != 2 {
t.Fatal("two where conditions expected")
} else {
assert.Equal(t, "k.keyword LIKE 'spoon%' OR k.keyword LIKE 'table%'", w[0])
assert.Equal(t, "k.keyword LIKE 'img%' OR k.keyword LIKE 'json%' OR k.keyword LIKE 'usa%'", w[1])
}
w, v := LikeAnyWord("k.keyword", "table spoon & usa | img json")
assert.Equal(t, []string{"k.keyword LIKE ? OR k.keyword LIKE ?", "k.keyword LIKE ? OR k.keyword LIKE ? OR k.keyword LIKE ?"}, w)
assert.Equal(t, [][]any{{"spoon%", "table%"}, {"img%", "json%", "usa%"}}, v)
})
t.Run("SearchAndOrEnglish", func(t *testing.T) {
if w := LikeAnyWord("k.keyword", "table spoon and usa or img json"); len(w) != 2 {
t.Fatal("two where conditions expected")
} else {
assert.Equal(t, "k.keyword LIKE 'spoon%' OR k.keyword LIKE 'table%'", w[0])
assert.Equal(t, "k.keyword LIKE 'img%' OR k.keyword LIKE 'json%' OR k.keyword LIKE 'usa%'", w[1])
}
w, v := LikeAnyWord("k.keyword", "table spoon and usa or img json")
assert.Equal(t, []string{"k.keyword LIKE ? OR k.keyword LIKE ?", "k.keyword LIKE ? OR k.keyword LIKE ? OR k.keyword LIKE ?"}, w)
assert.Equal(t, [][]any{{"spoon%", "table%"}, {"img%", "json%", "usa%"}}, v)
})
t.Run("EscapeSql", func(t *testing.T) {
if w := LikeAnyWord("k.keyword", "table% | 'spoon' & \"us'a"); len(w) != 2 {
t.Fatalf("two where conditions expected: %#v", w)
} else {
assert.Equal(t, "k.keyword LIKE 'spoon%' OR k.keyword LIKE 'table%'", w[0])
assert.Equal(t, "k.keyword LIKE '\"\"us''a%'", w[1])
}
// Quote characters survive in the bound value — the parameter binder, not the
// query builder, is responsible for escaping them.
w, v := LikeAnyWord("k.keyword", "table% | 'spoon' & \"us'a")
assert.Equal(t, []string{"k.keyword LIKE ? OR k.keyword LIKE ?", "k.keyword LIKE ?"}, w)
assert.Equal(t, [][]any{{"spoon%", "table%"}, {"\"us'a%"}}, v)
})
}
func TestLikeAll(t *testing.T) {
t.Run("Keywords", func(t *testing.T) {
if w := LikeAll("k.keyword", "Jo Mander 李", true, false); len(w) == 2 {
assert.Equal(t, "k.keyword LIKE 'mander%'", w[0])
assert.Equal(t, "k.keyword LIKE '李'", w[1])
} else {
t.Logf("wheres: %#v", w)
t.Fatal("two where conditions expected")
}
w, v := LikeAll("k.keyword", "Jo Mander 李", true, false)
assert.Equal(t, []string{"k.keyword LIKE ?", "k.keyword LIKE ?"}, w)
assert.Equal(t, [][]any{{"mander%"}, {"李"}}, v)
})
t.Run("Exact", func(t *testing.T) {
if w := LikeAll("k.keyword", "Jo Mander 李", true, true); len(w) == 2 {
assert.Equal(t, "k.keyword LIKE 'mander'", w[0])
assert.Equal(t, "k.keyword LIKE '李'", w[1])
} else {
t.Logf("wheres: %#v", w)
t.Fatal("two where conditions expected")
}
w, v := LikeAll("k.keyword", "Jo Mander 李", true, true)
assert.Equal(t, []string{"k.keyword LIKE ?", "k.keyword LIKE ?"}, w)
assert.Equal(t, [][]any{{"mander"}, {"李"}}, v)
})
t.Run("StringEmpty", func(t *testing.T) {
w := LikeAll("k.keyword", "", true, true)
w, v := LikeAll("k.keyword", "", true, true)
assert.Empty(t, w)
assert.Empty(t, v)
})
t.Run("ZeroWords", func(t *testing.T) {
w := LikeAll("k.keyword", "ab", true, true)
w, v := LikeAll("k.keyword", "ab", true, true)
assert.Empty(t, w)
assert.Empty(t, v)
})
t.Run("LengthAlignment", func(t *testing.T) {
w, v := LikeAll("k.keyword", "Jo Mander 李", true, false)
assert.Equal(t, len(w), len(v))
})
}
func TestLikeAllKeywords(t *testing.T) {
t.Run("Keywords", func(t *testing.T) {
if w := LikeAllKeywords("k.keyword", "Jo Mander 李"); len(w) == 2 {
assert.Equal(t, "k.keyword LIKE 'mander%'", w[0])
assert.Equal(t, "k.keyword LIKE '李'", w[1])
} else {
t.Fatalf("unexpected result: %#v", w)
}
w, v := LikeAllKeywords("k.keyword", "Jo Mander 李")
assert.Equal(t, []string{"k.keyword LIKE ?", "k.keyword LIKE ?"}, w)
assert.Equal(t, [][]any{{"mander%"}, {"李"}}, v)
})
}
func TestLikeAllWords(t *testing.T) {
t.Run("Keywords", func(t *testing.T) {
if w := LikeAllWords("k.name", "Jo Mander 王"); len(w) == 3 {
assert.Equal(t, "k.name LIKE 'jo%'", w[0])
assert.Equal(t, "k.name LIKE 'mander%'", w[1])
assert.Equal(t, "k.name LIKE '王%'", w[2])
} else {
t.Fatalf("unexpected result: %#v", w)
}
w, v := LikeAllWords("k.name", "Jo Mander 王")
assert.Equal(t, []string{"k.name LIKE ?", "k.name LIKE ?", "k.name LIKE ?"}, w)
assert.Equal(t, [][]any{{"jo%"}, {"mander%"}, {"王%"}}, v)
})
}
func TestLikeAllNames(t *testing.T) {
t.Run("MultipleNames", func(t *testing.T) {
if w := LikeAllNames(Cols{"k.name"}, "j Mander 王"); len(w) == 1 {
assert.Equal(t, "k.name LIKE 'j Mander 王%'", w[0])
} else {
t.Fatalf("unexpected result: %#v", w)
}
w, v := LikeAllNames(Cols{"k.name"}, "j Mander 王")
assert.Equal(t, []string{"k.name LIKE ?"}, w)
assert.Equal(t, [][]any{{"j Mander 王%"}}, v)
})
t.Run("MultipleColumns", func(t *testing.T) {
if w := LikeAllNames(Cols{"a.col1", "b.col2"}, "Mo Mander"); len(w) == 1 {
assert.Equal(t, "a.col1 LIKE 'Mo Mander%' OR b.col2 LIKE 'Mo Mander%'", w[0])
} else {
t.Fatalf("unexpected result: %#v", w)
}
w, v := LikeAllNames(Cols{"a.col1", "b.col2"}, "Mo Mander")
assert.Equal(t, []string{"a.col1 LIKE ? OR b.col2 LIKE ?"}, w)
assert.Equal(t, [][]any{{"Mo Mander%", "Mo Mander%"}}, v)
})
t.Run("EmptyName", func(t *testing.T) {
w := LikeAllNames(Cols{"k.name"}, "")
w, v := LikeAllNames(Cols{"k.name"}, "")
assert.Empty(t, w)
assert.Empty(t, v)
})
t.Run("EmptyCols", func(t *testing.T) {
w, v := LikeAllNames(Cols{}, "anything")
assert.Empty(t, w)
assert.Empty(t, v)
})
t.Run("SingleCharacter", func(t *testing.T) {
if w := LikeAllNames(Cols{"k.name"}, "a"); len(w) == 1 {
assert.Equal(t, "k.name LIKE '%a%'", w[0])
} else {
t.Fatalf("unexpected result: %#v", w)
}
w, v := LikeAllNames(Cols{"k.name"}, "a")
assert.Equal(t, []string{"k.name LIKE ?"}, w)
assert.Equal(t, [][]any{{"%a%"}}, v)
})
t.Run("FullNames", func(t *testing.T) {
if w := LikeAllNames(Cols{"j.name", "j.alias"}, "Bill & Melinda Gates"); len(w) == 2 {
assert.Equal(t, "j.name LIKE '%Bill%' OR j.alias LIKE '%Bill%'", w[0])
assert.Equal(t, "j.name LIKE 'Melinda Gates%' OR j.alias LIKE 'Melinda Gates%'", w[1])
} else {
t.Fatalf("unexpected result: %#v", w)
}
w, v := LikeAllNames(Cols{"j.name", "j.alias"}, "Bill & Melinda Gates")
assert.Equal(t, []string{"j.name LIKE ? OR j.alias LIKE ?", "j.name LIKE ? OR j.alias LIKE ?"}, w)
assert.Equal(t, [][]any{{"%Bill%", "%Bill%"}, {"Melinda Gates%", "Melinda Gates%"}}, v)
})
t.Run("Plus", func(t *testing.T) {
if w := LikeAllNames(Cols{"name"}, clean.SearchQuery("Paul + Paula")); len(w) == 2 {
assert.Equal(t, "name LIKE '%Paul%'", w[0])
assert.Equal(t, "name LIKE '%Paula%'", w[1])
} else {
t.Fatalf("unexpected result: %#v", w)
}
w, v := LikeAllNames(Cols{"name"}, clean.SearchQuery("Paul + Paula"))
assert.Equal(t, []string{"name LIKE ?", "name LIKE ?"}, w)
assert.Equal(t, [][]any{{"%Paul%"}, {"%Paula%"}}, v)
})
t.Run("And", func(t *testing.T) {
if w := LikeAllNames(Cols{"name"}, clean.SearchQuery("P and Paula")); len(w) == 2 {
assert.Equal(t, "name LIKE '%P%'", w[0])
assert.Equal(t, "name LIKE '%Paula%'", w[1])
} else {
t.Fatalf("unexpected result: %#v", w)
}
w, v := LikeAllNames(Cols{"name"}, clean.SearchQuery("P and Paula"))
assert.Equal(t, []string{"name LIKE ?", "name LIKE ?"}, w)
assert.Equal(t, [][]any{{"%P%"}, {"%Paula%"}}, v)
})
t.Run("Or", func(t *testing.T) {
if w := LikeAllNames(Cols{"name"}, clean.SearchQuery("Paul or Paula")); len(w) == 1 {
assert.Equal(t, "name LIKE '%Paul%' OR name LIKE '%Paula%'", w[0])
} else {
t.Fatalf("unexpected result: %#v", w)
}
w, v := LikeAllNames(Cols{"name"}, clean.SearchQuery("Paul or Paula"))
assert.Equal(t, []string{"name LIKE ? OR name LIKE ?"}, w)
assert.Equal(t, [][]any{{"%Paul%", "%Paula%"}}, v)
})
t.Run("LengthAlignment", func(t *testing.T) {
w, v := LikeAllNames(Cols{"j.name", "j.alias"}, "Bill & Melinda Gates")
assert.Equal(t, len(w), len(v))
})
}
func TestAnySlug(t *testing.T) {
t.Run("Multiple", func(t *testing.T) {
where := AnySlug("custom_slug", "table spoon usa img json", " ")
assert.Equal(t, "custom_slug = 'table' OR custom_slug = 'spoon' OR custom_slug = 'usa' OR custom_slug = 'img' OR custom_slug = 'json'", where)
w, v := AnySlug("custom_slug", "table spoon usa img json", " ")
assert.Equal(t, "custom_slug = ? OR custom_slug = ? OR custom_slug = ? OR custom_slug = ? OR custom_slug = ?", w)
assert.Equal(t, []any{"table", "spoon", "usa", "img", "json"}, v)
})
t.Run("CatDog", func(t *testing.T) {
where := AnySlug("custom_slug", "cat dog", " ")
assert.Equal(t, "custom_slug = 'cat' OR custom_slug = 'dog'", where)
w, v := AnySlug("custom_slug", "cat dog", " ")
assert.Equal(t, "custom_slug = ? OR custom_slug = ?", w)
assert.Equal(t, []any{"cat", "dog"}, v)
})
t.Run("CatsDogs", func(t *testing.T) {
where := AnySlug("custom_slug", "cats dogs", " ")
assert.Equal(t, "custom_slug = 'cats' OR custom_slug = 'cat' OR custom_slug = 'dogs' OR custom_slug = 'dog'", where)
w, v := AnySlug("custom_slug", "cats dogs", " ")
assert.Equal(t, "custom_slug = ? OR custom_slug = ? OR custom_slug = ? OR custom_slug = ?", w)
assert.Equal(t, []any{"cats", "cat", "dogs", "dog"}, v)
})
t.Run("Spoon", func(t *testing.T) {
where := AnySlug("custom_slug", "spoon", " ")
assert.Equal(t, "custom_slug = 'spoon'", where)
w, v := AnySlug("custom_slug", "spoon", " ")
assert.Equal(t, "custom_slug = ?", w)
assert.Equal(t, []any{"spoon"}, v)
})
t.Run("Img", func(t *testing.T) {
where := AnySlug("custom_slug", "img", " ")
assert.Equal(t, "custom_slug = 'img'", where)
w, v := AnySlug("custom_slug", "img", " ")
assert.Equal(t, "custom_slug = ?", w)
assert.Equal(t, []any{"img"}, v)
})
t.Run("Space", func(t *testing.T) {
where := AnySlug("custom_slug", " ", "")
assert.Equal(t, "custom_slug = '' OR custom_slug = ''", where)
w, v := AnySlug("custom_slug", " ", "")
assert.Equal(t, "custom_slug = ? OR custom_slug = ?", w)
assert.Equal(t, []any{"", ""}, v)
})
t.Run("Empty", func(t *testing.T) {
where := AnySlug("custom_slug", "", " ")
assert.Equal(t, "", where)
w, v := AnySlug("custom_slug", "", " ")
assert.Equal(t, "", w)
assert.Empty(t, v)
})
t.Run("CommaSeparated", func(t *testing.T) {
where := AnySlug("custom_slug", "botanical-garden|landscape|bay", txt.Or)
assert.Equal(t, "custom_slug = 'botanical-garden' OR custom_slug = 'landscape' OR custom_slug = 'bay'", where)
w, v := AnySlug("custom_slug", "botanical-garden,landscape,bay", ",")
assert.Equal(t, "custom_slug = ? OR custom_slug = ? OR custom_slug = ?", w)
assert.Equal(t, []any{"botanical-garden", "landscape", "bay"}, v)
})
t.Run("PipeSeparated", func(t *testing.T) {
w, v := AnySlug("custom_slug", "botanical-garden|landscape|bay", txt.Or)
assert.Equal(t, "custom_slug = ? OR custom_slug = ? OR custom_slug = ?", w)
assert.Equal(t, []any{"botanical-garden", "landscape", "bay"}, v)
})
t.Run("Emoji", func(t *testing.T) {
where := AnySlug("custom_slug", "💐", "|")
assert.Equal(t, "custom_slug = '_5cpzfea'", where)
w, v := AnySlug("custom_slug", "💐", "|")
assert.Equal(t, "custom_slug = ?", w)
assert.Equal(t, []any{"_5cpzfea"}, v)
})
t.Run("EmojiSlug", func(t *testing.T) {
where := AnySlug("custom_slug", "_5cpzfea", "|")
assert.Equal(t, "custom_slug = '_5cpzfea'", where)
w, v := AnySlug("custom_slug", "_5cpzfea", "|")
assert.Equal(t, "custom_slug = ?", w)
assert.Equal(t, []any{"_5cpzfea"}, v)
})
t.Run("SqlInjectionAttempt", func(t *testing.T) {
// Single quotes survive in the bound value because the parameter binder
// escapes them; nothing leaks into the SQL fragment itself.
w, v := AnySlug("custom_slug", "foo'; DROP TABLE photos--", "|")
assert.Equal(t, "custom_slug = ?", w)
assert.Equal(t, []any{"foo-drop-table-photos"}, v)
})
}
func TestAnyInt(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
where := AnyInt("photos.photo_month", "", txt.Or, entity.UnknownMonth, txt.MonthMax)
assert.Equal(t, "", where)
w, v := AnyInt("photos.photo_month", "", txt.Or, entity.UnknownMonth, txt.MonthMax)
assert.Equal(t, "", w)
assert.Empty(t, v)
})
t.Run("Range", func(t *testing.T) {
where := AnyInt("photos.photo_month", "-3|0|10|9|11|12|13", txt.Or, entity.UnknownMonth, txt.MonthMax)
assert.Equal(t, "photos.photo_month = 10 OR photos.photo_month = 9 OR photos.photo_month = 11 OR photos.photo_month = 12", where)
w, v := AnyInt("photos.photo_month", "-3|0|10|9|11|12|13", txt.Or, entity.UnknownMonth, txt.MonthMax)
assert.Equal(t, "photos.photo_month = ? OR photos.photo_month = ? OR photos.photo_month = ? OR photos.photo_month = ?", w)
assert.Equal(t, []any{10, 9, 11, 12}, v)
})
t.Run("Chars", func(t *testing.T) {
where := AnyInt("photos.photo_month", "a|b|c", txt.Or, entity.UnknownMonth, txt.MonthMax)
assert.Equal(t, "", where)
w, v := AnyInt("photos.photo_month", "a|b|c", txt.Or, entity.UnknownMonth, txt.MonthMax)
assert.Equal(t, "", w)
assert.Empty(t, v)
})
t.Run("CommaSeparated", func(t *testing.T) {
where := AnyInt("photos.photo_month", "-3,10,9,11,12,13", ",", entity.UnknownMonth, txt.MonthMax)
assert.Equal(t, "photos.photo_month = 10 OR photos.photo_month = 9 OR photos.photo_month = 11 OR photos.photo_month = 12", where)
w, v := AnyInt("photos.photo_month", "-3,10,9,11,12,13", ",", entity.UnknownMonth, txt.MonthMax)
assert.Equal(t, "photos.photo_month = ? OR photos.photo_month = ? OR photos.photo_month = ? OR photos.photo_month = ?", w)
assert.Equal(t, []any{10, 9, 11, 12}, v)
})
t.Run("Invalid", func(t *testing.T) {
where := AnyInt("photos.photo_month", " , | ", ",", entity.UnknownMonth, txt.MonthMax)
assert.Equal(t, "", where)
w, v := AnyInt("photos.photo_month", " , | ", ",", entity.UnknownMonth, txt.MonthMax)
assert.Equal(t, "", w)
assert.Empty(t, v)
})
t.Run("SqlInjectionAttempt", func(t *testing.T) {
// Any token that isn't a clean integer is discarded entirely — the SQL
// fragment never sees the injected payload.
w, v := AnyInt("photos.photo_month", "1; DROP TABLE photos|2", txt.Or, entity.UnknownMonth, txt.MonthMax)
assert.Equal(t, "photos.photo_month = ?", w)
assert.Equal(t, []any{2}, v)
})
}

View file

@ -399,14 +399,15 @@ func searchPhotos(frm form.SearchPhotos, sess *entity.Session, resultCols string
if labelIds, labelsErr := entity.FindLabelIDs(frm.Query, " ", true); labelsErr != nil || len(labelIds) == 0 {
log.Tracef("search: label %s not found, using fuzzy search", txt.LogParamLower(frm.Query))
for _, where := range LikeAnyKeyword("k.keyword", frm.Query) {
s = s.Where("files.photo_id IN (SELECT pk.photo_id FROM keywords k JOIN photos_keywords pk ON k.id = pk.keyword_id WHERE (?))", gorm.Expr(where))
wheres, values := LikeAnyKeyword("k.keyword", frm.Query)
for i, where := range wheres {
s = s.Where("files.photo_id IN (SELECT pk.photo_id FROM keywords k JOIN photos_keywords pk ON k.id = pk.keyword_id WHERE (?))", gorm.Expr(where, values[i]...))
}
} else {
if wheres := LikeAnyKeyword("k.keyword", frm.Query); len(wheres) > 0 {
for _, where := range wheres {
if wheres, values := LikeAnyKeyword("k.keyword", frm.Query); len(wheres) > 0 {
for i, where := range wheres {
s = s.Where("files.photo_id IN (SELECT pk.photo_id FROM keywords k JOIN photos_keywords pk ON k.id = pk.keyword_id WHERE (?)) OR "+
"files.photo_id IN (SELECT pl.photo_id FROM photos_labels pl WHERE pl.uncertainty < 100 AND pl.label_id IN (?))", gorm.Expr(where), labelIds)
"files.photo_id IN (SELECT pl.photo_id FROM photos_labels pl WHERE pl.uncertainty < 100 AND pl.label_id IN (?))", gorm.Expr(where, values[i]...), labelIds)
}
} else {
s = s.Where("files.photo_id IN (SELECT pl.photo_id FROM photos_labels pl WHERE pl.uncertainty < 100 AND pl.label_id IN (?))", labelIds)
@ -416,8 +417,9 @@ func searchPhotos(frm form.SearchPhotos, sess *entity.Session, resultCols string
// Search for one or more keywords.
if txt.NotEmpty(frm.Keywords) {
for _, where := range LikeAnyWord("k.keyword", frm.Keywords) {
s = s.Where("files.photo_id IN (SELECT pk.photo_id FROM keywords k JOIN photos_keywords pk ON k.id = pk.keyword_id WHERE (?))", gorm.Expr(where))
wheres, values := LikeAnyWord("k.keyword", frm.Keywords)
for i, where := range wheres {
s = s.Where("files.photo_id IN (SELECT pk.photo_id FROM keywords k JOIN photos_keywords pk ON k.id = pk.keyword_id WHERE (?))", gorm.Expr(where, values[i]...))
}
}
@ -464,14 +466,16 @@ func searchPhotos(frm form.SearchPhotos, sess *entity.Session, resultCols string
s = s.Where(fmt.Sprintf("files.photo_id IN (SELECT photo_id FROM files f JOIN %s m ON f.file_uid = m.file_uid AND m.marker_invalid = 0 WHERE subj_uid IN (?))",
entity.Marker{}.TableName()), subjects)
} else {
w, v := AnySlug("s.subj_slug", subj, txt.Or)
s = s.Where(fmt.Sprintf("files.photo_id IN (SELECT photo_id FROM files f JOIN %s m ON f.file_uid = m.file_uid AND m.marker_invalid = 0 JOIN %s s ON s.subj_uid = m.subj_uid WHERE (?))",
entity.Marker{}.TableName(), entity.Subject{}.TableName()), gorm.Expr(AnySlug("s.subj_slug", subj, txt.Or)))
entity.Marker{}.TableName(), entity.Subject{}.TableName()), gorm.Expr(w, v...))
}
}
} else if txt.NotEmpty(frm.Subjects) {
for _, where := range LikeAllNames(Cols{"subj_name", "subj_alias"}, frm.Subjects) {
wheres, values := LikeAllNames(Cols{"subj_name", "subj_alias"}, frm.Subjects)
for i, where := range wheres {
s = s.Where(fmt.Sprintf("files.photo_id IN (SELECT photo_id FROM files f JOIN %s m ON f.file_uid = m.file_uid AND m.marker_invalid = 0 JOIN %s s ON s.subj_uid = m.subj_uid WHERE (?))",
entity.Marker{}.TableName(), entity.Subject{}.TableName()), gorm.Expr(where))
entity.Marker{}.TableName(), entity.Subject{}.TableName()), gorm.Expr(where, values[i]...))
}
}
@ -532,17 +536,20 @@ func searchPhotos(frm form.SearchPhotos, sess *entity.Session, resultCols string
// Filter by year.
if frm.Year != "" {
s = s.Where(AnyInt("photos.photo_year", frm.Year, txt.Or, entity.UnknownYear, txt.YearMax))
w, v := AnyInt("photos.photo_year", frm.Year, txt.Or, entity.UnknownYear, txt.YearMax)
s = s.Where(w, v...)
}
// Filter by month.
if frm.Month != "" {
s = s.Where(AnyInt("photos.photo_month", frm.Month, txt.Or, entity.UnknownMonth, txt.MonthMax))
w, v := AnyInt("photos.photo_month", frm.Month, txt.Or, entity.UnknownMonth, txt.MonthMax)
s = s.Where(w, v...)
}
// Filter by day.
if frm.Day != "" {
s = s.Where(AnyInt("photos.photo_day", frm.Day, txt.Or, entity.UnknownDay, txt.DayMax))
w, v := AnyInt("photos.photo_day", frm.Day, txt.Or, entity.UnknownDay, txt.DayMax)
s = s.Where(w, v...)
}
// Filter by Resolution in Megapixels (MP).
@ -810,8 +817,9 @@ func searchPhotos(frm form.SearchPhotos, sess *entity.Session, resultCols string
v := strings.Trim(frm.Album, "*%") + "%"
s = s.Where("photos.photo_uid IN (SELECT pa.photo_uid FROM photos_albums pa JOIN albums a ON a.album_uid = pa.album_uid AND pa.hidden = 0 WHERE (a.album_title LIKE ? OR a.album_slug LIKE ?))", v, v)
} else if txt.NotEmpty(frm.Albums) {
for _, where := range LikeAnyWord("a.album_title", frm.Albums) {
s = s.Where("photos.photo_uid IN (SELECT pa.photo_uid FROM photos_albums pa JOIN albums a ON a.album_uid = pa.album_uid AND pa.hidden = 0 WHERE (?))", gorm.Expr(where))
wheres, values := LikeAnyWord("a.album_title", frm.Albums)
for i, where := range wheres {
s = s.Where("photos.photo_uid IN (SELECT pa.photo_uid FROM photos_albums pa JOIN albums a ON a.album_uid = pa.album_uid AND pa.hidden = 0 WHERE (?))", gorm.Expr(where, values[i]...))
}
}
}

View file

@ -303,14 +303,15 @@ func UserPhotosGeo(frm form.SearchPhotosGeo, sess *entity.Session) (results GeoR
if labelIds, labelsErr := entity.FindLabelIDs(frm.Query, " ", true); labelsErr != nil || len(labelIds) == 0 {
log.Tracef("search: label %s not found, using fuzzy search", txt.LogParamLower(frm.Query))
for _, where := range LikeAnyKeyword("k.keyword", frm.Query) {
s = s.Where("photos.id IN (SELECT pk.photo_id FROM keywords k JOIN photos_keywords pk ON k.id = pk.keyword_id WHERE (?))", gorm.Expr(where))
wheres, values := LikeAnyKeyword("k.keyword", frm.Query)
for i, where := range wheres {
s = s.Where("photos.id IN (SELECT pk.photo_id FROM keywords k JOIN photos_keywords pk ON k.id = pk.keyword_id WHERE (?))", gorm.Expr(where, values[i]...))
}
} else {
if wheres := LikeAnyKeyword("k.keyword", frm.Query); len(wheres) > 0 {
for _, where := range wheres {
if wheres, values := LikeAnyKeyword("k.keyword", frm.Query); len(wheres) > 0 {
for i, where := range wheres {
s = s.Where("photos.id IN (SELECT pk.photo_id FROM keywords k JOIN photos_keywords pk ON k.id = pk.keyword_id WHERE (?)) OR "+
"photos.id IN (SELECT pl.photo_id FROM photos_labels pl WHERE pl.uncertainty < 100 AND pl.label_id IN (?))", gorm.Expr(where), labelIds)
"photos.id IN (SELECT pl.photo_id FROM photos_labels pl WHERE pl.uncertainty < 100 AND pl.label_id IN (?))", gorm.Expr(where, values[i]...), labelIds)
}
} else {
s = s.Where("photos.id IN (SELECT pl.photo_id FROM photos_labels pl WHERE pl.uncertainty < 100 AND pl.label_id IN (?))", labelIds)
@ -320,8 +321,9 @@ func UserPhotosGeo(frm form.SearchPhotosGeo, sess *entity.Session) (results GeoR
// Search for one or more keywords.
if frm.Keywords != "" {
for _, where := range LikeAnyWord("k.keyword", frm.Keywords) {
s = s.Where("photos.id IN (SELECT pk.photo_id FROM keywords k JOIN photos_keywords pk ON k.id = pk.keyword_id WHERE (?))", gorm.Expr(where))
wheres, values := LikeAnyWord("k.keyword", frm.Keywords)
for i, where := range wheres {
s = s.Where("photos.id IN (SELECT pk.photo_id FROM keywords k JOIN photos_keywords pk ON k.id = pk.keyword_id WHERE (?))", gorm.Expr(where, values[i]...))
}
}
@ -368,14 +370,16 @@ func UserPhotosGeo(frm form.SearchPhotosGeo, sess *entity.Session) (results GeoR
s = s.Where(fmt.Sprintf("photos.id IN (SELECT photo_id FROM files f JOIN %s m ON f.file_uid = m.file_uid AND m.marker_invalid = 0 WHERE subj_uid IN (?))",
entity.Marker{}.TableName()), subjects)
} else {
w, v := AnySlug("s.subj_slug", subj, txt.Or)
s = s.Where(fmt.Sprintf("photos.id IN (SELECT photo_id FROM files f JOIN %s m ON f.file_uid = m.file_uid AND m.marker_invalid = 0 JOIN %s s ON s.subj_uid = m.subj_uid WHERE (?))",
entity.Marker{}.TableName(), entity.Subject{}.TableName()), gorm.Expr(AnySlug("s.subj_slug", subj, txt.Or)))
entity.Marker{}.TableName(), entity.Subject{}.TableName()), gorm.Expr(w, v...))
}
}
} else if frm.Subjects != "" {
for _, where := range LikeAllNames(Cols{"subj_name", "subj_alias"}, frm.Subjects) {
wheres, values := LikeAllNames(Cols{"subj_name", "subj_alias"}, frm.Subjects)
for i, where := range wheres {
s = s.Where(fmt.Sprintf("photos.id IN (SELECT photo_id FROM files f JOIN %s m ON f.file_uid = m.file_uid AND m.marker_invalid = 0 JOIN %s s ON s.subj_uid = m.subj_uid WHERE (?))",
entity.Marker{}.TableName(), entity.Subject{}.TableName()), gorm.Expr(where))
entity.Marker{}.TableName(), entity.Subject{}.TableName()), gorm.Expr(where, values[i]...))
}
}
@ -387,8 +391,9 @@ func UserPhotosGeo(frm form.SearchPhotosGeo, sess *entity.Session) (results GeoR
v := strings.Trim(frm.Album, "*%") + "%"
s = s.Where("photos.photo_uid IN (SELECT pa.photo_uid FROM photos_albums pa JOIN albums a ON a.album_uid = pa.album_uid AND pa.hidden = 0 WHERE (a.album_title LIKE ? OR a.album_slug LIKE ?))", v, v)
} else if txt.NotEmpty(frm.Albums) {
for _, where := range LikeAnyWord("a.album_title", frm.Albums) {
s = s.Where("photos.photo_uid IN (SELECT pa.photo_uid FROM photos_albums pa JOIN albums a ON a.album_uid = pa.album_uid AND pa.hidden = 0 WHERE (?))", gorm.Expr(where))
wheres, values := LikeAnyWord("a.album_title", frm.Albums)
for i, where := range wheres {
s = s.Where("photos.photo_uid IN (SELECT pa.photo_uid FROM photos_albums pa JOIN albums a ON a.album_uid = pa.album_uid AND pa.hidden = 0 WHERE (?))", gorm.Expr(where, values[i]...))
}
}
}
@ -420,17 +425,20 @@ func UserPhotosGeo(frm form.SearchPhotosGeo, sess *entity.Session) (results GeoR
// Filter by year.
if frm.Year != "" {
s = s.Where(AnyInt("photos.photo_year", frm.Year, txt.Or, entity.UnknownYear, txt.YearMax))
w, v := AnyInt("photos.photo_year", frm.Year, txt.Or, entity.UnknownYear, txt.YearMax)
s = s.Where(w, v...)
}
// Filter by month.
if frm.Month != "" {
s = s.Where(AnyInt("photos.photo_month", frm.Month, txt.Or, entity.UnknownMonth, txt.MonthMax))
w, v := AnyInt("photos.photo_month", frm.Month, txt.Or, entity.UnknownMonth, txt.MonthMax)
s = s.Where(w, v...)
}
// Filter by day.
if frm.Day != "" {
s = s.Where(AnyInt("photos.photo_day", frm.Day, txt.Or, entity.UnknownDay, txt.DayMax))
w, v := AnyInt("photos.photo_day", frm.Day, txt.Or, entity.UnknownDay, txt.DayMax)
s = s.Where(w, v...)
}
// Filter by Resolution in Megapixels (MP).

View file

@ -56,8 +56,9 @@ func Subjects(frm form.SearchSubjects) (results SubjectResults, err error) {
}
if frm.Query != "" {
for _, where := range LikeAllNames(Cols{"subj_name", "subj_alias"}, frm.Query) {
s = s.Where("?", gorm.Expr(where))
wheres, values := LikeAllNames(Cols{"subj_name", "subj_alias"}, frm.Query)
for i, where := range wheres {
s = s.Where("?", gorm.Expr(where, values[i]...))
}
}
@ -121,7 +122,7 @@ func SubjectUIDs(s string) (result []string, names []string, remaining string) {
var matches []Matches
wheres := LikeAllNames(Cols{"subj_name", "subj_alias"}, s)
wheres, values := LikeAllNames(Cols{"subj_name", "subj_alias"}, s)
if len(wheres) == 0 {
return result, names, s
@ -129,11 +130,11 @@ func SubjectUIDs(s string) (result []string, names []string, remaining string) {
remaining = s
for _, where := range wheres {
for i, where := range wheres {
var subj []string
stmt := Db().Model(entity.Subject{})
stmt = stmt.Where("?", gorm.Expr(where))
stmt = stmt.Where("?", gorm.Expr(where, values[i]...))
if err := stmt.Scan(&matches).Error; err != nil {
log.Errorf("search: %s while finding subjects", err)

View file

@ -51,3 +51,37 @@ func SqlString(s string) string {
return string(b[:j])
}
// SqlClean strips bytes that must be omitted from an SQL parameter value
// (currently control characters below 0x20). Quote characters are preserved
// unchanged because the caller is expected to pass the result through a
// parameterized query, where escaping is the driver's responsibility.
func SqlClean(s string) string {
var i int
for i = 0; i < len(s); i++ {
if _, omit := SqlSpecial(s[i]); omit {
break
}
}
// Return unchanged if no omittable characters were found.
if i >= len(s) {
return s
}
b := make([]byte, len(s))
copy(b, s[:i])
j := i
for ; i < len(s); i++ {
if _, omit := SqlSpecial(s[i]); omit {
continue
}
b[j] = s[i]
j++
}
return string(b[:j])
}

View file

@ -82,3 +82,31 @@ func TestSqlString(t *testing.T) {
assert.Equal(t, "123ABCabc", SqlString("123ABCabc"))
})
}
func TestSqlClean(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
assert.Equal(t, "", SqlClean(""))
})
t.Run("Alnum", func(t *testing.T) {
assert.Equal(t, "123ABCabc", SqlClean("123ABCabc"))
})
t.Run("Special", func(t *testing.T) {
// Quotes, backslashes and wildcards are kept; control characters are stripped.
s := "' \" \t \n %_'\\'"
exp := "' \" %_'\\'"
assert.Equal(t, exp, SqlClean(s))
})
t.Run("OmitOnly", func(t *testing.T) {
assert.Equal(t, "", SqlClean("\x00\x01\x02\x1f"))
})
t.Run("EarlyExit", func(t *testing.T) {
// First byte is omittable, so the fast-path early-exit branch is skipped.
assert.Equal(t, "abc", SqlClean("\tabc"))
})
t.Run("Unicode", func(t *testing.T) {
assert.Equal(t, "Clean《MeUp✀☒ちュس", SqlClean("Clean《MeUp✀☒ちュس"))
})
t.Run("TrailingControl", func(t *testing.T) {
assert.Equal(t, "abc", SqlClean("abc\n"))
})
}