this repo has no description smallweb.run
smallweb
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

add json logs

pomdtr e256d876 c9d800f6

+127 -114
+1 -1
cmd/crons.go
··· 153 153 fmt.Println(err) 154 154 continue 155 155 } 156 - wk := worker.NewWorker(a) 156 + wk := worker.NewWorker(a, nil) 157 157 158 158 command, err := wk.Command(context.Background(), job.Strings("args"), nil) 159 159 if err != nil {
+1 -1
cmd/run.go
··· 37 37 return fmt.Errorf("failed to load app: %w", err) 38 38 } 39 39 40 - wk := worker.NewWorker(a) 40 + wk := worker.NewWorker(a, nil) 41 41 var input []byte 42 42 if !isatty.IsTerminal(os.Stdin.Fd()) { 43 43 i, err := io.ReadAll(os.Stdin)
+73 -70
cmd/up.go
··· 9 9 "errors" 10 10 "fmt" 11 11 "io" 12 + "log/slog" 12 13 "net" 13 14 "net/http" 14 15 "net/url" ··· 30 31 "github.com/creack/pty" 31 32 "github.com/knadh/koanf/providers/file" 32 33 "github.com/mhale/smtpd" 34 + sloghttp "github.com/samber/slog-http" 33 35 34 36 "github.com/knadh/koanf/providers/posflag" 35 37 "github.com/knadh/koanf/v2" ··· 45 47 "github.com/spf13/cobra" 46 48 ) 47 49 50 + var ErrSilent = errors.New("exit error") 51 + 48 52 func NewCmdUp() *cobra.Command { 49 53 var flags struct { 50 54 enableCrons bool ··· 59 63 } 60 64 61 65 cmd := &cobra.Command{ 62 - Use: "up", 63 - Short: "Start the smallweb evaluation server", 64 - Aliases: []string{"serve"}, 65 - Args: cobra.NoArgs, 66 + Use: "up", 67 + Short: "Start the smallweb evaluation server", 68 + Aliases: []string{"serve"}, 69 + Args: cobra.NoArgs, 70 + SilenceErrors: true, 66 71 PreRunE: func(cmd *cobra.Command, args []string) error { 67 72 if _, err := checkDenoVersion(); err != nil { 68 73 return err ··· 71 76 return nil 72 77 }, 73 78 RunE: func(cmd *cobra.Command, args []string) error { 79 + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{})) 74 80 if k.String("dir") == "" { 75 - return fmt.Errorf("dir cannot be empty") 81 + logger.Error("dir cannot be empty") 82 + return ErrSilent 76 83 } 77 84 78 85 if k.String("domain") == "" { 79 - return fmt.Errorf("domain cannot be empty") 86 + logger.Error("domain cannot be empty") 87 + return ErrSilent 80 88 } 81 89 82 90 handler := &Handler{ 83 91 workers: make(map[string]*worker.Worker), 92 + logger: logger, 84 93 } 85 94 86 - issuerUrl, err := url.Parse(k.String("oidc.issuer")) 87 - if err != nil { 88 - return fmt.Errorf("failed to parse issuer url: %v", err) 95 + if issuer := k.String("oidc.issuer"); issuer != "" { 96 + issuerUrl, err := url.Parse(issuer) 97 + if err == nil { 98 + handler.oidcIssuerUrl = issuerUrl 99 + } else { 100 + logger.Error("failed to parse issuer url") 101 + } 89 102 } 90 - handler.oidcIssuerUrl = issuerUrl 91 103 92 104 watcher, err := watcher.NewWatcher(k.String("dir"), func() { 93 105 fileProvider := file.Provider(utils.FindConfigPath(k.String("dir"))) ··· 101 113 if issuer := k.String("oidc.issuer"); issuer != "" { 102 114 issuerUrl, err := url.Parse(issuer) 103 115 if err != nil { 104 - fmt.Fprintf(cmd.ErrOrStderr(), "failed to parse issuer url: %v\n", err) 116 + logger.Error("failed to parse issuer url") 105 117 return 106 118 } 107 119 108 120 handler.oidcIssuerUrl = issuerUrl 121 + handler.oidcProvider = nil 109 122 } 110 123 }) 111 124 if err != nil { 112 - return fmt.Errorf("failed to create watcher: %v", err) 125 + logger.Error("failed to create watcher") 126 + return ErrSilent 113 127 } 114 128 115 129 handler.watcher = watcher ··· 130 144 return fmt.Errorf("domain not found") 131 145 }, 132 146 } 133 - fmt.Fprintf(cmd.ErrOrStderr(), "Serving *.%s from %s on %s...\n", k.String("domain"), utils.AddTilde(k.String("dir")), ":443") 134 - go certmagic.HTTPS(nil, handler) 147 + logger.Info("serving on-demand https", "domain", k.String("domain"), "dir", k.String("dir")) 148 + go certmagic.HTTPS(nil, sloghttp.New(logger.With("logger", "http"))(handler)) 135 149 } else if flags.tlsCert != "" && flags.tlsKey != "" { 136 150 cert, err := tls.LoadX509KeyPair(flags.tlsCert, flags.tlsKey) 137 151 if err != nil { 138 - return fmt.Errorf("failed to load tls certificate: %v", err) 152 + logger.Error("failed to load tls certificate", "error", err) 153 + return ErrSilent 139 154 } 140 155 141 156 tlsConfig := &tls.Config{Certificates: []tls.Certificate{cert}} ··· 148 163 149 164 ln, err := getListener(addr, tlsConfig) 150 165 if err != nil { 151 - return fmt.Errorf("failed to get listener: %v", err) 166 + logger.Error("failed to get listener", "error", err) 167 + return ErrSilent 152 168 } 153 169 154 - fmt.Fprintf(cmd.ErrOrStderr(), "Serving *.%s from %s on %s...\n", k.String("domain"), utils.AddTilde(k.String("dir")), addr) 155 - go http.Serve(ln, handler) 170 + logger.Info("serving https", "domain", k.String("domain"), "dir", k.String("dir")) 171 + go http.Serve(ln, sloghttp.New(logger.With("logger", "http"))(handler)) 156 172 } else { 157 173 addr := flags.addr 158 174 if addr == "" { ··· 161 177 162 178 ln, err := getListener(addr, nil) 163 179 if err != nil { 164 - return fmt.Errorf("failed to get listener: %v", err) 180 + logger.Error("failed to get listener", "error", err) 181 + return ErrSilent 165 182 } 166 183 167 - fmt.Fprintf(cmd.ErrOrStderr(), "Serving *.%s from %s on %s...\n", k.String("domain"), utils.AddTilde(k.String("dir")), addr) 168 - go http.Serve(ln, handler) 184 + logger.Info("serving http", "domain", k.String("domain"), "dir", k.String("dir")) 185 + go http.Serve(ln, sloghttp.New(logger.With("logger", "http"))(handler)) 169 186 } 170 187 171 188 if flags.enableCrons { 172 - fmt.Fprintln(cmd.ErrOrStderr(), "Starting cron jobs...") 189 + logger.Info("starting cron jobs") 173 190 crons := CronRunner(cmd.OutOrStdout(), cmd.ErrOrStderr()) 174 191 crons.Start() 175 192 defer crons.Stop() 176 193 } 177 194 178 - if flags.apiAddr != "" { 179 - mux := http.NewServeMux() 180 - 181 - mux.HandleFunc("GET /caddy/ask", func(w http.ResponseWriter, r *http.Request) { 182 - domain := r.URL.Query().Get("domain") 183 - if domain == "" { 184 - http.Error(w, "domain parameter is required", http.StatusBadRequest) 185 - return 186 - } 187 - 188 - _, _, found := lookupApp(domain) 189 - if !found { 190 - http.Error(w, "app not found", http.StatusNotFound) 191 - return 192 - } 193 - 194 - w.Write([]byte("ok")) 195 - }) 196 - 197 - ln, err := getListener(flags.apiAddr, nil) 198 - if err != nil { 199 - return fmt.Errorf("failed to get listener for api: %v", err) 200 - } 201 - 202 - fmt.Fprintf(cmd.ErrOrStderr(), "Starting api server on %s...\n", flags.apiAddr) 203 - go http.Serve(ln, mux) 204 - } 205 - 206 195 if flags.smtpAddr != "" { 207 - fmt.Fprintf(cmd.ErrOrStderr(), "Starting smtp server on %s...\n", flags.smtpAddr) 208 - go smtpd.ListenAndServe(flags.smtpAddr, func(remoteAddr net.Addr, from string, to []string, data []byte) error { 196 + handler := func(remoteAddr net.Addr, from string, to []string, data []byte) error { 209 197 for _, recipient := range to { 210 198 parts := strings.Split(recipient, "@") 211 199 if len(parts) != 2 { 212 - return fmt.Errorf("invalid recipient: %s", recipient) 200 + logger.Error("invalid recipient", "recipient", recipient) 201 + continue 213 202 } 214 203 215 204 account, domain := parts[0], parts[1] 216 205 if domain != k.String("domain") { 217 - return fmt.Errorf("invalid domain: %s", domain) 206 + logger.Error("invalid domain", "domain", domain) 207 + continue 218 208 } 219 209 220 210 a, err := app.LoadApp(account, k.String("dir"), k.String("domain"), k.Bool(fmt.Sprintf("apps.%s.admin", parts[0]))) 221 211 if err != nil { 222 - return fmt.Errorf("failed to load app: %v", err) 212 + logger.Error("failed to load app", "error", err) 213 + continue 223 214 } 224 215 225 - worker := worker.NewWorker(a) 216 + worker := worker.NewWorker(a, nil) 226 217 if err := worker.SendEmail(context.Background(), data); err != nil { 227 - return fmt.Errorf("failed to send email: %v", err) 218 + logger.Error("failed to send email", "error", err) 219 + continue 228 220 } 229 221 } 230 222 231 223 return nil 232 - }, "smallweb", k.String("domain")) 224 + } 225 + 226 + logger.Info("starting smtp server", "addr", flags.smtpAddr) 227 + if flags.tlsCert != "" && flags.tlsKey != "" { 228 + go smtpd.ListenAndServeTLS(flags.smtpAddr, flags.tlsCert, flags.tlsKey, handler, "smallweb", k.String("domain")) 229 + } else { 230 + go smtpd.ListenAndServe(flags.smtpAddr, handler, "smallweb", k.String("domain")) 231 + } 233 232 } 234 233 235 234 if flags.sshAddr != "" { ··· 237 236 if flags.sshPrivateKey == "" { 238 237 homeDir, err := os.UserHomeDir() 239 238 if err != nil { 240 - return fmt.Errorf("failed to get home directory: %v", err) 239 + logger.Error("failed to get home directory", "error", err) 240 + return ErrSilent 241 241 } 242 242 243 243 for _, keyType := range []string{"id_rsa", "id_ed25519"} { ··· 249 249 } 250 250 251 251 if sshPrivateKeyPath == "" { 252 - return fmt.Errorf("ssh private key not found") 252 + logger.Error("ssh private key not found") 253 + return ErrSilent 253 254 } 254 255 255 256 privateKeyBytes, err := os.ReadFile(sshPrivateKeyPath) 256 257 if err != nil { 257 - return fmt.Errorf("failed to read private key: %v", err) 258 + logger.Error("failed to read private key", "error", err) 259 + return ErrSilent 258 260 } 259 261 260 262 privateKey, err := gossh.ParseRawPrivateKey(privateKeyBytes) 261 263 if err != nil { 262 - fmt.Fprintf(os.Stderr, "Failed to parse private key: %v\n", err) 263 - os.Exit(1) 264 + logger.Error("failed to parse private key", "error", err) 265 + return ErrSilent 264 266 } 265 267 266 268 signer, err := gossh.NewSignerFromKey(privateKey) 267 269 if err != nil { 268 - fmt.Fprintf(os.Stderr, "Failed to create signer: %v\n", err) 269 - os.Exit(1) 270 + logger.Error("failed to create signer", "error", err) 271 + return ErrSilent 270 272 } 271 273 272 274 authorizedKey := string(gossh.MarshalAuthorizedKey(signer.PublicKey())) ··· 304 306 return 305 307 } 306 308 307 - wk := worker.NewWorker(a) 309 + wk := worker.NewWorker(a, nil) 308 310 cmd, err := wk.Command(sess.Context(), sess.Command(), nil) 309 311 if err != nil { 310 312 fmt.Fprintf(sess, "failed to get command: %v\n", err) ··· 397 399 ) 398 400 399 401 if err != nil { 400 - return fmt.Errorf("failed to create ssh server: %v", err) 402 + logger.Error("failed to create ssh server", "error", err) 403 + return ErrSilent 401 404 } 402 405 403 - fmt.Fprintf(cmd.ErrOrStderr(), "Starting ssh server on %s...\n", flags.sshAddr) 406 + logger.Info("serving ssh", "addr", flags.sshAddr) 404 407 go srv.ListenAndServe() 405 408 } 406 409 ··· 420 423 cmd.Flags().StringVar(&flags.sshPrivateKey, "ssh-host-key", "", "ssh host key") 421 424 cmd.Flags().StringVar(&flags.tlsCert, "tls-cert", "", "tls certificate file") 422 425 cmd.Flags().StringVar(&flags.tlsKey, "tls-key", "", "tls key file") 423 - cmd.Flags().StringVar(&flags.apiAddr, "api-addr", "", "address to listen on for api") 424 426 cmd.Flags().BoolVar(&flags.enableCrons, "enable-crons", false, "enable cron jobs") 425 427 cmd.Flags().Bool("cron", false, "enable cron jobs") 426 428 cmd.Flags().BoolVar(&flags.onDemandTLS, "on-demand-tls", false, "enable on-demand tls") ··· 463 465 464 466 type Handler struct { 465 467 watcher *watcher.Watcher 468 + logger *slog.Logger 466 469 workerMu sync.Mutex 467 470 workers map[string]*worker.Worker 468 471 oidcMu sync.Mutex ··· 924 927 return nil, fmt.Errorf("failed to load app: %w", err) 925 928 } 926 929 927 - wk := worker.NewWorker(a) 930 + wk := worker.NewWorker(a, me.logger.With("logger", "console", "app", appname)) 928 931 929 932 if err := wk.Start(); err != nil { 930 933 return nil, fmt.Errorf("failed to start worker: %w", err)
+2 -1
go.mod
··· 21 21 require ( 22 22 github.com/bmatcuk/doublestar/v4 v4.8.1 23 23 github.com/caddyserver/certmagic v0.22.2 24 - github.com/charmbracelet/log v0.4.1 25 24 github.com/charmbracelet/ssh v0.0.0-20250213143314-8712ec3ff3ef 26 25 github.com/charmbracelet/wish v1.4.7 27 26 github.com/cli/browser v1.3.0 ··· 34 33 github.com/mhale/smtpd v0.8.3 35 34 github.com/pkg/sftp v1.13.9 36 35 github.com/robfig/cron/v3 v3.0.1 36 + github.com/samber/slog-http v1.6.0 37 37 golang.org/x/crypto v0.36.0 38 38 golang.org/x/oauth2 v0.28.0 39 39 ) ··· 90 90 github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 91 91 github.com/charmbracelet/keygen v0.5.3 // indirect 92 92 github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc // indirect 93 + github.com/charmbracelet/log v0.4.1 // indirect 93 94 github.com/charmbracelet/x/ansi v0.8.0 // indirect 94 95 github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 95 96 github.com/charmbracelet/x/conpty v0.1.0 // indirect
+2
go.sum
··· 371 371 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 372 372 github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= 373 373 github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= 374 + github.com/samber/slog-http v1.6.0 h1:+rD5QtOWGTcFT7jq8Yf0EgGy87krv0pcgh9jtWkrqjQ= 375 + github.com/samber/slog-http v1.6.0/go.mod h1:PAcQQrYFo5KM7Qbk50gNNwKEAMGCyfsw6GN5dI0iv9g= 374 376 github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 375 377 github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 376 378 github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
+3 -4
main.go
··· 3 3 import ( 4 4 _ "embed" 5 5 "errors" 6 + "fmt" 6 7 "os" 7 - "os/exec" 8 8 9 9 "github.com/pomdtr/smallweb/cmd" 10 10 ) ··· 17 17 root.SetErr(os.Stderr) 18 18 19 19 if err := root.Execute(); err != nil { 20 - var exitError *exec.ExitError 21 - if errors.As(err, &exitError) { 22 - os.Exit(exitError.ExitCode()) 20 + if !errors.Is(err, cmd.ErrSilent) { 21 + fmt.Fprintln(os.Stderr, err) 23 22 } 24 23 25 24 os.Exit(1)
+45 -37
worker/worker.go
··· 8 8 "encoding/json" 9 9 "fmt" 10 10 "io" 11 + "log/slog" 11 12 "net" 12 13 "net/http" 13 14 "os" ··· 19 20 "time" 20 21 21 22 "github.com/adrg/xdg" 22 - "github.com/charmbracelet/log" 23 23 "github.com/gorilla/websocket" 24 24 "github.com/pomdtr/smallweb/app" 25 25 "github.com/pomdtr/smallweb/build" ··· 33 33 App app.App 34 34 Env map[string]string 35 35 StartedAt time.Time 36 + Logger *slog.Logger 36 37 37 38 port int 38 39 idleTimer *time.Timer ··· 72 73 return env 73 74 } 74 75 75 - func NewWorker(app app.App) *Worker { 76 + func NewWorker(app app.App, logger *slog.Logger) *Worker { 76 77 worker := &Worker{ 77 - App: app, 78 + App: app, 79 + Logger: logger, 78 80 } 79 81 80 82 return worker ··· 84 86 85 87 type SandboxMethod string 86 88 87 - var ( 88 - SandboxMethodFetch SandboxMethod = "fetch" 89 - SandboxMethodRun SandboxMethod = "run" 90 - ) 91 - 92 - func (me *Worker) DenoArgs(deno string, method SandboxMethod) []string { 89 + func (me *Worker) DenoArgs(deno string) ([]string, error) { 93 90 args := []string{ 94 91 "--allow-net", 95 92 "--allow-import", ··· 111 108 fmt.Sprintf("--allow-write=%s", me.App.RootDir), 112 109 ) 113 110 114 - return args 111 + return args, nil 115 112 } 116 113 117 114 // if root is not a symlink ··· 120 117 args = append( 121 118 args, 122 119 fmt.Sprintf("--allow-read=%s,%s,%s", appDir, deno, npmCache), 120 + fmt.Sprintf("--allow-write=%s", me.App.DataDir()), 123 121 ) 124 122 125 - if method == SandboxMethodRun { 126 - args = append(args, fmt.Sprintf("--allow-write=%s", me.App.Dir())) 127 - } else { 128 - args = append(args, fmt.Sprintf("--allow-write=%s", me.App.DataDir())) 129 - } 130 - 131 - return args 123 + return args, nil 132 124 } 133 125 134 126 target, err := os.Readlink(appDir) 135 127 if err != nil { 136 - log.Printf("could not read symlink: %v", err) 128 + return nil, fmt.Errorf("could not read symlink: %w", err) 137 129 } 138 130 139 131 if !filepath.IsAbs(target) { ··· 143 135 args = append( 144 136 args, 145 137 fmt.Sprintf("--allow-read=%s,%s,%s,%s", appDir, target, deno, npmCache), 138 + fmt.Sprintf("--allow-write=%s,%s", me.App.DataDir(), filepath.Join(target, "data")), 146 139 ) 147 140 148 - if method == SandboxMethodRun { 149 - args = append(args, fmt.Sprintf("--allow-write=%s,%s", me.App.Dir(), target)) 150 - } else { 151 - args = append(args, fmt.Sprintf("--allow-write=%s,%s", me.App.DataDir(), filepath.Join(target, "data"))) 152 - } 153 - 154 - return args 141 + return args, nil 155 142 } 156 143 157 144 func (me *Worker) Start() error { ··· 167 154 } 168 155 169 156 args := []string{"run"} 170 - args = append(args, me.DenoArgs(deno, SandboxMethodFetch)...) 157 + denoArgs, err := me.DenoArgs(deno) 158 + if err != nil { 159 + return fmt.Errorf("could not get deno args: %w", err) 160 + } 161 + 162 + args = append(args, denoArgs...) 171 163 input := strings.Builder{} 172 164 encoder := json.NewEncoder(&input) 173 165 encoder.SetEscapeHTML(false) ··· 228 220 logPipe := func(pipe io.ReadCloser, stream string) { 229 221 scanner := bufio.NewScanner(pipe) 230 222 for scanner.Scan() { 231 - log.Info( 223 + if me.Logger == nil { 224 + if stream == "stderr" { 225 + fmt.Fprintln(os.Stderr, scanner.Text()) 226 + } else { 227 + fmt.Fprintln(os.Stdout, scanner.Text()) 228 + } 229 + continue 230 + } 231 + me.Logger.Info( 232 232 scanner.Text(), 233 - "app", me.App.Name, 234 233 "stream", stream, 235 234 ) 236 235 } ··· 263 262 me.command = nil 264 263 265 264 if err := command.Process.Signal(os.Interrupt); err != nil { 266 - log.Printf("Failed to send interrupt signal: %v", err) 265 + return fmt.Errorf("failed to send interrupt signal: %w", err) 267 266 } 268 267 269 268 done := make(chan error, 1) ··· 304 303 if r.Header.Get("Upgrade") == "websocket" { 305 304 serverConn, err := upgrader.Upgrade(w, r, nil) 306 305 if err != nil { 307 - log.Printf("Error upgrading connection: %v", err) 306 + http.Error(w, err.Error(), http.StatusInternalServerError) 308 307 return 309 308 } 310 309 defer serverConn.Close() ··· 471 470 return nil, fmt.Errorf("could not find deno executable") 472 471 } 473 472 474 - denoArgs := []string{"run"} 475 - denoArgs = append(denoArgs, me.DenoArgs(deno, SandboxMethodRun)...) 473 + cmdArgs := []string{"run"} 474 + denoArgs, err := me.DenoArgs(deno) 475 + if err != nil { 476 + return nil, fmt.Errorf("could not get deno args: %w", err) 477 + } 478 + cmdArgs = append(cmdArgs, denoArgs...) 476 479 477 480 payload := strings.Builder{} 478 481 encoder := json.NewEncoder(&payload) ··· 486 489 return nil, fmt.Errorf("could not encode input: %w", err) 487 490 } 488 491 489 - denoArgs = append(denoArgs, "-", payload.String()) 492 + cmdArgs = append(cmdArgs, "-", payload.String()) 490 493 491 - command := exec.CommandContext(ctx, deno, denoArgs...) 494 + command := exec.CommandContext(ctx, deno, cmdArgs...) 492 495 command.Stdin = strings.NewReader(sandboxContent) 493 496 command.Dir = me.App.Dir() 494 497 ··· 503 506 return fmt.Errorf("could not find deno executable") 504 507 } 505 508 506 - denoArgs := []string{"run"} 507 - denoArgs = append(denoArgs, me.DenoArgs(deno, SandboxMethodRun)...) 509 + args := []string{"run"} 510 + denoArgs, err := me.DenoArgs(deno) 511 + if err != nil { 512 + return fmt.Errorf("could not get deno args: %w", err) 513 + } 514 + 515 + args = append(args, denoArgs...) 508 516 509 517 payload := strings.Builder{} 510 518 encoder := json.NewEncoder(&payload) ··· 517 525 return fmt.Errorf("could not encode input: %w", err) 518 526 } 519 527 520 - denoArgs = append(denoArgs, "-", payload.String()) 528 + denoArgs = append(args, "-", payload.String()) 521 529 522 530 command := exec.CommandContext(ctx, deno, denoArgs...) 523 531