+2
.vscode/launch.json
+2
.vscode/launch.json
+1
-1
.vscode/tasks.json
+1
-1
.vscode/tasks.json
+20
-16
app/app.go
+20
-16
app/app.go
···
31
}
32
33
type App struct {
34
-
Admin bool `json:"admin"`
35
-
Name string `json:"name"`
36
-
Dir string `json:"dir,omitempty"`
37
-
Domain string `json:"-"`
38
-
URL string `json:"url"`
39
-
Env map[string]string `json:"-"`
40
-
Config AppConfig `json:"-"`
41
}
42
43
-
func (me *App) Root() string {
44
dir := me.Dir
45
if fi, err := os.Lstat(dir); err == nil && fi.Mode()&os.ModeSymlink != 0 {
46
if root, err := os.Readlink(dir); err == nil {
···
99
}
100
101
app := App{
102
-
Name: appname,
103
-
Admin: isAdmin,
104
-
Dir: filepath.Join(rootDir, appname),
105
-
Domain: fmt.Sprintf("%s.%s", appname, domain),
106
-
URL: fmt.Sprintf("https://%s.%s/", appname, domain),
107
-
Env: make(map[string]string),
108
}
109
110
for _, dotenvPath := range []string{
···
202
}
203
204
if me.Config.Entrypoint != "" {
205
-
return filepath.Join(me.Root(), me.Config.Entrypoint)
206
}
207
208
for _, candidate := range []string{"main.js", "main.ts", "main.jsx", "main.tsx"} {
209
-
path := filepath.Join(me.Root(), candidate)
210
if utils.FileExists(path) {
211
return path
212
}
···
31
}
32
33
type App struct {
34
+
Admin bool `json:"admin"`
35
+
Name string `json:"name"`
36
+
RootDir string `json:"-"`
37
+
RootDomain string `json:"-"`
38
+
Dir string `json:"dir,omitempty"`
39
+
Domain string `json:"-"`
40
+
URL string `json:"url"`
41
+
Env map[string]string `json:"-"`
42
+
Config AppConfig `json:"-"`
43
}
44
45
+
func (me *App) DataDir() string {
46
dir := me.Dir
47
if fi, err := os.Lstat(dir); err == nil && fi.Mode()&os.ModeSymlink != 0 {
48
if root, err := os.Readlink(dir); err == nil {
···
101
}
102
103
app := App{
104
+
Name: appname,
105
+
Admin: isAdmin,
106
+
RootDir: rootDir,
107
+
RootDomain: domain,
108
+
Dir: filepath.Join(rootDir, appname),
109
+
Domain: fmt.Sprintf("%s.%s", appname, domain),
110
+
URL: fmt.Sprintf("https://%s.%s/", appname, domain),
111
+
Env: make(map[string]string),
112
}
113
114
for _, dotenvPath := range []string{
···
206
}
207
208
if me.Config.Entrypoint != "" {
209
+
return filepath.Join(me.DataDir(), me.Config.Entrypoint)
210
}
211
212
for _, candidate := range []string{"main.js", "main.ts", "main.jsx", "main.tsx"} {
213
+
path := filepath.Join(me.DataDir(), candidate)
214
if utils.FileExists(path) {
215
return path
216
}
+1
-1
cmd/crons.go
+1
-1
cmd/crons.go
+1
-1
cmd/email.go
+1
-1
cmd/email.go
+1
-1
cmd/fetch.go
+1
-1
cmd/fetch.go
+1
-1
cmd/run.go
+1
-1
cmd/run.go
+43
-23
cmd/up.go
+43
-23
cmd/up.go
···
25
"github.com/caddyserver/certmagic"
26
"github.com/charmbracelet/ssh"
27
"github.com/creack/pty"
28
"github.com/pkg/sftp"
29
"github.com/pomdtr/smallweb/app"
30
"github.com/pomdtr/smallweb/watcher"
···
39
func NewCmdUp() *cobra.Command {
40
var flags struct {
41
cron bool
42
-
addr string
43
sshAddr string
44
sshHostKey string
45
onDemandTLS bool
···
110
fmt.Fprintf(os.Stderr, "Serving *.%s from %s with on-demand TLS...\n", k.String("domain"), utils.AddTilde(k.String("dir")))
111
go certmagic.HTTPS(nil, handler)
112
} else {
113
-
addr := flags.addr
114
if addr == "" {
115
if flags.cert != "" || flags.key != "" {
116
addr = "0.0.0.0:443"
···
166
},
167
SubsystemHandlers: map[string]ssh.SubsystemHandler{
168
"sftp": func(sess ssh.Session) {
169
-
var workDir string
170
-
if sess.User() == "_" {
171
-
workDir = k.String("dir")
172
-
} else {
173
-
app, err := app.LoadApp(sess.User(), k.String("dir"), k.String("domain"), slices.Contains(k.Strings("adminApps"), sess.User()))
174
-
if err != nil {
175
-
fmt.Fprintln(sess, "failed to load app:", err)
176
-
return
177
-
}
178
-
179
-
workDir = filepath.Join(app.Root(), "data")
180
-
if _, err := os.Stat(workDir); err != nil {
181
-
fmt.Fprintln(sess, "failed to get app data directory")
182
-
return
183
-
}
184
}
185
186
server, err := sftp.NewServer(
187
sess,
188
-
sftp.WithServerWorkingDirectory(workDir),
189
)
190
191
if err != nil {
···
211
cmd = exec.Command(execPath, sess.Command()...)
212
cmd.Env = os.Environ()
213
} else {
214
-
app, err := app.LoadApp(sess.User(), k.String("dir"), k.String("domain"), slices.Contains(k.Strings("adminApps"), sess.User()))
215
if err != nil {
216
fmt.Fprintf(sess, "failed to load app: %v\n", err)
217
return
218
}
219
220
-
wk := worker.NewWorker(app, k.String("dir"), k.String("domain"))
221
command, err := wk.Command(sess.Context(), sess.Command()...)
222
if err != nil {
223
fmt.Fprintf(sess, "failed to get command: %v\n", err)
···
289
defer server.Close()
290
}
291
292
// sigint handling
293
sigint := make(chan os.Signal, 1)
294
signal.Notify(sigint, os.Interrupt)
···
298
},
299
}
300
301
-
cmd.Flags().StringVar(&flags.addr, "addr", "", "address to listen on")
302
cmd.Flags().StringVar(&flags.sshAddr, "ssh-addr", "", "address to listen on for ssh/sftp")
303
cmd.Flags().StringVar(&flags.sshHostKey, "ssh-host-key", "~/.ssh/id_ed25519", "ssh host key")
304
cmd.Flags().BoolVar(&flags.onDemandTLS, "on-demand-tls", false, "enable on-demand TLS")
305
cmd.Flags().StringVar(&flags.cert, "cert", "", "tls certificate file")
···
308
309
cmd.MarkFlagsMutuallyExclusive("on-demand-tls", "cert")
310
cmd.MarkFlagsMutuallyExclusive("on-demand-tls", "key")
311
-
cmd.MarkFlagsMutuallyExclusive("on-demand-tls", "addr")
312
313
return cmd
314
}
···
442
return nil, fmt.Errorf("failed to load app: %w", err)
443
}
444
445
-
wk := worker.NewWorker(a, k.String("dir"), domain)
446
447
wk.Logger = me.logger
448
if err := wk.Start(); err != nil {
···
25
"github.com/caddyserver/certmagic"
26
"github.com/charmbracelet/ssh"
27
"github.com/creack/pty"
28
+
"github.com/mhale/smtpd"
29
"github.com/pkg/sftp"
30
"github.com/pomdtr/smallweb/app"
31
"github.com/pomdtr/smallweb/watcher"
···
40
func NewCmdUp() *cobra.Command {
41
var flags struct {
42
cron bool
43
+
httpAddr string
44
+
smtpAddr string
45
sshAddr string
46
sshHostKey string
47
onDemandTLS bool
···
112
fmt.Fprintf(os.Stderr, "Serving *.%s from %s with on-demand TLS...\n", k.String("domain"), utils.AddTilde(k.String("dir")))
113
go certmagic.HTTPS(nil, handler)
114
} else {
115
+
addr := flags.httpAddr
116
if addr == "" {
117
if flags.cert != "" || flags.key != "" {
118
addr = "0.0.0.0:443"
···
168
},
169
SubsystemHandlers: map[string]ssh.SubsystemHandler{
170
"sftp": func(sess ssh.Session) {
171
+
if sess.User() != "_" {
172
+
fmt.Fprintln(sess, "sftp is only allowed for the _ user")
173
+
return
174
}
175
176
server, err := sftp.NewServer(
177
sess,
178
+
sftp.WithServerWorkingDirectory(utils.AddTilde(k.String("dir"))),
179
)
180
181
if err != nil {
···
201
cmd = exec.Command(execPath, sess.Command()...)
202
cmd.Env = os.Environ()
203
} else {
204
+
a, err := app.LoadApp(sess.User(), k.String("dir"), k.String("domain"), slices.Contains(k.Strings("adminApps"), sess.User()))
205
if err != nil {
206
fmt.Fprintf(sess, "failed to load app: %v\n", err)
207
return
208
}
209
210
+
wk := worker.NewWorker(a)
211
command, err := wk.Command(sess.Context(), sess.Command()...)
212
if err != nil {
213
fmt.Fprintf(sess, "failed to get command: %v\n", err)
···
279
defer server.Close()
280
}
281
282
+
if flags.smtpAddr != "" {
283
+
fmt.Fprintf(os.Stderr, "Starting SFTP server on %s...\n", flags.smtpAddr)
284
+
go smtpd.ListenAndServe(flags.smtpAddr, func(remoteAddr net.Addr, from string, to []string, data []byte) error {
285
+
for _, recipient := range to {
286
+
parts := strings.SplitN(recipient, "@", 2)
287
+
appname, domain := parts[0], parts[1]
288
+
if domain != k.String("domain") {
289
+
continue
290
+
}
291
+
292
+
a, err := app.LoadApp(appname, k.String("dir"), k.String("domain"), slices.Contains(k.Strings("adminApps"), appname))
293
+
if err != nil {
294
+
if errors.Is(err, app.ErrAppNotFound) {
295
+
continue
296
+
}
297
+
298
+
return fmt.Errorf("failed to load app: %v", err)
299
+
}
300
+
301
+
wk := worker.NewWorker(a)
302
+
if err := wk.SendEmail(context.Background(), strings.NewReader(string(data))); err != nil {
303
+
return fmt.Errorf("failed to send email: %v", err)
304
+
}
305
+
}
306
+
307
+
return nil
308
+
}, "Smallweb", k.String("domain"))
309
+
}
310
+
311
// sigint handling
312
sigint := make(chan os.Signal, 1)
313
signal.Notify(sigint, os.Interrupt)
···
317
},
318
}
319
320
+
cmd.Flags().StringVar(&flags.httpAddr, "http-addr", "", "address to listen on")
321
cmd.Flags().StringVar(&flags.sshAddr, "ssh-addr", "", "address to listen on for ssh/sftp")
322
+
cmd.Flags().StringVar(&flags.smtpAddr, "smtp-addr", "", "address to listen on")
323
cmd.Flags().StringVar(&flags.sshHostKey, "ssh-host-key", "~/.ssh/id_ed25519", "ssh host key")
324
cmd.Flags().BoolVar(&flags.onDemandTLS, "on-demand-tls", false, "enable on-demand TLS")
325
cmd.Flags().StringVar(&flags.cert, "cert", "", "tls certificate file")
···
328
329
cmd.MarkFlagsMutuallyExclusive("on-demand-tls", "cert")
330
cmd.MarkFlagsMutuallyExclusive("on-demand-tls", "key")
331
+
cmd.MarkFlagsMutuallyExclusive("on-demand-tls", "http-addr")
332
333
return cmd
334
}
···
462
return nil, fmt.Errorf("failed to load app: %w", err)
463
}
464
465
+
wk := worker.NewWorker(a)
466
467
wk.Logger = me.logger
468
if err := wk.Start(); err != nil {
examples/email/main.ts
examples/inbox/main.ts
examples/email/main.ts
examples/inbox/main.ts
+6
examples/inbox/email.eml
+6
examples/inbox/email.eml
+6
examples/inbox/run.sh
+6
examples/inbox/run.sh
+1
go.mod
+1
go.mod
+2
go.sum
+2
go.sum
···
298
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
299
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
300
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
301
github.com/mholt/acmez/v2 v2.0.3 h1:CgDBlEwg3QBp6s45tPQmFIBrkRIkBT4rW4orMM6p4sw=
302
github.com/mholt/acmez/v2 v2.0.3/go.mod h1:pQ1ysaDeGrIMvJ9dfJMk5kJNkn7L2sb3UhyrX6Q91cw=
303
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
···
298
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
299
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
300
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
301
+
github.com/mhale/smtpd v0.8.3 h1:8j8YNXajksoSLZja3HdwvYVZPuJSqAxFsib3adzRRt8=
302
+
github.com/mhale/smtpd v0.8.3/go.mod h1:MQl+y2hwIEQCXtNhe5+55n0GZOjSmeqORDIXbqUL3x4=
303
github.com/mholt/acmez/v2 v2.0.3 h1:CgDBlEwg3QBp6s45tPQmFIBrkRIkBT4rW4orMM6p4sw=
304
github.com/mholt/acmez/v2 v2.0.3/go.mod h1:pQ1ysaDeGrIMvJ9dfJMk5kJNkn7L2sb3UhyrX6Q91cw=
305
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
+15
-19
worker/worker.go
+15
-19
worker/worker.go
···
52
53
type Worker struct {
54
App app.App
55
-
RootDir string
56
-
Domain string
57
Env map[string]string
58
StartedAt time.Time
59
···
64
activeRequests atomic.Int32
65
}
66
67
-
func commandEnv(a app.App, rootDir string, domain string) []string {
68
env := []string{}
69
70
for k, v := range a.Env {
···
76
env = append(env, fmt.Sprintf("DENO_DIR=%s", filepath.Join(xdg.CacheHome, "smallweb", "deno")))
77
78
env = append(env, fmt.Sprintf("SMALLWEB_VERSION=%s", build.Version))
79
-
env = append(env, fmt.Sprintf("SMALLWEB_DIR=%s", rootDir))
80
-
env = append(env, fmt.Sprintf("SMALLWEB_DOMAIN=%s", domain))
81
env = append(env, fmt.Sprintf("SMALLWEB_APP_NAME=%s", a.Name))
82
env = append(env, fmt.Sprintf("SMALLWEB_APP_DOMAIN=%s", a.Domain))
83
env = append(env, fmt.Sprintf("SMALLWEB_APP_URL=%s", a.URL))
···
88
89
if a.Admin {
90
env = append(env, "SMALLWEB_ADMIN=1")
91
-
env = append(env, "SMALLWEB_LOG_PATH=%s", utils.GetLogFilename(domain))
92
}
93
94
return env
95
}
96
97
-
func NewWorker(app app.App, rootDir string, domain string) *Worker {
98
worker := &Worker{
99
-
App: app,
100
-
RootDir: rootDir,
101
-
Domain: domain,
102
}
103
104
return worker
···
128
if a.Admin {
129
args = append(
130
args,
131
-
fmt.Sprintf("--allow-read=%s,%s,%s,%s", me.RootDir, sandboxPath, deno, npmCache),
132
-
fmt.Sprintf("--allow-write=%s", me.RootDir),
133
)
134
135
return args
136
}
137
138
-
appRoot := a.Root()
139
// if root is not a symlink
140
if fi, err := os.Lstat(appRoot); err == nil && fi.Mode()&os.ModeSymlink == 0 {
141
args = append(
···
192
args = append(args, sandboxPath, input.String())
193
194
command := exec.Command(deno, args...)
195
-
command.Dir = me.App.Root()
196
-
command.Env = commandEnv(me.App, me.RootDir, me.Domain)
197
198
stdoutPipe, err := command.StdoutPipe()
199
if err != nil {
···
505
denoArgs = append(denoArgs, sandboxPath, input.String())
506
507
command := exec.CommandContext(ctx, deno, denoArgs...)
508
-
command.Dir = me.App.Root()
509
510
-
command.Env = commandEnv(me.App, me.RootDir, me.Domain)
511
512
return command, nil
513
}
···
534
denoArgs = append(denoArgs, sandboxPath, input.String())
535
536
command := exec.CommandContext(ctx, deno, denoArgs...)
537
-
command.Dir = me.App.Root()
538
command.Stderr = os.Stderr
539
-
command.Env = commandEnv(me.App, me.RootDir, me.Domain)
540
541
stdin, err := command.StdinPipe()
542
if err != nil {
···
52
53
type Worker struct {
54
App app.App
55
Env map[string]string
56
StartedAt time.Time
57
···
62
activeRequests atomic.Int32
63
}
64
65
+
func commandEnv(a app.App) []string {
66
env := []string{}
67
68
for k, v := range a.Env {
···
74
env = append(env, fmt.Sprintf("DENO_DIR=%s", filepath.Join(xdg.CacheHome, "smallweb", "deno")))
75
76
env = append(env, fmt.Sprintf("SMALLWEB_VERSION=%s", build.Version))
77
+
env = append(env, fmt.Sprintf("SMALLWEB_DIR=%s", a.RootDir))
78
+
env = append(env, fmt.Sprintf("SMALLWEB_DOMAIN=%s", a.RootDomain))
79
env = append(env, fmt.Sprintf("SMALLWEB_APP_NAME=%s", a.Name))
80
env = append(env, fmt.Sprintf("SMALLWEB_APP_DOMAIN=%s", a.Domain))
81
env = append(env, fmt.Sprintf("SMALLWEB_APP_URL=%s", a.URL))
···
86
87
if a.Admin {
88
env = append(env, "SMALLWEB_ADMIN=1")
89
+
env = append(env, "SMALLWEB_LOG_PATH=%s", utils.GetLogFilename(a.RootDomain))
90
}
91
92
return env
93
}
94
95
+
func NewWorker(app app.App) *Worker {
96
worker := &Worker{
97
+
App: app,
98
}
99
100
return worker
···
124
if a.Admin {
125
args = append(
126
args,
127
+
fmt.Sprintf("--allow-read=%s,%s,%s,%s", me.App.RootDir, sandboxPath, deno, npmCache),
128
+
fmt.Sprintf("--allow-write=%s", me.App.RootDir),
129
)
130
131
return args
132
}
133
134
+
appRoot := a.DataDir()
135
// if root is not a symlink
136
if fi, err := os.Lstat(appRoot); err == nil && fi.Mode()&os.ModeSymlink == 0 {
137
args = append(
···
188
args = append(args, sandboxPath, input.String())
189
190
command := exec.Command(deno, args...)
191
+
command.Dir = me.App.DataDir()
192
+
command.Env = commandEnv(me.App)
193
194
stdoutPipe, err := command.StdoutPipe()
195
if err != nil {
···
501
denoArgs = append(denoArgs, sandboxPath, input.String())
502
503
command := exec.CommandContext(ctx, deno, denoArgs...)
504
+
command.Dir = me.App.DataDir()
505
506
+
command.Env = commandEnv(me.App)
507
508
return command, nil
509
}
···
530
denoArgs = append(denoArgs, sandboxPath, input.String())
531
532
command := exec.CommandContext(ctx, deno, denoArgs...)
533
+
command.Dir = me.App.DataDir()
534
command.Stderr = os.Stderr
535
+
command.Env = commandEnv(me.App)
536
537
stdin, err := command.StdinPipe()
538
if err != nil {