Pkg: Move /service/http/... to /http/... and add package /http/dns

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-10-19 21:08:48 +02:00
parent dacb5794f5
commit a921f82a17
172 changed files with 704 additions and 390 deletions

View File

@@ -3,7 +3,7 @@ package clean
import (
"strings"
"github.com/photoprism/photoprism/pkg/service/http/header"
"github.com/photoprism/photoprism/pkg/http/header"
)
// ContentType normalizes media content type strings, see https://en.wikipedia.org/wiki/Media_type.

View File

@@ -1,7 +1,7 @@
package clean
import (
"github.com/photoprism/photoprism/pkg/service/http/header"
"github.com/photoprism/photoprism/pkg/http/header"
)
// IP returns the sanitized and normalized network address if it is valid, or the default otherwise.

View File

@@ -1,7 +1,7 @@
package fs
import (
"github.com/photoprism/photoprism/pkg/service/http/header"
"github.com/photoprism/photoprism/pkg/http/header"
)
// TypeAnimated maps animated file types to their mime type.

View File

@@ -32,7 +32,7 @@ import (
"path/filepath"
"syscall"
"github.com/photoprism/photoprism/pkg/service/http/safe"
"github.com/photoprism/photoprism/pkg/http/safe"
)
var ignoreCase bool

View File

@@ -7,7 +7,7 @@ import (
"github.com/gabriel-vasile/mimetype"
"github.com/photoprism/photoprism/pkg/service/http/header"
"github.com/photoprism/photoprism/pkg/http/header"
)
const (

View File

@@ -5,7 +5,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/pkg/service/http/header"
"github.com/photoprism/photoprism/pkg/http/header"
)
func TestDetectMimeType(t *testing.T) {

26
pkg/http/dns/dns.go Normal file
View File

@@ -0,0 +1,26 @@
/*
Package dns provides helpers for validating and classifying hostnames that are
safe to use in cluster URLs, node identifiers, and other HTTP-facing settings.
Copyright (c) 2018 - 2025 PhotoPrism UG. All rights reserved.
This program is free software: you can redistribute it and/or modify
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
<https://docs.photoprism.app/license/agpl>
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
The AGPL is supplemented by our Trademark and Brand Guidelines,
which describe how our Brand Assets may be used:
<https://www.photoprism.app/trademark>
Feel free to send an email to hello@photoprism.app if you have questions,
want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/>
*/
package dns

73
pkg/http/dns/domain.go Normal file
View File

@@ -0,0 +1,73 @@
package dns
import (
"net"
"os"
"strings"
)
// GetHostname is a var to allow tests to stub os.Hostname.
var GetHostname = os.Hostname
// IsDomain validates a DNS domain (FQDN or single label not allowed here). It must have at least one dot.
// Each label must match IsLabel (except overall length and hyphen rules already covered by regex logic).
func IsDomain(d string) bool {
d = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(d)), ".")
if d == "" || strings.Count(d, ".") < 1 || len(d) > 253 {
return false
}
if _, bad := ReservedDomains[d]; bad {
return false
}
if IsLocalSuffix(d) {
return false
}
parts := strings.Split(d, ".")
for _, p := range parts {
if !IsLabel(p) {
return false
}
}
return true
}
// GetSystemDomain tries to determine a usable cluster domain from system configuration.
// It uses the system hostname and returns the domain (everything after the first dot) when valid and not reserved.
func GetSystemDomain() string {
hn, _ := GetHostname()
hn = strings.ToLower(strings.TrimSpace(hn))
if hn == "" {
return ""
}
if _, bad := NonUniqueHostnames[hn]; bad {
return ""
}
// If hostname contains a dot, take the domain part.
if i := strings.IndexByte(hn, '.'); i > 0 && i < len(hn)-1 {
dom := hn[i+1:]
if IsDomain(dom) {
return dom
}
}
// Try reverse lookup to get FQDN domain, then validate.
if addrs, err := net.LookupAddr(hn); err == nil {
for _, fqdn := range addrs {
fqdn = strings.TrimSuffix(strings.ToLower(fqdn), ".")
if fqdn == "" || fqdn == hn {
continue
}
if i := strings.IndexByte(fqdn, '.'); i > 0 && i < len(fqdn)-1 {
dom := fqdn[i+1:]
if IsDomain(dom) {
return dom
}
}
}
}
return ""
}

View File

@@ -0,0 +1,44 @@
package dns
import (
"testing"
)
func Test_IsDNSLabel(t *testing.T) {
good := []string{"a", "node1", "pp-node-01", "n32", "a234567890123456789012345678901"}
bad := []string{"", "A", "node_1", "-bad", "bad-", stringsRepeat("a", 33)}
for _, s := range good {
if !IsLabel(s) {
t.Fatalf("expected valid label: %q", s)
}
}
for _, s := range bad {
if IsLabel(s) {
t.Fatalf("expected invalid label: %q", s)
}
}
}
func Test_IsDNSDomain(t *testing.T) {
good := []string{"example.dev", "sub.domain.dev", "a.b"}
bad := []string{"localdomain", "localhost", "a", "EXAMPLE.com", "example.com", "invalid", "test", "x.local"}
for _, s := range good {
if !IsDomain(s) {
t.Fatalf("expected valid domain: %q", s)
}
}
for _, s := range bad {
if IsDomain(s) {
t.Fatalf("expected invalid domain: %q", s)
}
}
}
// helper: fast string repeat without importing strings just for tests
func stringsRepeat(s string, n int) string {
b := make([]byte, 0, len(s)*n)
for i := 0; i < n; i++ {
b = append(b, s...)
}
return string(b)
}

16
pkg/http/dns/label.go Normal file
View File

@@ -0,0 +1,16 @@
package dns
import (
"regexp"
)
var labelRegex = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$`)
// IsLabel returns true if s is a valid DNS label per our rules: lowercase, [a-z0-9-], 132 chars, starts/ends alnum.
func IsLabel(s string) bool {
if s == "" || len(s) > 32 {
return false
}
return labelRegex.MatchString(s)
}

42
pkg/http/dns/local.go Normal file
View File

@@ -0,0 +1,42 @@
package dns
import (
"net"
"strings"
)
// NonUniqueHostnames lists hostnames that must never be used as node name or to
// derive a cluster domain. It is mutable on purpose so tests or operators can
// extend the set without changing the package API.
var NonUniqueHostnames = map[string]struct{}{
"localhost": {},
"localhost.localdomain": {},
"localdomain": {},
}
// IsLocalSuffix reports whether the provided suffix is considered local-only
// (for example mDNS domains ending in .local) and therefore unsuitable when
// deriving public cluster domains.
func IsLocalSuffix(suffix string) bool {
return suffix == "local" || strings.HasSuffix(suffix, ".local")
}
// IsLoopbackHost reports whether host refers to a loopback address that is safe
// to contact over plain HTTP during bootstrap. It accepts hostnames (e.g.
// "localhost") as well as IPv4/IPv6 addresses and normalises case/whitespace.
func IsLoopbackHost(host string) bool {
h := strings.TrimSpace(strings.ToLower(host))
if h == "" {
return false
}
if h == "localhost" {
return true
}
if ip := net.ParseIP(h); ip != nil {
return ip.IsLoopback()
}
return false
}

View File

@@ -0,0 +1,49 @@
package dns
import "testing"
func TestIsLocalSuffix(t *testing.T) {
cases := []struct {
name string
input string
expect bool
}{
{"Local mDNS", "local", true},
{"Nested local", "sub.local", true},
{"Regular domain", "example.dev", false},
{"Empty", "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := IsLocalSuffix(tc.input); got != tc.expect {
t.Fatalf("IsLocalSuffix(%q) = %v, want %v", tc.input, got, tc.expect)
}
})
}
}
func TestIsLoopbackHost(t *testing.T) {
cases := []struct {
name string
input string
expect bool
}{
{"Empty", "", false},
{"Localhost", "localhost", true},
{"Mixed case host", "LOCALHOST", true},
{"Loopback IPv4", "127.0.0.42", true},
{"Loopback IPv6", "::1", true},
{"Trim whitespace", " 127.0.0.1 ", true},
{"Regular IPv4", "192.168.0.1", false},
{"Regular host", "node.example", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := IsLoopbackHost(tc.input); got != tc.expect {
t.Fatalf("IsLoopbackHost(%q) = %v, want %v", tc.input, got, tc.expect)
}
})
}
}

10
pkg/http/dns/reserved.go Normal file
View File

@@ -0,0 +1,10 @@
package dns
// ReservedDomains lists special/reserved domains that must not be used as cluster domains.
var ReservedDomains = map[string]struct{}{
"example.com": {},
"example.net": {},
"example.org": {},
"invalid": {},
"test": {},
}

View File

@@ -13,8 +13,8 @@ import (
"github.com/gabriel-vasile/mimetype"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/service/http/header"
"github.com/photoprism/photoprism/pkg/service/http/scheme"
"github.com/photoprism/photoprism/pkg/http/header"
"github.com/photoprism/photoprism/pkg/http/scheme"
)
// DataUrl generates a data URL of the binary data from the specified io.Reader.

View File

@@ -7,7 +7,7 @@ import (
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/service/http/header"
"github.com/photoprism/photoprism/pkg/http/header"
)
// ContentType returns a normalized video content type strings based on the video file type and codec.

View File

@@ -5,7 +5,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/pkg/service/http/header"
"github.com/photoprism/photoprism/pkg/http/header"
)
func TestContentType(t *testing.T) {

View File

@@ -4,8 +4,8 @@ import (
"time"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/http/header"
"github.com/photoprism/photoprism/pkg/media"
"github.com/photoprism/photoprism/pkg/service/http/header"
)
// Info represents video file information.

View File

@@ -7,7 +7,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/service/http/header"
"github.com/photoprism/photoprism/pkg/http/header"
)
func TestInfo(t *testing.T) {

View File

@@ -10,8 +10,8 @@ import (
"github.com/abema/go-mp4"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/http/header"
"github.com/photoprism/photoprism/pkg/media"
"github.com/photoprism/photoprism/pkg/service/http/header"
)
// ProbeFile returns information for the given filename.

View File

@@ -8,8 +8,8 @@ import (
"github.com/stretchr/testify/require"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/http/header"
"github.com/photoprism/photoprism/pkg/media"
"github.com/photoprism/photoprism/pkg/service/http/header"
)
func TestProbeFile(t *testing.T) {

View File

@@ -2,7 +2,7 @@ package video
import (
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/service/http/header"
"github.com/photoprism/photoprism/pkg/http/header"
)
// Unknown represents an unknown and/or unsupported video format.