Please enter the commit message for your changes. Lines starting
with '#' will be ignored, and an empty message aborts the commit. On branch main Initial commit Changes to be committed: new file: .DS_Store new file: .env new file: .gitignore new file: ai-worker/Dockerfile new file: ai-worker/requirements.txt new file: ai-worker/worker.py new file: background-worker/Dockerfile new file: background-worker/go.mod new file: background-worker/go.sum new file: background-worker/main.go new file: background-worker/market.go new file: background-worker/rmv.go new file: background-worker/rss.go new file: background-worker/sql_work.go new file: db/Dockerfile new file: db/init.sql new file: docker-compose.yml new file: server-app/dockerfile new file: server-app/go.mod new file: server-app/go.sum new file: server-app/main.go new file: volumes/.DS_Store new file: volumes/db-init/.DS_Store new file: volumes/db-init/data/news_rss_feeds.csv new file: volumes/web/.DS_Store new file: volumes/web/static/css/blog.css new file: volumes/web/static/css/index-lite.css new file: volumes/web/static/css/index.css new file: volumes/web/static/css/mandelbrot.css new file: volumes/web/static/img/minecraft.png new file: volumes/web/static/js/blog.js new file: volumes/web/static/js/index-lite.js new file: volumes/web/static/js/index.js new file: volumes/web/static/js/mandelbrot.js new file: volumes/web/static/media/cantina.mp3 new file: volumes/web/static/media/countdowns.json new file: volumes/web/static/media/gong.mp4 new file: volumes/web/template/blog.html new file: volumes/web/template/index-lite.html new file: volumes/web/template/index.html new file: volumes/web/template/mandelbrot.html
This commit is contained in:
11
server-app/dockerfile
Normal file
11
server-app/dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM golang:1.24
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["go", "run", "main.go"]
|
||||
11
server-app/go.mod
Normal file
11
server-app/go.mod
Normal file
@@ -0,0 +1,11 @@
|
||||
module server-app
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require github.com/jackc/pgx/v5 v5.8.0
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
)
|
||||
26
server-app/go.sum
Normal file
26
server-app/go.sum
Normal file
@@ -0,0 +1,26 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
450
server-app/main.go
Normal file
450
server-app/main.go
Normal file
@@ -0,0 +1,450 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type defconState struct {
|
||||
mu sync.RWMutex
|
||||
Level int `json:"level"`
|
||||
}
|
||||
|
||||
func (d *defconState) get() int {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
return d.Level
|
||||
}
|
||||
|
||||
func (d *defconState) set(level int) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
d.Level = level
|
||||
}
|
||||
|
||||
var defcon = defconState{Level: 5}
|
||||
var marketOrder = []string{
|
||||
"USD/JPY",
|
||||
"OIL/USD",
|
||||
"XAU/USD",
|
||||
"USD/EUR",
|
||||
"USD/CHF",
|
||||
"USD/GBP",
|
||||
}
|
||||
|
||||
var marketAliases = map[string]string{
|
||||
"yen": "USD/JPY",
|
||||
"oil": "OIL/USD",
|
||||
"gold": "XAU/USD",
|
||||
"usd_eur": "USD/EUR",
|
||||
"usd_chf": "USD/CHF",
|
||||
"usd_gbp": "USD/GBP",
|
||||
"EUR/USD": "USD/EUR",
|
||||
"EUR/GBP": "USD/GBP",
|
||||
}
|
||||
|
||||
var uniMainzNet = mustParseCIDR("134.93.0.0/16")
|
||||
|
||||
func mustParseCIDR(cidr string) *net.IPNet {
|
||||
_, block, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
log.Fatalf("invalid CIDR %q: %v", cidr, err)
|
||||
}
|
||||
return block
|
||||
}
|
||||
|
||||
func clientIP(r *http.Request) net.IP {
|
||||
var candidates []string
|
||||
|
||||
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
||||
parts := strings.Split(fwd, ",")
|
||||
for _, p := range parts {
|
||||
if trimmed := strings.TrimSpace(p); trimmed != "" {
|
||||
candidates = append(candidates, trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if real := strings.TrimSpace(r.Header.Get("X-Real-IP")); real != "" {
|
||||
candidates = append(candidates, real)
|
||||
}
|
||||
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err == nil && host != "" {
|
||||
candidates = append(candidates, host)
|
||||
} else if r.RemoteAddr != "" {
|
||||
candidates = append(candidates, r.RemoteAddr)
|
||||
}
|
||||
|
||||
for _, raw := range candidates {
|
||||
ip := net.ParseIP(raw)
|
||||
if ip == nil {
|
||||
continue
|
||||
}
|
||||
if ip4 := ip.To4(); ip4 != nil {
|
||||
return ip4
|
||||
}
|
||||
return ip
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
||||
func buildDSN() string {
|
||||
if url := os.Getenv("DATABASE_URL"); url != "" {
|
||||
return url
|
||||
}
|
||||
host := envOr("POSTGRES_HOST", "database")
|
||||
port := envOr("POSTGRES_PORT", "5432")
|
||||
user := envOr("POSTGRES_USER", "app")
|
||||
pass := envOr("POSTGRES_PASSWORD", "appsecret")
|
||||
name := envOr("POSTGRES_DB", "appdb")
|
||||
|
||||
return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", user, pass, host, port, name)
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func canonicalInstrumentName(name string) (string, bool) {
|
||||
if canon, ok := marketAliases[name]; ok {
|
||||
name = canon
|
||||
}
|
||||
for _, target := range marketOrder {
|
||||
if target == name {
|
||||
return name, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func fetchLatestQuotes(ctx context.Context, conn *pgx.Conn, requested []string) (map[string]float64, error) {
|
||||
lookup := append([]string{}, requested...)
|
||||
for alias := range marketAliases {
|
||||
lookup = append(lookup, alias)
|
||||
}
|
||||
|
||||
rows, err := conn.Query(ctx, `
|
||||
SELECT instrument, bid
|
||||
FROM market_quotes
|
||||
WHERE instrument = ANY($1)
|
||||
ORDER BY quoted_at DESC
|
||||
`, lookup)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := map[string]float64{}
|
||||
for rows.Next() {
|
||||
var instrument string
|
||||
var bid float64
|
||||
if err := rows.Scan(&instrument, &bid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
canon, ok := canonicalInstrumentName(instrument)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, exists := result[canon]; !exists {
|
||||
result[canon] = bid
|
||||
}
|
||||
}
|
||||
if rows.Err() != nil {
|
||||
return nil, rows.Err()
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func withConn(r *http.Request, fn func(conn *pgx.Conn) error) error {
|
||||
ctx := r.Context()
|
||||
conn, err := pgx.Connect(ctx, buildDSN())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close(ctx)
|
||||
return fn(conn)
|
||||
}
|
||||
|
||||
func serveFile(path string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, filepath.Clean(path))
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
|
||||
|
||||
http.HandleFunc("/", serveFile("web/template/index.html"))
|
||||
http.HandleFunc("/lite", serveFile("web/template/index-lite.html"))
|
||||
http.HandleFunc("/blog", serveFile("web/template/blog.html"))
|
||||
http.HandleFunc("/mandelbrot", serveFile("web/template/mandelbrot.html"))
|
||||
http.HandleFunc("/media/gong", serveFile("web/static/media/gong.mp4"))
|
||||
http.HandleFunc("/media/cantina", serveFile("web/static/media/cantina.mp3"))
|
||||
|
||||
http.HandleFunc("/api/artikeltext", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := withConn(r, func(conn *pgx.Conn) error {
|
||||
|
||||
rows, err := conn.Query(r.Context(), `
|
||||
SELECT title, link, summary, image, published_at
|
||||
FROM articles
|
||||
WHERE image IS NOT NULL
|
||||
ORDER BY COALESCE(published_at, created_at) DESC
|
||||
LIMIT 100
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// 🔑 Header einmal setzen
|
||||
w.Header().Set("Content-Type", "application/x-ndjson; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
// hilft bei manchen Proxies:
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
// Kickstart: sofort 1 Byte senden, damit Streaming „anfängt“
|
||||
|
||||
|
||||
enc := json.NewEncoder(w)
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
|
||||
if !ok {
|
||||
return fmt.Errorf("streaming not supported")
|
||||
}
|
||||
|
||||
w.Write([]byte("\n"))
|
||||
flusher.Flush()
|
||||
|
||||
for rows.Next() {
|
||||
var title, link, summary string
|
||||
var image *string
|
||||
var published *time.Time
|
||||
|
||||
if err := rows.Scan(&title, &link, &summary, &image, &published); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
item := map[string]interface{}{
|
||||
"title": title,
|
||||
"link": link,
|
||||
"text": summary,
|
||||
"image": image,
|
||||
"publishedAt": published,
|
||||
}
|
||||
|
||||
// 👇 EIN Artikel = EIN JSON
|
||||
if err := enc.Encode(item); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
flusher.Flush() // 🚀 sofort senden
|
||||
}
|
||||
|
||||
return rows.Err()
|
||||
|
||||
}); err != nil {
|
||||
log.Printf("artikeltext query failed: %v", err)
|
||||
http.Error(w, "stream error", http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
||||
http.HandleFunc("/api/images", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := withConn(r, func(conn *pgx.Conn) error {
|
||||
rows, err := conn.Query(r.Context(), `
|
||||
SELECT title, image, published_at
|
||||
FROM articles
|
||||
ORDER BY COALESCE(published_at, created_at) DESC
|
||||
LIMIT 10000
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
enc := json.NewEncoder(w)
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
return fmt.Errorf("streaming not supported")
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var title string
|
||||
var image *string
|
||||
var published *time.Time
|
||||
if err := rows.Scan(&title, &image, &published); err != nil {
|
||||
return err
|
||||
}
|
||||
item := map[string]interface{}{
|
||||
"title": title,
|
||||
"image": image,
|
||||
"publishedAt": published,
|
||||
}
|
||||
|
||||
if err := enc.Encode(item); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
if rows.Err() != nil {
|
||||
return rows.Err()
|
||||
}
|
||||
return rows.Err()
|
||||
|
||||
}); err != nil {
|
||||
// Fallback: keine News statt Fehler, damit die Seite weiterlädt.
|
||||
log.Printf("Image query failed, returning empty list: %v", err)
|
||||
writeJSON(w, http.StatusOK, []map[string]interface{}{})
|
||||
}
|
||||
})
|
||||
|
||||
http.HandleFunc("/api/rmv", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := withConn(r, func(conn *pgx.Conn) error {
|
||||
rows, err := conn.Query(r.Context(), `
|
||||
SELECT trip_index, leg_index, linie, abfahrt, ankunft, von, nach
|
||||
FROM rmv_data
|
||||
ORDER BY trip_index, leg_index
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type leg struct {
|
||||
Linie string `json:"linie"`
|
||||
Abfahrt string `json:"abfahrt"`
|
||||
Ankunft string `json:"ankunft"`
|
||||
Von string `json:"von"`
|
||||
Nach string `json:"nach"`
|
||||
}
|
||||
byTrip := map[int][]leg{}
|
||||
var maxTrip int
|
||||
for rows.Next() {
|
||||
var tripIdx, legIdx int
|
||||
var l leg
|
||||
if err := rows.Scan(&tripIdx, &legIdx, &l.Linie, &l.Abfahrt, &l.Ankunft, &l.Von, &l.Nach); err != nil {
|
||||
return err
|
||||
}
|
||||
byTrip[tripIdx] = append(byTrip[tripIdx], l)
|
||||
if tripIdx > maxTrip {
|
||||
maxTrip = tripIdx
|
||||
}
|
||||
}
|
||||
if rows.Err() != nil {
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
var trips [][]leg
|
||||
for i := 0; i <= maxTrip; i++ {
|
||||
if legs, ok := byTrip[i]; ok {
|
||||
trips = append(trips, legs)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"abfahrten": trips})
|
||||
return nil
|
||||
}); err != nil {
|
||||
log.Printf("rmv query failed: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db error"})
|
||||
}
|
||||
})
|
||||
|
||||
http.HandleFunc("/api/market", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := withConn(r, func(conn *pgx.Conn) error {
|
||||
result, err := fetchLatestQuotes(r.Context(), conn, marketOrder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
type quote struct {
|
||||
Instrument string `json:"instrument"`
|
||||
Bid float64 `json:"bid"`
|
||||
}
|
||||
var ordered []quote
|
||||
for _, inst := range marketOrder {
|
||||
if bid, ok := result[inst]; ok {
|
||||
ordered = append(ordered, quote{Instrument: inst, Bid: bid})
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, ordered)
|
||||
return nil
|
||||
}); err != nil {
|
||||
log.Printf("market query failed: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db error"})
|
||||
}
|
||||
})
|
||||
|
||||
http.HandleFunc("/api/defcon", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, map[string]int{"level": defcon.get()})
|
||||
case http.MethodPost:
|
||||
var body struct {
|
||||
Level int `json:"level"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
if body.Level < 1 || body.Level > 5 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "level must be 1-5"})
|
||||
return
|
||||
}
|
||||
defcon.set(body.Level)
|
||||
writeJSON(w, http.StatusOK, map[string]int{"level": body.Level})
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
|
||||
http.HandleFunc("/api/bunker-status", func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := clientIP(r)
|
||||
var ipString string
|
||||
if ip != nil {
|
||||
ipString = ip.String()
|
||||
}
|
||||
inMainz := ip != nil && uniMainzNet.Contains(ip)
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"online": inMainz,
|
||||
"ts": time.Now().UTC(),
|
||||
"client_ip": ipString,
|
||||
"network": uniMainzNet.String(),
|
||||
})
|
||||
})
|
||||
|
||||
http.HandleFunc("/api/countdowns", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, filepath.Clean("web/static/media/countdowns.json"))
|
||||
})
|
||||
|
||||
log.Println("Server läuft auf http://0.0.0.0:8080")
|
||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
||||
}
|
||||
Reference in New Issue
Block a user