repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

commit
028d33f
parent
c79df32
author
Antonio Mika
date
2025-03-12 17:29:25 -0400 EDT
Refactor modules
222 files changed,  +3123, -258
M go.mod
M cmd/auth/web/main.go
+1, -1
1@@ -1,6 +1,6 @@
2 package main
3 
4-import "github.com/picosh/pico/auth"
5+import "github.com/picosh/pico/pkg/apps/auth"
6 
7 func main() {
8 	auth.StartApiServer()
M cmd/feeds/fetch/main.go
+2, -2
 1@@ -4,8 +4,8 @@ import (
 2 	"fmt"
 3 
 4 	"github.com/mmcdole/gofeed"
 5-	"github.com/picosh/pico/db/postgres"
 6-	"github.com/picosh/pico/feeds"
 7+	"github.com/picosh/pico/pkg/apps/feeds"
 8+	"github.com/picosh/pico/pkg/db/postgres"
 9 )
10 
11 func main() {
M cmd/feeds/ssh/main.go
+1, -1
1@@ -1,6 +1,6 @@
2 package main
3 
4-import "github.com/picosh/pico/feeds"
5+import "github.com/picosh/pico/pkg/apps/feeds"
6 
7 func main() {
8 	feeds.StartSshServer()
M cmd/feeds/web/main.go
+1, -1
1@@ -1,6 +1,6 @@
2 package main
3 
4-import "github.com/picosh/pico/feeds"
5+import "github.com/picosh/pico/pkg/apps/feeds"
6 
7 func main() {
8 	feeds.StartApiServer()
M cmd/pastes/ssh/main.go
+1, -1
1@@ -1,6 +1,6 @@
2 package main
3 
4-import "github.com/picosh/pico/pastes"
5+import "github.com/picosh/pico/pkg/apps/pastes"
6 
7 func main() {
8 	pastes.StartSshServer()
M cmd/pastes/web/main.go
+1, -1
1@@ -1,6 +1,6 @@
2 package main
3 
4-import "github.com/picosh/pico/pastes"
5+import "github.com/picosh/pico/pkg/apps/pastes"
6 
7 func main() {
8 	pastes.StartApiServer()
M cmd/pgs/ssh/main.go
+4, -4
 1@@ -1,10 +1,10 @@
 2 package main
 3 
 4 import (
 5-	"github.com/picosh/pico/pgs"
 6-	pgsdb "github.com/picosh/pico/pgs/db"
 7-	"github.com/picosh/pico/shared"
 8-	"github.com/picosh/pico/shared/storage"
 9+	"github.com/picosh/pico/pkg/apps/pgs"
10+	pgsdb "github.com/picosh/pico/pkg/apps/pgs/db"
11+	"github.com/picosh/pico/pkg/shared"
12+	"github.com/picosh/pico/pkg/shared/storage"
13 	"github.com/picosh/utils"
14 )
15 
M cmd/pgs/web/main.go
+4, -4
 1@@ -1,10 +1,10 @@
 2 package main
 3 
 4 import (
 5-	"github.com/picosh/pico/pgs"
 6-	pgsdb "github.com/picosh/pico/pgs/db"
 7-	"github.com/picosh/pico/shared"
 8-	"github.com/picosh/pico/shared/storage"
 9+	"github.com/picosh/pico/pkg/apps/pgs"
10+	pgsdb "github.com/picosh/pico/pkg/apps/pgs/db"
11+	"github.com/picosh/pico/pkg/shared"
12+	"github.com/picosh/pico/pkg/shared/storage"
13 	"github.com/picosh/utils"
14 )
15 
M cmd/pico/ssh/main.go
+1, -1
1@@ -1,6 +1,6 @@
2 package main
3 
4-import "github.com/picosh/pico/pico"
5+import "github.com/picosh/pico/pkg/apps/pico"
6 
7 func main() {
8 	pico.StartSshServer()
M cmd/pipe/ssh/main.go
+1, -1
1@@ -1,6 +1,6 @@
2 package main
3 
4-import "github.com/picosh/pico/pipe"
5+import "github.com/picosh/pico/pkg/apps/pipe"
6 
7 func main() {
8 	pipe.StartSshServer()
M cmd/pipe/web/main.go
+1, -1
1@@ -1,6 +1,6 @@
2 package main
3 
4-import "github.com/picosh/pico/pipe"
5+import "github.com/picosh/pico/pkg/apps/pipe"
6 
7 func main() {
8 	pipe.StartApiServer()
M cmd/prose/ssh/main.go
+1, -1
1@@ -1,6 +1,6 @@
2 package main
3 
4-import "github.com/picosh/pico/prose"
5+import "github.com/picosh/pico/pkg/apps/prose"
6 
7 func main() {
8 	prose.StartSshServer()
M cmd/prose/web/main.go
+1, -1
1@@ -1,6 +1,6 @@
2 package main
3 
4-import "github.com/picosh/pico/prose"
5+import "github.com/picosh/pico/pkg/apps/prose"
6 
7 func main() {
8 	prose.StartApiServer()
M cmd/scripts/analytics/analytics.go
+2, -2
 1@@ -4,8 +4,8 @@ import (
 2 	"log/slog"
 3 	"os"
 4 
 5-	"github.com/picosh/pico/db"
 6-	"github.com/picosh/pico/db/postgres"
 7+	"github.com/picosh/pico/pkg/db"
 8+	"github.com/picosh/pico/pkg/db/postgres"
 9 	"github.com/picosh/utils"
10 )
11 
M cmd/scripts/clean-analytics/clean.go
+2, -2
 1@@ -5,8 +5,8 @@ import (
 2 	"log/slog"
 3 	"os"
 4 
 5-	"github.com/picosh/pico/db/postgres"
 6-	"github.com/picosh/pico/shared"
 7+	"github.com/picosh/pico/pkg/db/postgres"
 8+	"github.com/picosh/pico/pkg/shared"
 9 )
10 
11 func main() {
M cmd/scripts/clean-object-store/clean.go
+5, -5
 1@@ -5,11 +5,11 @@ import (
 2 	"os"
 3 	"strings"
 4 
 5-	"github.com/picosh/pico/db"
 6-	"github.com/picosh/pico/pgs"
 7-	pgsdb "github.com/picosh/pico/pgs/db"
 8-	"github.com/picosh/pico/shared"
 9-	"github.com/picosh/pico/shared/storage"
10+	"github.com/picosh/pico/pkg/apps/pgs"
11+	pgsdb "github.com/picosh/pico/pkg/apps/pgs/db"
12+	"github.com/picosh/pico/pkg/db"
13+	"github.com/picosh/pico/pkg/shared"
14+	"github.com/picosh/pico/pkg/shared/storage"
15 	"github.com/picosh/utils"
16 )
17 
M cmd/scripts/dates/dates.go
+3, -3
 1@@ -7,9 +7,9 @@ import (
 2 	"os"
 3 	"time"
 4 
 5-	"github.com/picosh/pico/db"
 6-	"github.com/picosh/pico/db/postgres"
 7-	"github.com/picosh/pico/shared"
 8+	"github.com/picosh/pico/pkg/db"
 9+	"github.com/picosh/pico/pkg/db/postgres"
10+	"github.com/picosh/pico/pkg/shared"
11 )
12 
13 func findPosts(dbpool *sql.DB) ([]*db.Post, error) {
M cmd/scripts/file-size-sync/sync.go
+2, -2
 1@@ -5,8 +5,8 @@ import (
 2 	"log/slog"
 3 	"os"
 4 
 5-	"github.com/picosh/pico/db/postgres"
 6-	"github.com/picosh/pico/shared"
 7+	"github.com/picosh/pico/pkg/db/postgres"
 8+	"github.com/picosh/pico/pkg/shared"
 9 )
10 
11 func bail(err error) {
M cmd/scripts/migrate/migrate.go
+3, -3
 1@@ -7,9 +7,9 @@ import (
 2 	"log/slog"
 3 	"os"
 4 
 5-	"github.com/picosh/pico/db"
 6-	"github.com/picosh/pico/db/postgres"
 7-	"github.com/picosh/pico/shared"
 8+	"github.com/picosh/pico/pkg/db"
 9+	"github.com/picosh/pico/pkg/db/postgres"
10+	"github.com/picosh/pico/pkg/shared"
11 )
12 
13 func findPosts(dbpool *sql.DB) ([]*db.Post, error) {
M cmd/scripts/pico-plus/main.go
+1, -1
1@@ -4,7 +4,7 @@ import (
2 	"log/slog"
3 	"os"
4 
5-	"github.com/picosh/pico/db/postgres"
6+	"github.com/picosh/pico/pkg/db/postgres"
7 )
8 
9 func main() {
M cmd/scripts/prose-imgs-migrate/main.go
+7, -7
 1@@ -7,13 +7,13 @@ import (
 2 	"path/filepath"
 3 	"time"
 4 
 5-	"github.com/picosh/pico/db"
 6-	"github.com/picosh/pico/db/postgres"
 7-	"github.com/picosh/pico/prose"
 8-	"github.com/picosh/pico/shared"
 9-	"github.com/picosh/pico/shared/storage"
10-	sst "github.com/picosh/pobj/storage"
11-	sendUtils "github.com/picosh/send/utils"
12+	"github.com/picosh/pico/pkg/apps/prose"
13+	"github.com/picosh/pico/pkg/db"
14+	"github.com/picosh/pico/pkg/db/postgres"
15+	sst "github.com/picosh/pico/pkg/pobj/storage"
16+	sendUtils "github.com/picosh/pico/pkg/send/utils"
17+	"github.com/picosh/pico/pkg/shared"
18+	"github.com/picosh/pico/pkg/shared/storage"
19 )
20 
21 func bail(err error) {
M cmd/scripts/rm-old-buckets/rm-old-buckets.go
+3, -3
 1@@ -6,9 +6,9 @@ import (
 2 
 3 	"github.com/minio/minio-go/v7"
 4 	"github.com/minio/minio-go/v7/pkg/credentials"
 5-	"github.com/picosh/pico/db/postgres"
 6-	"github.com/picosh/pico/prose"
 7-	"github.com/picosh/pico/shared"
 8+	"github.com/picosh/pico/pkg/apps/prose"
 9+	"github.com/picosh/pico/pkg/db/postgres"
10+	"github.com/picosh/pico/pkg/shared"
11 )
12 
13 func bail(err error) {
M cmd/scripts/shasum/shasum.go
+2, -2
 1@@ -4,8 +4,8 @@ import (
 2 	"log/slog"
 3 	"os"
 4 
 5-	"github.com/picosh/pico/db/postgres"
 6-	"github.com/picosh/pico/shared"
 7+	"github.com/picosh/pico/pkg/db/postgres"
 8+	"github.com/picosh/pico/pkg/shared"
 9 	"github.com/picosh/utils"
10 )
11 
M cmd/scripts/tags/tags.go
+3, -3
 1@@ -5,9 +5,9 @@ import (
 2 	"log/slog"
 3 	"os"
 4 
 5-	"github.com/picosh/pico/db"
 6-	"github.com/picosh/pico/db/postgres"
 7-	"github.com/picosh/pico/shared"
 8+	"github.com/picosh/pico/pkg/db"
 9+	"github.com/picosh/pico/pkg/db/postgres"
10+	"github.com/picosh/pico/pkg/shared"
11 )
12 
13 func findPosts(dbpool *sql.DB) ([]*db.Post, error) {
M go.mod
+12, -14
 1@@ -4,13 +4,13 @@ go 1.24
 2 
 3 toolchain go1.24.0
 4 
 5-replace github.com/picosh/tunkit => ../tunkit
 6+// replace github.com/picosh/tunkit => ../tunkit
 7 
 8-replace github.com/picosh/send => ../send
 9+// replace github.com/picosh/send => ../send
10 
11 // replace github.com/picosh/go-rsync-receiver => ../go-rsync-receiver
12 
13-replace github.com/picosh/pobj => ../pobj
14+// replace github.com/picosh/pobj => ../pobj
15 
16 // replace github.com/picosh/pubsub => ../pubsub
17 
18@@ -26,6 +26,12 @@ require (
19 	github.com/alecthomas/chroma/v2 v2.14.0
20 	github.com/antoniomika/syncmap v1.0.0
21 	github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
22+	github.com/aws/aws-sdk-go-v2 v1.36.2
23+	github.com/aws/aws-sdk-go-v2/config v1.29.7
24+	github.com/aws/aws-sdk-go-v2/credentials v1.17.60
25+	github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.63
26+	github.com/aws/aws-sdk-go-v2/service/s3 v1.77.1
27+	github.com/aws/smithy-go v1.22.3
28 	github.com/containerd/console v1.0.4
29 	github.com/darkweak/souin v1.7.5
30 	github.com/darkweak/souin/plugins/souin/storages v1.7.5
31@@ -37,14 +43,14 @@ require (
32 	github.com/gorilla/websocket v1.5.3
33 	github.com/jmoiron/sqlx v1.4.0
34 	github.com/lib/pq v1.10.9
35+	github.com/matryer/is v1.4.1
36 	github.com/microcosm-cc/bluemonday v1.0.27
37+	github.com/minio/madmin-go/v3 v3.0.94
38 	github.com/minio/minio-go/v7 v7.0.87
39 	github.com/mmcdole/gofeed v1.3.0
40 	github.com/neurosnap/go-exif-remove v0.0.0-20221010134343-50d1e3c35577
41-	github.com/picosh/pobj v0.0.0-20250304201248-a9c7179aa49b
42+	github.com/picosh/go-rsync-receiver v0.0.0-20250304201040-fcc11dd22d79
43 	github.com/picosh/pubsub v0.0.0-20241114191831-ec8f16c0eb88
44-	github.com/picosh/send v0.0.0-20250304201154-e36cd3bbbb35
45-	github.com/picosh/tunkit v0.0.0-00010101000000-000000000000
46 	github.com/picosh/utils v0.0.0-20241120033529-8ca070c09bf4
47 	github.com/pkg/sftp v1.13.7
48 	github.com/prometheus/client_golang v1.21.0-rc.0
49@@ -80,12 +86,8 @@ require (
50 	github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
51 	github.com/armon/go-metrics v0.4.1 // indirect
52 	github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b // indirect
53-	github.com/aws/aws-sdk-go-v2 v1.36.2 // indirect
54 	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect
55-	github.com/aws/aws-sdk-go-v2/config v1.29.7 // indirect
56-	github.com/aws/aws-sdk-go-v2/credentials v1.17.60 // indirect
57 	github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.29 // indirect
58-	github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.63 // indirect
59 	github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.33 // indirect
60 	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.33 // indirect
61 	github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
62@@ -94,11 +96,9 @@ require (
63 	github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.6.1 // indirect
64 	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.14 // indirect
65 	github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.14 // indirect
66-	github.com/aws/aws-sdk-go-v2/service/s3 v1.77.1 // indirect
67 	github.com/aws/aws-sdk-go-v2/service/sso v1.24.16 // indirect
68 	github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.15 // indirect
69 	github.com/aws/aws-sdk-go-v2/service/sts v1.33.15 // indirect
70-	github.com/aws/smithy-go v1.22.3 // indirect
71 	github.com/aymerick/douceur v0.2.0 // indirect
72 	github.com/beorn7/perks v1.0.1 // indirect
73 	github.com/bits-and-blooms/bitset v1.5.0 // indirect
74@@ -210,7 +210,6 @@ require (
75 	github.com/mholt/acmez/v2 v2.0.1 // indirect
76 	github.com/miekg/dns v1.1.63 // indirect
77 	github.com/minio/crc64nvme v1.0.1 // indirect
78-	github.com/minio/madmin-go/v3 v3.0.94 // indirect
79 	github.com/minio/md5-simd v1.1.2 // indirect
80 	github.com/mitchellh/copystructure v1.2.0 // indirect
81 	github.com/mitchellh/go-ps v1.0.0 // indirect
82@@ -228,7 +227,6 @@ require (
83 	github.com/nutsdb/nutsdb v1.0.4 // indirect
84 	github.com/onsi/ginkgo/v2 v2.15.0 // indirect
85 	github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
86-	github.com/picosh/go-rsync-receiver v0.0.0-20250304201040-fcc11dd22d79 // indirect
87 	github.com/pierrec/lz4/v4 v4.1.21 // indirect
88 	github.com/pkg/errors v0.9.1 // indirect
89 	github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
R auth/__snapshots__/api_test.snap => pkg/apps/auth/__snapshots__/api_test.snap
+0, -0
R auth/api.go => pkg/apps/auth/api.go
+3, -3
 1@@ -16,9 +16,9 @@ import (
 2 	"strings"
 3 	"time"
 4 
 5-	"github.com/picosh/pico/db"
 6-	"github.com/picosh/pico/db/postgres"
 7-	"github.com/picosh/pico/shared"
 8+	"github.com/picosh/pico/pkg/db"
 9+	"github.com/picosh/pico/pkg/db/postgres"
10+	"github.com/picosh/pico/pkg/shared"
11 	"github.com/picosh/utils"
12 	"github.com/picosh/utils/pipe/metrics"
13 	"github.com/prometheus/client_golang/prometheus/promhttp"
R auth/api_test.go => pkg/apps/auth/api_test.go
+3, -3
 1@@ -12,9 +12,9 @@ import (
 2 	"time"
 3 
 4 	"github.com/gkampitakis/go-snaps/snaps"
 5-	"github.com/picosh/pico/db"
 6-	"github.com/picosh/pico/db/stub"
 7-	"github.com/picosh/pico/shared"
 8+	"github.com/picosh/pico/pkg/db"
 9+	"github.com/picosh/pico/pkg/db/stub"
10+	"github.com/picosh/pico/pkg/shared"
11 )
12 
13 var testUserID = "user-1"
R auth/html/base.layout.tmpl => pkg/apps/auth/html/base.layout.tmpl
+0, -0
R auth/html/footer.partial.tmpl => pkg/apps/auth/html/footer.partial.tmpl
+0, -0
R auth/html/marketing-footer.partial.tmpl => pkg/apps/auth/html/marketing-footer.partial.tmpl
+0, -0
R auth/html/redirect.page.tmpl => pkg/apps/auth/html/redirect.page.tmpl
+0, -0
R auth/public/apple-touch-icon.png => pkg/apps/auth/public/apple-touch-icon.png
+0, -0
R auth/public/favicon-16x16.png => pkg/apps/auth/public/favicon-16x16.png
+0, -0
R auth/public/favicon.ico => pkg/apps/auth/public/favicon.ico
+0, -0
R auth/public/main.css => pkg/apps/auth/public/main.css
+0, -0
R auth/public/robots.txt => pkg/apps/auth/public/robots.txt
+0, -0
R feeds/api.go => pkg/apps/feeds/api.go
+2, -2
 1@@ -6,8 +6,8 @@ import (
 2 	"net/url"
 3 	"time"
 4 
 5-	"github.com/picosh/pico/db/postgres"
 6-	"github.com/picosh/pico/shared"
 7+	"github.com/picosh/pico/pkg/db/postgres"
 8+	"github.com/picosh/pico/pkg/shared"
 9 	"github.com/prometheus/client_golang/prometheus/promhttp"
10 )
11 
R feeds/cli.go => pkg/apps/feeds/cli.go
+3, -3
 1@@ -5,9 +5,9 @@ import (
 2 	"text/tabwriter"
 3 	"time"
 4 
 5-	"github.com/picosh/pico/db"
 6-	"github.com/picosh/pico/pssh"
 7-	"github.com/picosh/pico/shared"
 8+	"github.com/picosh/pico/pkg/db"
 9+	"github.com/picosh/pico/pkg/pssh"
10+	"github.com/picosh/pico/pkg/shared"
11 )
12 
13 func WishMiddleware(dbpool db.DB, cfg *shared.ConfigSite) pssh.SSHServerMiddleware {
R feeds/config.go => pkg/apps/feeds/config.go
+1, -1
1@@ -1,7 +1,7 @@
2 package feeds
3 
4 import (
5-	"github.com/picosh/pico/shared"
6+	"github.com/picosh/pico/pkg/shared"
7 	"github.com/picosh/utils"
8 )
9 
R feeds/cron.go => pkg/apps/feeds/cron.go
+2, -2
 1@@ -15,8 +15,8 @@ import (
 2 	"time"
 3 
 4 	"github.com/mmcdole/gofeed"
 5-	"github.com/picosh/pico/db"
 6-	"github.com/picosh/pico/shared"
 7+	"github.com/picosh/pico/pkg/db"
 8+	"github.com/picosh/pico/pkg/shared"
 9 	"github.com/sendgrid/sendgrid-go"
10 	"github.com/sendgrid/sendgrid-go/helpers/mail"
11 )
R feeds/html/base.layout.tmpl => pkg/apps/feeds/html/base.layout.tmpl
+0, -0
R feeds/html/digest.page.tmpl => pkg/apps/feeds/html/digest.page.tmpl
+0, -0
R feeds/html/digest_text.page.tmpl => pkg/apps/feeds/html/digest_text.page.tmpl
+0, -0
R feeds/html/footer.partial.tmpl => pkg/apps/feeds/html/footer.partial.tmpl
+0, -0
R feeds/html/marketing-footer.partial.tmpl => pkg/apps/feeds/html/marketing-footer.partial.tmpl
+0, -0
R feeds/html/marketing.page.tmpl => pkg/apps/feeds/html/marketing.page.tmpl
+0, -0
R feeds/public/apple-touch-icon.png => pkg/apps/feeds/public/apple-touch-icon.png
+0, -0
R feeds/public/card.png => pkg/apps/feeds/public/card.png
+0, -0
R feeds/public/favicon-16x16.png => pkg/apps/feeds/public/favicon-16x16.png
+0, -0
R feeds/public/favicon.ico => pkg/apps/feeds/public/favicon.ico
+0, -0
R feeds/public/main.css => pkg/apps/feeds/public/main.css
+0, -0
R feeds/public/robots.txt => pkg/apps/feeds/public/robots.txt
+0, -0
R feeds/scp_hooks.go => pkg/apps/feeds/scp_hooks.go
+4, -4
 1@@ -8,10 +8,10 @@ import (
 2 	"strings"
 3 	"time"
 4 
 5-	"github.com/picosh/pico/db"
 6-	"github.com/picosh/pico/filehandlers"
 7-	"github.com/picosh/pico/pssh"
 8-	"github.com/picosh/pico/shared"
 9+	"github.com/picosh/pico/pkg/db"
10+	"github.com/picosh/pico/pkg/filehandlers"
11+	"github.com/picosh/pico/pkg/pssh"
12+	"github.com/picosh/pico/pkg/shared"
13 	"github.com/picosh/utils"
14 )
15 
R feeds/ssh.go => pkg/apps/feeds/ssh.go
+10, -10
 1@@ -6,16 +6,16 @@ import (
 2 	"os/signal"
 3 	"syscall"
 4 
 5-	"github.com/picosh/pico/db/postgres"
 6-	"github.com/picosh/pico/filehandlers"
 7-	"github.com/picosh/pico/pssh"
 8-	"github.com/picosh/pico/shared"
 9-	"github.com/picosh/send/auth"
10-	"github.com/picosh/send/list"
11-	"github.com/picosh/send/pipe"
12-	"github.com/picosh/send/protocols/rsync"
13-	"github.com/picosh/send/protocols/scp"
14-	"github.com/picosh/send/protocols/sftp"
15+	"github.com/picosh/pico/pkg/db/postgres"
16+	"github.com/picosh/pico/pkg/filehandlers"
17+	"github.com/picosh/pico/pkg/pssh"
18+	"github.com/picosh/pico/pkg/send/auth"
19+	"github.com/picosh/pico/pkg/send/list"
20+	"github.com/picosh/pico/pkg/send/pipe"
21+	"github.com/picosh/pico/pkg/send/protocols/rsync"
22+	"github.com/picosh/pico/pkg/send/protocols/scp"
23+	"github.com/picosh/pico/pkg/send/protocols/sftp"
24+	"github.com/picosh/pico/pkg/shared"
25 	"github.com/picosh/utils"
26 	"golang.org/x/crypto/ssh"
27 )
R pastes/api.go => pkg/apps/pastes/api.go
+3, -3
 1@@ -8,9 +8,9 @@ import (
 2 	"os"
 3 	"time"
 4 
 5-	"github.com/picosh/pico/db"
 6-	"github.com/picosh/pico/db/postgres"
 7-	"github.com/picosh/pico/shared"
 8+	"github.com/picosh/pico/pkg/db"
 9+	"github.com/picosh/pico/pkg/db/postgres"
10+	"github.com/picosh/pico/pkg/shared"
11 	"github.com/picosh/utils"
12 	"github.com/prometheus/client_golang/prometheus/promhttp"
13 )
R pastes/config.go => pkg/apps/pastes/config.go
+1, -1
1@@ -1,7 +1,7 @@
2 package pastes
3 
4 import (
5-	"github.com/picosh/pico/shared"
6+	"github.com/picosh/pico/pkg/shared"
7 	"github.com/picosh/utils"
8 )
9 
R pastes/cron.go => pkg/apps/pastes/cron.go
+2, -2
 1@@ -3,8 +3,8 @@ package pastes
 2 import (
 3 	"time"
 4 
 5-	"github.com/picosh/pico/db"
 6-	"github.com/picosh/pico/shared"
 7+	"github.com/picosh/pico/pkg/db"
 8+	"github.com/picosh/pico/pkg/shared"
 9 )
10 
11 func deleteExpiredPosts(cfg *shared.ConfigSite, dbpool db.DB) error {
R pastes/html/base.layout.tmpl => pkg/apps/pastes/html/base.layout.tmpl
+0, -0
R pastes/html/blog.page.tmpl => pkg/apps/pastes/html/blog.page.tmpl
+0, -0
R pastes/html/footer.partial.tmpl => pkg/apps/pastes/html/footer.partial.tmpl
+0, -0
R pastes/html/marketing-footer.partial.tmpl => pkg/apps/pastes/html/marketing-footer.partial.tmpl
+0, -0
R pastes/html/marketing.page.tmpl => pkg/apps/pastes/html/marketing.page.tmpl
+0, -0
R pastes/html/post.page.tmpl => pkg/apps/pastes/html/post.page.tmpl
+0, -0
R pastes/parser.go => pkg/apps/pastes/parser.go
+0, -0
R pastes/public/apple-touch-icon.png => pkg/apps/pastes/public/apple-touch-icon.png
+0, -0
R pastes/public/card.png => pkg/apps/pastes/public/card.png
+0, -0
R pastes/public/favicon-16x16.png => pkg/apps/pastes/public/favicon-16x16.png
+0, -0
R pastes/public/favicon.ico => pkg/apps/pastes/public/favicon.ico
+0, -0
R pastes/public/main.css => pkg/apps/pastes/public/main.css
+0, -0
R pastes/public/robots.txt => pkg/apps/pastes/public/robots.txt
+0, -0
R pastes/public/smol.css => pkg/apps/pastes/public/smol.css
+0, -0
R pastes/public/syntax.css => pkg/apps/pastes/public/syntax.css
+0, -0
R pastes/scp_hooks.go => pkg/apps/pastes/scp_hooks.go
+4, -4
 1@@ -7,10 +7,10 @@ import (
 2 	"time"
 3 
 4 	"github.com/araddon/dateparse"
 5-	"github.com/picosh/pico/db"
 6-	"github.com/picosh/pico/filehandlers"
 7-	"github.com/picosh/pico/pssh"
 8-	"github.com/picosh/pico/shared"
 9+	"github.com/picosh/pico/pkg/db"
10+	"github.com/picosh/pico/pkg/filehandlers"
11+	"github.com/picosh/pico/pkg/pssh"
12+	"github.com/picosh/pico/pkg/shared"
13 	"github.com/picosh/utils"
14 )
15 
R pastes/ssh.go => pkg/apps/pastes/ssh.go
+10, -10
 1@@ -6,16 +6,16 @@ import (
 2 	"os/signal"
 3 	"syscall"
 4 
 5-	"github.com/picosh/pico/db/postgres"
 6-	"github.com/picosh/pico/filehandlers"
 7-	"github.com/picosh/pico/pssh"
 8-	"github.com/picosh/pico/shared"
 9-	"github.com/picosh/send/auth"
10-	"github.com/picosh/send/list"
11-	"github.com/picosh/send/pipe"
12-	"github.com/picosh/send/protocols/rsync"
13-	"github.com/picosh/send/protocols/scp"
14-	"github.com/picosh/send/protocols/sftp"
15+	"github.com/picosh/pico/pkg/db/postgres"
16+	"github.com/picosh/pico/pkg/filehandlers"
17+	"github.com/picosh/pico/pkg/pssh"
18+	"github.com/picosh/pico/pkg/send/auth"
19+	"github.com/picosh/pico/pkg/send/list"
20+	"github.com/picosh/pico/pkg/send/pipe"
21+	"github.com/picosh/pico/pkg/send/protocols/rsync"
22+	"github.com/picosh/pico/pkg/send/protocols/scp"
23+	"github.com/picosh/pico/pkg/send/protocols/sftp"
24+	"github.com/picosh/pico/pkg/shared"
25 	"github.com/picosh/utils"
26 	"golang.org/x/crypto/ssh"
27 )
R pgs/access.go => pkg/apps/pgs/access.go
+1, -1
1@@ -3,7 +3,7 @@ package pgs
2 import (
3 	"slices"
4 
5-	"github.com/picosh/pico/db"
6+	"github.com/picosh/pico/pkg/db"
7 	"golang.org/x/crypto/ssh"
8 )
9 
R pgs/calc_route.go => pkg/apps/pgs/calc_route.go
+3, -3
 1@@ -8,9 +8,9 @@ import (
 2 	"regexp"
 3 	"strings"
 4 
 5-	"github.com/picosh/pico/shared"
 6-	"github.com/picosh/pico/shared/storage"
 7-	"github.com/picosh/send/utils"
 8+	"github.com/picosh/pico/pkg/send/utils"
 9+	"github.com/picosh/pico/pkg/shared"
10+	"github.com/picosh/pico/pkg/shared/storage"
11 )
12 
13 type HttpReply struct {
R pgs/calc_route_test.go => pkg/apps/pgs/calc_route_test.go
+0, -0
R pgs/cli.go => pkg/apps/pgs/cli.go
+4, -4
 1@@ -11,10 +11,10 @@ import (
 2 	"text/tabwriter"
 3 	"time"
 4 
 5-	"github.com/picosh/pico/db"
 6-	pgsdb "github.com/picosh/pico/pgs/db"
 7-	"github.com/picosh/pico/shared"
 8-	sst "github.com/picosh/pobj/storage"
 9+	pgsdb "github.com/picosh/pico/pkg/apps/pgs/db"
10+	"github.com/picosh/pico/pkg/db"
11+	sst "github.com/picosh/pico/pkg/pobj/storage"
12+	"github.com/picosh/pico/pkg/shared"
13 	"github.com/picosh/utils"
14 )
15 
R pgs/cli_wish.go => pkg/apps/pgs/cli_wish.go
+4, -4
 1@@ -6,10 +6,10 @@ import (
 2 	"slices"
 3 	"strings"
 4 
 5-	"github.com/picosh/pico/db"
 6-	pgsdb "github.com/picosh/pico/pgs/db"
 7-	"github.com/picosh/pico/pssh"
 8-	sendutils "github.com/picosh/send/utils"
 9+	pgsdb "github.com/picosh/pico/pkg/apps/pgs/db"
10+	"github.com/picosh/pico/pkg/db"
11+	"github.com/picosh/pico/pkg/pssh"
12+	sendutils "github.com/picosh/pico/pkg/send/utils"
13 	"github.com/picosh/utils"
14 )
15 
R pgs/config.go => pkg/apps/pgs/config.go
+2, -2
 1@@ -6,8 +6,8 @@ import (
 2 	"path/filepath"
 3 	"time"
 4 
 5-	pgsdb "github.com/picosh/pico/pgs/db"
 6-	"github.com/picosh/pico/shared/storage"
 7+	pgsdb "github.com/picosh/pico/pkg/apps/pgs/db"
 8+	"github.com/picosh/pico/pkg/shared/storage"
 9 	"github.com/picosh/utils"
10 )
11 
R pgs/db/db.go => pkg/apps/pgs/db/db.go
+1, -1
1@@ -1,6 +1,6 @@
2 package pgsdb
3 
4-import "github.com/picosh/pico/db"
5+import "github.com/picosh/pico/pkg/db"
6 
7 type PgsDB interface {
8 	FindUser(userID string) (*db.User, error)
R pgs/db/memory.go => pkg/apps/pgs/db/memory.go
+1, -1
1@@ -6,7 +6,7 @@ import (
2 	"time"
3 
4 	"github.com/google/uuid"
5-	"github.com/picosh/pico/db"
6+	"github.com/picosh/pico/pkg/db"
7 	"github.com/picosh/utils"
8 )
9 
R pgs/db/postgres.go => pkg/apps/pgs/db/postgres.go
+1, -1
1@@ -7,7 +7,7 @@ import (
2 
3 	"github.com/jmoiron/sqlx"
4 	_ "github.com/lib/pq"
5-	"github.com/picosh/pico/db"
6+	"github.com/picosh/pico/pkg/db"
7 	"github.com/picosh/utils"
8 )
9 
R pgs/fs.go => pkg/apps/pgs/fs.go
+0, -0
R pgs/header.go => pkg/apps/pgs/header.go
+0, -0
R pgs/header_test.go => pkg/apps/pgs/header_test.go
+0, -0
R pgs/html/base.layout.tmpl => pkg/apps/pgs/html/base.layout.tmpl
+0, -0
R pgs/html/footer.partial.tmpl => pkg/apps/pgs/html/footer.partial.tmpl
+0, -0
R pgs/html/marketing-footer.partial.tmpl => pkg/apps/pgs/html/marketing-footer.partial.tmpl
+0, -0
R pgs/html/marketing.page.tmpl => pkg/apps/pgs/html/marketing.page.tmpl
+0, -0
R pgs/public/card.png => pkg/apps/pgs/public/card.png
+0, -0
R pgs/public/favicon-16x16.png => pkg/apps/pgs/public/favicon-16x16.png
+0, -0
R pgs/public/favicon.ico => pkg/apps/pgs/public/favicon.ico
+0, -0
R pgs/public/main.css => pkg/apps/pgs/public/main.css
+0, -0
R pgs/public/robots.txt => pkg/apps/pgs/public/robots.txt
+0, -0
R pgs/redirect.go => pkg/apps/pgs/redirect.go
+0, -0
R pgs/redirect_test.go => pkg/apps/pgs/redirect_test.go
+0, -0
R pgs/ssh.go => pkg/apps/pgs/ssh.go
+9, -9
 1@@ -6,15 +6,15 @@ import (
 2 	"os/signal"
 3 	"syscall"
 4 
 5-	"github.com/picosh/pico/pssh"
 6-	"github.com/picosh/pico/shared"
 7-	"github.com/picosh/send/auth"
 8-	"github.com/picosh/send/list"
 9-	"github.com/picosh/send/pipe"
10-	"github.com/picosh/send/protocols/rsync"
11-	"github.com/picosh/send/protocols/scp"
12-	"github.com/picosh/send/protocols/sftp"
13-	"github.com/picosh/tunkit"
14+	"github.com/picosh/pico/pkg/pssh"
15+	"github.com/picosh/pico/pkg/send/auth"
16+	"github.com/picosh/pico/pkg/send/list"
17+	"github.com/picosh/pico/pkg/send/pipe"
18+	"github.com/picosh/pico/pkg/send/protocols/rsync"
19+	"github.com/picosh/pico/pkg/send/protocols/scp"
20+	"github.com/picosh/pico/pkg/send/protocols/sftp"
21+	"github.com/picosh/pico/pkg/shared"
22+	"github.com/picosh/pico/pkg/tunkit"
23 	"github.com/picosh/utils"
24 	"golang.org/x/crypto/ssh"
25 )
R pgs/ssh_test.go => pkg/apps/pgs/ssh_test.go
+3, -3
 1@@ -14,9 +14,9 @@ import (
 2 	"testing"
 3 	"time"
 4 
 5-	"github.com/picosh/pico/db"
 6-	pgsdb "github.com/picosh/pico/pgs/db"
 7-	"github.com/picosh/pico/shared/storage"
 8+	pgsdb "github.com/picosh/pico/pkg/apps/pgs/db"
 9+	"github.com/picosh/pico/pkg/db"
10+	"github.com/picosh/pico/pkg/shared/storage"
11 	"github.com/picosh/utils"
12 	"github.com/pkg/sftp"
13 	"github.com/prometheus/client_golang/prometheus"
R pgs/tunnel.go => pkg/apps/pgs/tunnel.go
+3, -3
 1@@ -6,9 +6,9 @@ import (
 2 	"strings"
 3 	"time"
 4 
 5-	"github.com/picosh/pico/db"
 6-	"github.com/picosh/pico/pssh"
 7-	"github.com/picosh/pico/shared"
 8+	"github.com/picosh/pico/pkg/db"
 9+	"github.com/picosh/pico/pkg/pssh"
10+	"github.com/picosh/pico/pkg/shared"
11 	"golang.org/x/crypto/ssh"
12 )
13 
R pgs/uploader.go => pkg/apps/pgs/uploader.go
+7, -7
 1@@ -15,13 +15,13 @@ import (
 2 	"sync"
 3 	"time"
 4 
 5-	"github.com/picosh/pico/db"
 6-	pgsdb "github.com/picosh/pico/pgs/db"
 7-	"github.com/picosh/pico/pssh"
 8-	"github.com/picosh/pico/shared"
 9-	"github.com/picosh/pobj"
10-	sst "github.com/picosh/pobj/storage"
11-	sendutils "github.com/picosh/send/utils"
12+	pgsdb "github.com/picosh/pico/pkg/apps/pgs/db"
13+	"github.com/picosh/pico/pkg/db"
14+	"github.com/picosh/pico/pkg/pobj"
15+	sst "github.com/picosh/pico/pkg/pobj/storage"
16+	"github.com/picosh/pico/pkg/pssh"
17+	sendutils "github.com/picosh/pico/pkg/send/utils"
18+	"github.com/picosh/pico/pkg/shared"
19 	"github.com/picosh/utils"
20 	ignore "github.com/sabhiram/go-gitignore"
21 )
R pgs/web.go => pkg/apps/pgs/web.go
+4, -4
 1@@ -20,10 +20,10 @@ import (
 2 	"github.com/darkweak/souin/plugins/souin/storages"
 3 	"github.com/darkweak/storages/core"
 4 	"github.com/gorilla/feeds"
 5-	"github.com/picosh/pico/db"
 6-	"github.com/picosh/pico/shared"
 7-	"github.com/picosh/pico/shared/storage"
 8-	sst "github.com/picosh/pobj/storage"
 9+	"github.com/picosh/pico/pkg/db"
10+	sst "github.com/picosh/pico/pkg/pobj/storage"
11+	"github.com/picosh/pico/pkg/shared"
12+	"github.com/picosh/pico/pkg/shared/storage"
13 	"github.com/prometheus/client_golang/prometheus/promhttp"
14 	"google.golang.org/protobuf/proto"
15 )
R pgs/web_asset_handler.go => pkg/apps/pgs/web_asset_handler.go
+2, -2
 1@@ -14,8 +14,8 @@ import (
 2 	"net/http/httputil"
 3 	_ "net/http/pprof"
 4 
 5-	"github.com/picosh/pico/shared/storage"
 6-	sst "github.com/picosh/pobj/storage"
 7+	sst "github.com/picosh/pico/pkg/pobj/storage"
 8+	"github.com/picosh/pico/pkg/shared/storage"
 9 )
10 
11 type ApiAssetHandler struct {
R pgs/web_cache.go => pkg/apps/pgs/web_cache.go
+1, -1
1@@ -6,7 +6,7 @@ import (
2 	"log/slog"
3 	"time"
4 
5-	"github.com/picosh/pico/shared"
6+	"github.com/picosh/pico/pkg/shared"
7 	"github.com/picosh/utils/pipe"
8 )
9 
R pgs/web_test.go => pkg/apps/pgs/web_test.go
+4, -4
 1@@ -10,10 +10,10 @@ import (
 2 	"testing"
 3 	"time"
 4 
 5-	pgsdb "github.com/picosh/pico/pgs/db"
 6-	"github.com/picosh/pico/shared"
 7-	"github.com/picosh/pico/shared/storage"
 8-	sst "github.com/picosh/pobj/storage"
 9+	pgsdb "github.com/picosh/pico/pkg/apps/pgs/db"
10+	sst "github.com/picosh/pico/pkg/pobj/storage"
11+	"github.com/picosh/pico/pkg/shared"
12+	"github.com/picosh/pico/pkg/shared/storage"
13 )
14 
15 type ApiExample struct {
R pico/cli.go => pkg/apps/pico/cli.go
+3, -3
 1@@ -8,9 +8,9 @@ import (
 2 	"log/slog"
 3 	"strings"
 4 
 5-	"github.com/picosh/pico/db"
 6-	"github.com/picosh/pico/pssh"
 7-	"github.com/picosh/pico/shared"
 8+	"github.com/picosh/pico/pkg/db"
 9+	"github.com/picosh/pico/pkg/pssh"
10+	"github.com/picosh/pico/pkg/shared"
11 	"github.com/picosh/utils"
12 
13 	pipeLogger "github.com/picosh/utils/pipe/log"
R pico/config.go => pkg/apps/pico/config.go
+1, -1
1@@ -1,7 +1,7 @@
2 package pico
3 
4 import (
5-	"github.com/picosh/pico/shared"
6+	"github.com/picosh/pico/pkg/shared"
7 	"github.com/picosh/utils"
8 )
9 
R pico/file_handler.go => pkg/apps/pico/file_handler.go
+4, -4
 1@@ -11,10 +11,10 @@ import (
 2 	"strings"
 3 	"time"
 4 
 5-	"github.com/picosh/pico/db"
 6-	"github.com/picosh/pico/pssh"
 7-	"github.com/picosh/pico/shared"
 8-	sendutils "github.com/picosh/send/utils"
 9+	"github.com/picosh/pico/pkg/db"
10+	"github.com/picosh/pico/pkg/pssh"
11+	sendutils "github.com/picosh/pico/pkg/send/utils"
12+	"github.com/picosh/pico/pkg/shared"
13 	"github.com/picosh/utils"
14 	"golang.org/x/crypto/ssh"
15 )
R pico/html/.gitkeep => pkg/apps/pico/html/.gitkeep
+0, -0
R pico/ssh.go => pkg/apps/pico/ssh.go
+10, -10
 1@@ -7,16 +7,16 @@ import (
 2 	"syscall"
 3 
 4 	"git.sr.ht/~rockorager/vaxis"
 5-	"github.com/picosh/pico/db/postgres"
 6-	"github.com/picosh/pico/pssh"
 7-	"github.com/picosh/pico/shared"
 8-	"github.com/picosh/pico/tui"
 9-	"github.com/picosh/send/auth"
10-	"github.com/picosh/send/list"
11-	"github.com/picosh/send/pipe"
12-	"github.com/picosh/send/protocols/rsync"
13-	"github.com/picosh/send/protocols/scp"
14-	"github.com/picosh/send/protocols/sftp"
15+	"github.com/picosh/pico/pkg/db/postgres"
16+	"github.com/picosh/pico/pkg/pssh"
17+	"github.com/picosh/pico/pkg/send/auth"
18+	"github.com/picosh/pico/pkg/send/list"
19+	"github.com/picosh/pico/pkg/send/pipe"
20+	"github.com/picosh/pico/pkg/send/protocols/rsync"
21+	"github.com/picosh/pico/pkg/send/protocols/scp"
22+	"github.com/picosh/pico/pkg/send/protocols/sftp"
23+	"github.com/picosh/pico/pkg/shared"
24+	"github.com/picosh/pico/pkg/tui"
25 	"github.com/picosh/utils"
26 	"golang.org/x/crypto/ssh"
27 )
R pipe/api.go => pkg/apps/pipe/api.go
+2, -2
 1@@ -16,8 +16,8 @@ import (
 2 
 3 	"github.com/google/uuid"
 4 	"github.com/gorilla/websocket"
 5-	"github.com/picosh/pico/db/postgres"
 6-	"github.com/picosh/pico/shared"
 7+	"github.com/picosh/pico/pkg/db/postgres"
 8+	"github.com/picosh/pico/pkg/shared"
 9 	"github.com/picosh/utils/pipe"
10 	"github.com/prometheus/client_golang/prometheus/promhttp"
11 )
R pipe/cli.go => pkg/apps/pipe/cli.go
+3, -3
 1@@ -14,9 +14,9 @@ import (
 2 
 3 	"github.com/antoniomika/syncmap"
 4 	"github.com/google/uuid"
 5-	"github.com/picosh/pico/db"
 6-	"github.com/picosh/pico/pssh"
 7-	"github.com/picosh/pico/shared"
 8+	"github.com/picosh/pico/pkg/db"
 9+	"github.com/picosh/pico/pkg/pssh"
10+	"github.com/picosh/pico/pkg/shared"
11 	psub "github.com/picosh/pubsub"
12 	gossh "golang.org/x/crypto/ssh"
13 )
R pipe/config.go => pkg/apps/pipe/config.go
+1, -1
1@@ -1,7 +1,7 @@
2 package pipe
3 
4 import (
5-	"github.com/picosh/pico/shared"
6+	"github.com/picosh/pico/pkg/shared"
7 	"github.com/picosh/utils"
8 )
9 
R pipe/html/base.layout.tmpl => pkg/apps/pipe/html/base.layout.tmpl
+0, -0
R pipe/html/footer.partial.tmpl => pkg/apps/pipe/html/footer.partial.tmpl
+0, -0
R pipe/html/marketing-footer.partial.tmpl => pkg/apps/pipe/html/marketing-footer.partial.tmpl
+0, -0
R pipe/html/marketing.page.tmpl => pkg/apps/pipe/html/marketing.page.tmpl
+0, -0
R pipe/public/anim.js => pkg/apps/pipe/public/anim.js
+0, -0
R pipe/public/apple-touch-icon.png => pkg/apps/pipe/public/apple-touch-icon.png
+0, -0
R pipe/public/favicon-16x16.png => pkg/apps/pipe/public/favicon-16x16.png
+0, -0
R pipe/public/favicon.ico => pkg/apps/pipe/public/favicon.ico
+0, -0
R pipe/public/robots.txt => pkg/apps/pipe/public/robots.txt
+0, -0
R pipe/ssh.go => pkg/apps/pipe/ssh.go
+3, -3
 1@@ -7,9 +7,9 @@ import (
 2 	"syscall"
 3 
 4 	"github.com/antoniomika/syncmap"
 5-	"github.com/picosh/pico/db/postgres"
 6-	"github.com/picosh/pico/pssh"
 7-	"github.com/picosh/pico/shared"
 8+	"github.com/picosh/pico/pkg/db/postgres"
 9+	"github.com/picosh/pico/pkg/pssh"
10+	"github.com/picosh/pico/pkg/shared"
11 	psub "github.com/picosh/pubsub"
12 	"github.com/picosh/utils"
13 	"golang.org/x/crypto/ssh"
R prose/api.go => pkg/apps/prose/api.go
+4, -4
 1@@ -15,10 +15,10 @@ import (
 2 	"slices"
 3 
 4 	"github.com/gorilla/feeds"
 5-	"github.com/picosh/pico/db"
 6-	"github.com/picosh/pico/db/postgres"
 7-	"github.com/picosh/pico/shared"
 8-	"github.com/picosh/pico/shared/storage"
 9+	"github.com/picosh/pico/pkg/db"
10+	"github.com/picosh/pico/pkg/db/postgres"
11+	"github.com/picosh/pico/pkg/shared"
12+	"github.com/picosh/pico/pkg/shared/storage"
13 	"github.com/picosh/utils"
14 	"github.com/prometheus/client_golang/prometheus/promhttp"
15 )
R prose/artifacts/main.css => pkg/apps/prose/artifacts/main.css
+0, -0
R prose/config.go => pkg/apps/prose/config.go
+1, -1
1@@ -1,7 +1,7 @@
2 package prose
3 
4 import (
5-	"github.com/picosh/pico/shared"
6+	"github.com/picosh/pico/pkg/shared"
7 	"github.com/picosh/utils"
8 )
9 
R prose/html/base.layout.tmpl => pkg/apps/prose/html/base.layout.tmpl
+0, -0
R prose/html/blog-aside.partial.tmpl => pkg/apps/prose/html/blog-aside.partial.tmpl
+0, -0
R prose/html/blog-default.partial.tmpl => pkg/apps/prose/html/blog-default.partial.tmpl
+0, -0
R prose/html/blog.page.tmpl => pkg/apps/prose/html/blog.page.tmpl
+0, -0
R prose/html/footer.partial.tmpl => pkg/apps/prose/html/footer.partial.tmpl
+0, -0
R prose/html/imgs.page.tmpl => pkg/apps/prose/html/imgs.page.tmpl
+0, -0
R prose/html/marketing-footer.partial.tmpl => pkg/apps/prose/html/marketing-footer.partial.tmpl
+0, -0
R prose/html/post.page.tmpl => pkg/apps/prose/html/post.page.tmpl
+0, -0
R prose/html/read.page.tmpl => pkg/apps/prose/html/read.page.tmpl
+0, -0
R prose/html/rss.page.tmpl => pkg/apps/prose/html/rss.page.tmpl
+0, -0
R prose/public/card.png => pkg/apps/prose/public/card.png
+0, -0
R prose/public/favicon-16x16.png => pkg/apps/prose/public/favicon-16x16.png
+0, -0
R prose/public/favicon.ico => pkg/apps/prose/public/favicon.ico
+0, -0
R prose/public/robots.txt => pkg/apps/prose/public/robots.txt
+0, -0
R prose/public/smol-v2.css => pkg/apps/prose/public/smol-v2.css
+0, -0
R prose/public/smol.css => pkg/apps/prose/public/smol.css
+0, -0
R prose/public/syntax.css => pkg/apps/prose/public/syntax.css
+0, -0
R prose/scp_hooks.go => pkg/apps/prose/scp_hooks.go
+4, -4
 1@@ -6,10 +6,10 @@ import (
 2 
 3 	"slices"
 4 
 5-	"github.com/picosh/pico/db"
 6-	"github.com/picosh/pico/filehandlers"
 7-	"github.com/picosh/pico/pssh"
 8-	"github.com/picosh/pico/shared"
 9+	"github.com/picosh/pico/pkg/db"
10+	"github.com/picosh/pico/pkg/filehandlers"
11+	"github.com/picosh/pico/pkg/pssh"
12+	"github.com/picosh/pico/pkg/shared"
13 	"github.com/picosh/utils"
14 	pipeUtil "github.com/picosh/utils/pipe"
15 )
R prose/ssh.go => pkg/apps/prose/ssh.go
+12, -12
 1@@ -6,18 +6,18 @@ import (
 2 	"os/signal"
 3 	"syscall"
 4 
 5-	"github.com/picosh/pico/db/postgres"
 6-	"github.com/picosh/pico/filehandlers"
 7-	uploadimgs "github.com/picosh/pico/filehandlers/imgs"
 8-	"github.com/picosh/pico/pssh"
 9-	"github.com/picosh/pico/shared"
10-	"github.com/picosh/pico/shared/storage"
11-	"github.com/picosh/send/auth"
12-	"github.com/picosh/send/list"
13-	"github.com/picosh/send/pipe"
14-	"github.com/picosh/send/protocols/rsync"
15-	"github.com/picosh/send/protocols/scp"
16-	"github.com/picosh/send/protocols/sftp"
17+	"github.com/picosh/pico/pkg/db/postgres"
18+	"github.com/picosh/pico/pkg/filehandlers"
19+	uploadimgs "github.com/picosh/pico/pkg/filehandlers/imgs"
20+	"github.com/picosh/pico/pkg/pssh"
21+	"github.com/picosh/pico/pkg/send/auth"
22+	"github.com/picosh/pico/pkg/send/list"
23+	"github.com/picosh/pico/pkg/send/pipe"
24+	"github.com/picosh/pico/pkg/send/protocols/rsync"
25+	"github.com/picosh/pico/pkg/send/protocols/scp"
26+	"github.com/picosh/pico/pkg/send/protocols/sftp"
27+	"github.com/picosh/pico/pkg/shared"
28+	"github.com/picosh/pico/pkg/shared/storage"
29 	"github.com/picosh/utils"
30 	"golang.org/x/crypto/ssh"
31 )
R db/db.go => pkg/db/db.go
+0, -0
R db/postgres/storage.go => pkg/db/postgres/storage.go
+1, -1
1@@ -13,7 +13,7 @@ import (
2 	"slices"
3 
4 	_ "github.com/lib/pq"
5-	"github.com/picosh/pico/db"
6+	"github.com/picosh/pico/pkg/db"
7 	"github.com/picosh/utils"
8 )
9 
R db/stub/stub.go => pkg/db/stub/stub.go
+1, -1
1@@ -6,7 +6,7 @@ import (
2 	"log/slog"
3 	"time"
4 
5-	"github.com/picosh/pico/db"
6+	"github.com/picosh/pico/pkg/db"
7 )
8 
9 type StubDB struct {
R db/util.go => pkg/db/util.go
+0, -0
R filehandlers/imgs/handler.go => pkg/filehandlers/imgs/handler.go
+7, -7
 1@@ -12,13 +12,13 @@ import (
 2 	"strings"
 3 
 4 	exifremove "github.com/neurosnap/go-exif-remove"
 5-	"github.com/picosh/pico/db"
 6-	"github.com/picosh/pico/pssh"
 7-	"github.com/picosh/pico/shared"
 8-	"github.com/picosh/pico/shared/storage"
 9-	"github.com/picosh/pobj"
10-	sst "github.com/picosh/pobj/storage"
11-	sendutils "github.com/picosh/send/utils"
12+	"github.com/picosh/pico/pkg/db"
13+	"github.com/picosh/pico/pkg/pobj"
14+	sst "github.com/picosh/pico/pkg/pobj/storage"
15+	"github.com/picosh/pico/pkg/pssh"
16+	sendutils "github.com/picosh/pico/pkg/send/utils"
17+	"github.com/picosh/pico/pkg/shared"
18+	"github.com/picosh/pico/pkg/shared/storage"
19 	"github.com/picosh/utils"
20 )
21 
R filehandlers/post_handler.go => pkg/filehandlers/post_handler.go
+4, -4
 1@@ -10,10 +10,10 @@ import (
 2 	"strings"
 3 	"time"
 4 
 5-	"github.com/picosh/pico/db"
 6-	"github.com/picosh/pico/pssh"
 7-	"github.com/picosh/pico/shared"
 8-	sendutils "github.com/picosh/send/utils"
 9+	"github.com/picosh/pico/pkg/db"
10+	"github.com/picosh/pico/pkg/pssh"
11+	sendutils "github.com/picosh/pico/pkg/send/utils"
12+	"github.com/picosh/pico/pkg/shared"
13 	"github.com/picosh/utils"
14 )
15 
R filehandlers/router_handler.go => pkg/filehandlers/router_handler.go
+4, -4
 1@@ -8,10 +8,10 @@ import (
 2 	"os"
 3 	"path/filepath"
 4 
 5-	"github.com/picosh/pico/db"
 6-	"github.com/picosh/pico/pssh"
 7-	"github.com/picosh/pico/shared"
 8-	"github.com/picosh/send/utils"
 9+	"github.com/picosh/pico/pkg/db"
10+	"github.com/picosh/pico/pkg/pssh"
11+	"github.com/picosh/pico/pkg/send/utils"
12+	"github.com/picosh/pico/pkg/shared"
13 )
14 
15 type ReadWriteHandler interface {
A pkg/pobj/asset.go
+45, -0
 1@@ -0,0 +1,45 @@
 2+package pobj
 3+
 4+import (
 5+	"fmt"
 6+
 7+	"github.com/picosh/pico/pkg/pssh"
 8+	"github.com/picosh/pico/pkg/send/utils"
 9+)
10+
11+type AssetNames interface {
12+	BucketName(sesh *pssh.SSHServerConnSession) (string, error)
13+	ObjectName(sesh *pssh.SSHServerConnSession, entry *utils.FileEntry) (string, error)
14+	PrintObjectName(sesh *pssh.SSHServerConnSession, entry *utils.FileEntry, bucketName string) (string, error)
15+}
16+
17+type AssetNamesBasic struct{}
18+
19+var _ AssetNames = &AssetNamesBasic{}
20+var _ AssetNames = (*AssetNamesBasic)(nil)
21+
22+func (an *AssetNamesBasic) BucketName(sesh *pssh.SSHServerConnSession) (string, error) {
23+	return sesh.User(), nil
24+}
25+func (an *AssetNamesBasic) ObjectName(sesh *pssh.SSHServerConnSession, entry *utils.FileEntry) (string, error) {
26+	return entry.Filepath, nil
27+}
28+func (an *AssetNamesBasic) PrintObjectName(sesh *pssh.SSHServerConnSession, entry *utils.FileEntry, bucketName string) (string, error) {
29+	objectName, err := an.ObjectName(sesh, entry)
30+	if err != nil {
31+		return "", err
32+	}
33+	return fmt.Sprintf("%s%s", bucketName, objectName), nil
34+}
35+
36+type AssetNamesForceBucket struct {
37+	*AssetNamesBasic
38+	Name string
39+}
40+
41+var _ AssetNames = &AssetNamesForceBucket{}
42+var _ AssetNames = (*AssetNamesForceBucket)(nil)
43+
44+func (an *AssetNamesForceBucket) BucketName(sesh *pssh.SSHServerConnSession) (string, error) {
45+	return an.Name, nil
46+}
A pkg/pobj/handler.go
+269, -0
  1@@ -0,0 +1,269 @@
  2+package pobj
  3+
  4+import (
  5+	"bytes"
  6+	"encoding/binary"
  7+	"fmt"
  8+	"io"
  9+	"log/slog"
 10+	"os"
 11+	"path/filepath"
 12+	"time"
 13+
 14+	"github.com/picosh/pico/pkg/pobj/storage"
 15+	"github.com/picosh/pico/pkg/pssh"
 16+	"github.com/picosh/pico/pkg/send/utils"
 17+)
 18+
 19+type ctxBucketKey struct{}
 20+
 21+func getBucket(ctx *pssh.SSHServerConnSession) (storage.Bucket, error) {
 22+	bucket, ok := ctx.Value(ctxBucketKey{}).(storage.Bucket)
 23+	if !ok {
 24+		return bucket, fmt.Errorf("bucket not set on `ssh.Context()` for connection")
 25+	}
 26+	if bucket.Name == "" {
 27+		return bucket, fmt.Errorf("bucket not set on `ssh.Context()` for connection")
 28+	}
 29+	return bucket, nil
 30+}
 31+func setBucket(ctx *pssh.SSHServerConnSession, bucket storage.Bucket) {
 32+	ctx.SetValue(ctxBucketKey{}, bucket)
 33+}
 34+
 35+type FileData struct {
 36+	*utils.FileEntry
 37+	Text   []byte
 38+	User   string
 39+	Bucket storage.Bucket
 40+}
 41+
 42+type Config struct {
 43+	Logger     *slog.Logger
 44+	Storage    storage.ObjectStorage
 45+	AssetNames AssetNames
 46+}
 47+
 48+type UploadAssetHandler struct {
 49+	Cfg *Config
 50+}
 51+
 52+var _ utils.CopyFromClientHandler = &UploadAssetHandler{}
 53+var _ utils.CopyFromClientHandler = (*UploadAssetHandler)(nil)
 54+
 55+func NewUploadAssetHandler(cfg *Config) *UploadAssetHandler {
 56+	if cfg.AssetNames == nil {
 57+		cfg.AssetNames = &AssetNamesBasic{}
 58+	}
 59+
 60+	return &UploadAssetHandler{
 61+		Cfg: cfg,
 62+	}
 63+}
 64+
 65+func (h *UploadAssetHandler) GetLogger(s *pssh.SSHServerConnSession) *slog.Logger {
 66+	return h.Cfg.Logger
 67+}
 68+
 69+func (h *UploadAssetHandler) Delete(s *pssh.SSHServerConnSession, entry *utils.FileEntry) error {
 70+	h.Cfg.Logger.Info("deleting file", "file", entry.Filepath)
 71+	bucket, err := getBucket(s)
 72+	if err != nil {
 73+		h.Cfg.Logger.Error(err.Error())
 74+		return err
 75+	}
 76+
 77+	objectFileName, err := h.Cfg.AssetNames.ObjectName(s, entry)
 78+	if err != nil {
 79+		return err
 80+	}
 81+	return h.Cfg.Storage.DeleteObject(bucket, objectFileName)
 82+}
 83+
 84+func (h *UploadAssetHandler) Read(s *pssh.SSHServerConnSession, entry *utils.FileEntry) (os.FileInfo, utils.ReadAndReaderAtCloser, error) {
 85+	fileInfo := &utils.VirtualFile{
 86+		FName:    filepath.Base(entry.Filepath),
 87+		FIsDir:   false,
 88+		FSize:    entry.Size,
 89+		FModTime: time.Unix(entry.Mtime, 0),
 90+	}
 91+	h.Cfg.Logger.Info("reading file", "file", fileInfo)
 92+
 93+	bucketName, err := h.Cfg.AssetNames.BucketName(s)
 94+	if err != nil {
 95+		return nil, nil, err
 96+	}
 97+	bucket, err := h.Cfg.Storage.GetBucket(bucketName)
 98+	if err != nil {
 99+		return nil, nil, err
100+	}
101+
102+	fname, err := h.Cfg.AssetNames.ObjectName(s, entry)
103+	if err != nil {
104+		return nil, nil, err
105+	}
106+	contents, info, err := h.Cfg.Storage.GetObject(bucket, fname)
107+	if err != nil {
108+		return nil, nil, err
109+	}
110+
111+	fileInfo.FSize = info.Size
112+	fileInfo.FModTime = info.LastModified
113+
114+	reader := NewAllReaderAt(contents)
115+
116+	return fileInfo, reader, nil
117+}
118+
119+func (h *UploadAssetHandler) List(s *pssh.SSHServerConnSession, fpath string, isDir bool, recursive bool) ([]os.FileInfo, error) {
120+	h.Cfg.Logger.Info(
121+		"listing path",
122+		"dir", fpath,
123+		"isDir", isDir,
124+		"recursive", recursive,
125+	)
126+	var fileList []os.FileInfo
127+
128+	cleanFilename := fpath
129+
130+	bucketName, err := h.Cfg.AssetNames.BucketName(s)
131+	if err != nil {
132+		return fileList, err
133+	}
134+	bucket, err := h.Cfg.Storage.GetBucket(bucketName)
135+	if err != nil {
136+		return fileList, err
137+	}
138+
139+	fname, err := h.Cfg.AssetNames.ObjectName(s, &utils.FileEntry{Filepath: cleanFilename})
140+	if err != nil {
141+		return fileList, err
142+	}
143+
144+	if fname == "" || fname == "." {
145+		name := fname
146+		if name == "" {
147+			name = "/"
148+		}
149+
150+		info := &utils.VirtualFile{
151+			FName:  name,
152+			FIsDir: true,
153+		}
154+
155+		fileList = append(fileList, info)
156+	} else {
157+		name := fname
158+		if name != "/" && isDir {
159+			name += "/"
160+		}
161+
162+		foundList, err := h.Cfg.Storage.ListObjects(bucket, name, recursive)
163+		if err != nil {
164+			return fileList, err
165+		}
166+
167+		fileList = append(fileList, foundList...)
168+	}
169+
170+	return fileList, nil
171+}
172+
173+func (h *UploadAssetHandler) Validate(s *pssh.SSHServerConnSession) error {
174+	var err error
175+	userName := s.User()
176+
177+	assetBucket, err := h.Cfg.AssetNames.BucketName(s)
178+	if err != nil {
179+		return err
180+	}
181+	bucket, err := h.Cfg.Storage.UpsertBucket(assetBucket)
182+	if err != nil {
183+		return err
184+	}
185+	setBucket(s, bucket)
186+
187+	pk, _ := utils.KeyText(s)
188+	h.Cfg.Logger.Info(
189+		"attempting to upload files",
190+		"user", userName,
191+		"bucket", bucket.Name,
192+		"publicKey", pk,
193+	)
194+	return nil
195+}
196+
197+func (h *UploadAssetHandler) Write(s *pssh.SSHServerConnSession, entry *utils.FileEntry) (string, error) {
198+	var origText []byte
199+	if b, err := io.ReadAll(entry.Reader); err == nil {
200+		origText = b
201+	}
202+	fileSize := binary.Size(origText)
203+	// TODO: hack for now until I figure out how to get correct
204+	// filesize from sftp,scp,rsync
205+	entry.Size = int64(fileSize)
206+	userName := s.User()
207+
208+	bucket, err := getBucket(s)
209+	if err != nil {
210+		h.Cfg.Logger.Error(err.Error())
211+		return "", err
212+	}
213+
214+	data := &FileData{
215+		FileEntry: entry,
216+		User:      userName,
217+		Text:      origText,
218+		Bucket:    bucket,
219+	}
220+	err = h.writeAsset(s, data)
221+	if err != nil {
222+		h.Cfg.Logger.Error(err.Error())
223+		return "", err
224+	}
225+
226+	url, err := h.Cfg.AssetNames.PrintObjectName(s, entry, bucket.Name)
227+	if err != nil {
228+		return "", err
229+	}
230+	return url, nil
231+}
232+
233+func (h *UploadAssetHandler) validateAsset(_ *FileData) (bool, error) {
234+	return true, nil
235+}
236+
237+func (h *UploadAssetHandler) writeAsset(s *pssh.SSHServerConnSession, data *FileData) error {
238+	valid, err := h.validateAsset(data)
239+	if !valid {
240+		return err
241+	}
242+
243+	objectFileName, err := h.Cfg.AssetNames.ObjectName(s, data.FileEntry)
244+	if err != nil {
245+		return err
246+	}
247+	reader := bytes.NewReader(data.Text)
248+
249+	h.Cfg.Logger.Info(
250+		"uploading file to bucket",
251+		"user",
252+		data.User,
253+		"bucket",
254+		data.Bucket.Name,
255+		"object",
256+		objectFileName,
257+	)
258+
259+	_, _, err = h.Cfg.Storage.PutObject(
260+		data.Bucket,
261+		objectFileName,
262+		utils.NopReadAndReaderAtCloser(reader),
263+		data.FileEntry,
264+	)
265+	if err != nil {
266+		return err
267+	}
268+
269+	return nil
270+}
A pkg/pobj/main.go
+25, -0
 1@@ -0,0 +1,25 @@
 2+package pobj
 3+
 4+// func createRouter(handler utils.CopyFromClientHandler) proxy.Router {
 5+// 	return func(sh ssh.Handler, s ssh.Session) []wish.Middleware {
 6+// 		return []wish.Middleware{
 7+// 			pipe.Middleware(handler, ""),
 8+// 			list.Middleware(handler),
 9+// 			scp.Middleware(handler),
10+// 			wishrsync.Middleware(handler),
11+// 			auth.Middleware(handler),
12+// 			lm.Middleware(),
13+// 		}
14+// 	}
15+// }
16+
17+// func WithProxy(handler utils.CopyFromClientHandler, otherMiddleware ...wish.Middleware) ssh.Option {
18+// 	return func(server *ssh.Server) error {
19+// 		err := sftp.SSHOption(handler)(server)
20+// 		if err != nil {
21+// 			return err
22+// 		}
23+
24+// 		return proxy.WithProxy(createRouter(handler), otherMiddleware...)(server)
25+// 	}
26+// }
A pkg/pobj/reader.go
+42, -0
 1@@ -0,0 +1,42 @@
 2+package pobj
 3+
 4+import (
 5+	"errors"
 6+	"io"
 7+	"net/http"
 8+
 9+	"github.com/minio/minio-go/v7"
10+	"github.com/picosh/pico/pkg/send/utils"
11+)
12+
13+type AllReaderAt struct {
14+	Reader utils.ReadAndReaderAtCloser
15+}
16+
17+func NewAllReaderAt(reader utils.ReadAndReaderAtCloser) *AllReaderAt {
18+	return &AllReaderAt{reader}
19+}
20+
21+func (a *AllReaderAt) ReadAt(p []byte, off int64) (n int, err error) {
22+	n, err = a.Reader.ReadAt(p, off)
23+
24+	if errors.Is(err, io.EOF) {
25+		return
26+	}
27+
28+	resp := minio.ToErrorResponse(err)
29+
30+	if resp.StatusCode == http.StatusRequestedRangeNotSatisfiable {
31+		err = io.EOF
32+	}
33+
34+	return
35+}
36+
37+func (a *AllReaderAt) Read(p []byte) (int, error) {
38+	return a.Reader.Read(p)
39+}
40+
41+func (a *AllReaderAt) Close() error {
42+	return a.Reader.Close()
43+}
A pkg/pobj/storage/fs.go
+218, -0
  1@@ -0,0 +1,218 @@
  2+package storage
  3+
  4+import (
  5+	"fmt"
  6+	"io"
  7+	"io/fs"
  8+	"log/slog"
  9+	"os"
 10+	"path"
 11+	"path/filepath"
 12+	"strings"
 13+	"time"
 14+
 15+	"github.com/picosh/pico/pkg/send/utils"
 16+)
 17+
 18+// https://stackoverflow.com/a/32482941
 19+func dirSize(path string) (int64, error) {
 20+	var size int64
 21+	err := filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
 22+		if err != nil {
 23+			return err
 24+		}
 25+		if !info.IsDir() {
 26+			size += info.Size()
 27+		}
 28+		return err
 29+	})
 30+
 31+	return size, err
 32+}
 33+
 34+type StorageFS struct {
 35+	Dir    string
 36+	Logger *slog.Logger
 37+}
 38+
 39+var _ ObjectStorage = &StorageFS{}
 40+var _ ObjectStorage = (*StorageFS)(nil)
 41+
 42+func NewStorageFS(logger *slog.Logger, dir string) (*StorageFS, error) {
 43+	return &StorageFS{Logger: logger, Dir: dir}, nil
 44+}
 45+
 46+func (s *StorageFS) GetBucket(name string) (Bucket, error) {
 47+	dirPath := filepath.Join(s.Dir, name)
 48+	bucket := Bucket{
 49+		Name: name,
 50+		Path: dirPath,
 51+	}
 52+	s.Logger.Info("get bucket", "dir", dirPath)
 53+
 54+	info, err := os.Stat(dirPath)
 55+	if os.IsNotExist(err) {
 56+		return bucket, fmt.Errorf("directory does not exist: %v %w", dirPath, err)
 57+	}
 58+
 59+	if err != nil {
 60+		return bucket, fmt.Errorf("directory error: %v %w", dirPath, err)
 61+
 62+	}
 63+
 64+	if !info.IsDir() {
 65+		return bucket, fmt.Errorf("directory is a file, not a directory: %#v", dirPath)
 66+	}
 67+
 68+	return bucket, nil
 69+}
 70+
 71+func (s *StorageFS) UpsertBucket(name string) (Bucket, error) {
 72+	s.Logger.Info("upsert bucket", "name", name)
 73+	bucket, err := s.GetBucket(name)
 74+	if err == nil {
 75+		return bucket, nil
 76+	}
 77+
 78+	dir := filepath.Join(s.Dir, bucket.Path)
 79+	s.Logger.Info("bucket not found, creating", "dir", dir, "err", err)
 80+	err = os.MkdirAll(dir, os.ModePerm)
 81+	if err != nil {
 82+		return bucket, err
 83+	}
 84+
 85+	return bucket, nil
 86+}
 87+
 88+func (s *StorageFS) GetBucketQuota(bucket Bucket) (uint64, error) {
 89+	dsize, err := dirSize(bucket.Path)
 90+	return uint64(dsize), err
 91+}
 92+
 93+func (s *StorageFS) DeleteBucket(bucket Bucket) error {
 94+	return os.RemoveAll(bucket.Path)
 95+}
 96+
 97+func (s *StorageFS) GetObject(bucket Bucket, fpath string) (utils.ReadAndReaderAtCloser, *ObjectInfo, error) {
 98+	objInfo := &ObjectInfo{
 99+		LastModified: time.Time{},
100+		Metadata:     nil,
101+		UserMetadata: map[string]string{},
102+	}
103+
104+	dat, err := os.Open(filepath.Join(bucket.Path, fpath))
105+	if err != nil {
106+		return nil, objInfo, err
107+	}
108+
109+	info, err := dat.Stat()
110+	if err != nil {
111+		return nil, objInfo, err
112+	}
113+
114+	objInfo.Size = info.Size()
115+	objInfo.LastModified = info.ModTime()
116+	return dat, objInfo, nil
117+}
118+
119+func (s *StorageFS) PutObject(bucket Bucket, fpath string, contents io.Reader, entry *utils.FileEntry) (string, int64, error) {
120+	loc := filepath.Join(bucket.Path, fpath)
121+	err := os.MkdirAll(filepath.Dir(loc), os.ModePerm)
122+	if err != nil {
123+		return "", 0, err
124+	}
125+	f, err := os.OpenFile(loc, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
126+	if err != nil {
127+		return "", 0, err
128+	}
129+
130+	size, err := io.Copy(f, contents)
131+	if err != nil {
132+		return "", 0, err
133+	}
134+
135+	f.Close()
136+
137+	if entry.Mtime > 0 {
138+		uTime := time.Unix(entry.Mtime, 0)
139+		_ = os.Chtimes(loc, uTime, uTime)
140+	}
141+
142+	return loc, size, nil
143+}
144+
145+func (s *StorageFS) DeleteObject(bucket Bucket, fpath string) error {
146+	loc := filepath.Join(bucket.Path, fpath)
147+	err := os.Remove(loc)
148+	if err != nil {
149+		return err
150+	}
151+
152+	return nil
153+}
154+
155+func (s *StorageFS) ListBuckets() ([]string, error) {
156+	return []string{}, fmt.Errorf("not implemented")
157+}
158+
159+func (s *StorageFS) ListObjects(bucket Bucket, dir string, recursive bool) ([]os.FileInfo, error) {
160+	var fileList []os.FileInfo
161+
162+	fpath := path.Join(bucket.Path, dir)
163+
164+	info, err := os.Stat(fpath)
165+	if err != nil {
166+		return fileList, err
167+	}
168+
169+	if info.IsDir() && !strings.HasSuffix(dir, "/") {
170+		fileList = append(fileList, &utils.VirtualFile{
171+			FName:    "",
172+			FIsDir:   info.IsDir(),
173+			FSize:    info.Size(),
174+			FModTime: info.ModTime(),
175+		})
176+
177+		return fileList, err
178+	}
179+
180+	var files []fs.DirEntry
181+
182+	if recursive {
183+		err = filepath.WalkDir(fpath, func(s string, d fs.DirEntry, err error) error {
184+			if err != nil {
185+				return err
186+			}
187+			files = append(files, d)
188+			return nil
189+		})
190+		if err != nil {
191+			fileList = append(fileList, info)
192+			return fileList, nil
193+		}
194+	} else {
195+		files, err = os.ReadDir(fpath)
196+		if err != nil {
197+			fileList = append(fileList, info)
198+			return fileList, nil
199+		}
200+	}
201+
202+	for _, f := range files {
203+		info, err := f.Info()
204+		if err != nil {
205+			return fileList, err
206+		}
207+
208+		i := &utils.VirtualFile{
209+			FName:    f.Name(),
210+			FIsDir:   f.IsDir(),
211+			FSize:    info.Size(),
212+			FModTime: info.ModTime(),
213+		}
214+
215+		fileList = append(fileList, i)
216+	}
217+
218+	return fileList, err
219+}
A pkg/pobj/storage/memory.go
+208, -0
  1@@ -0,0 +1,208 @@
  2+package storage
  3+
  4+import (
  5+	"fmt"
  6+	"io"
  7+	"os"
  8+	"path/filepath"
  9+	"strings"
 10+	"sync"
 11+	"time"
 12+
 13+	"github.com/picosh/pico/pkg/send/utils"
 14+)
 15+
 16+type StorageMemory struct {
 17+	storage map[string]map[string]string
 18+	mu      sync.RWMutex
 19+}
 20+
 21+var _ ObjectStorage = &StorageMemory{}
 22+var _ ObjectStorage = (*StorageMemory)(nil)
 23+
 24+func NewStorageMemory(st map[string]map[string]string) (*StorageMemory, error) {
 25+	return &StorageMemory{
 26+		storage: st,
 27+	}, nil
 28+}
 29+
 30+func (s *StorageMemory) GetBucket(name string) (Bucket, error) {
 31+	s.mu.RLock()
 32+	defer s.mu.RUnlock()
 33+
 34+	bucket := Bucket{
 35+		Name: name,
 36+		Path: name,
 37+	}
 38+
 39+	_, ok := s.storage[name]
 40+	if !ok {
 41+		return bucket, fmt.Errorf("bucket does not exist")
 42+	}
 43+
 44+	return bucket, nil
 45+}
 46+
 47+func (s *StorageMemory) UpsertBucket(name string) (Bucket, error) {
 48+	bucket, err := s.GetBucket(name)
 49+	if err == nil {
 50+		return bucket, nil
 51+	}
 52+
 53+	s.mu.Lock()
 54+	defer s.mu.Unlock()
 55+
 56+	s.storage[name] = map[string]string{}
 57+	return bucket, nil
 58+}
 59+
 60+func (s *StorageMemory) GetBucketQuota(bucket Bucket) (uint64, error) {
 61+	s.mu.RLock()
 62+	defer s.mu.RUnlock()
 63+
 64+	objects := s.storage[bucket.Path]
 65+	size := 0
 66+	for _, val := range objects {
 67+		size += len([]byte(val))
 68+	}
 69+	return uint64(size), nil
 70+}
 71+
 72+func (s *StorageMemory) DeleteBucket(bucket Bucket) error {
 73+	s.mu.Lock()
 74+	defer s.mu.Unlock()
 75+
 76+	delete(s.storage, bucket.Path)
 77+	return nil
 78+}
 79+
 80+func (s *StorageMemory) GetObject(bucket Bucket, fpath string) (utils.ReadAndReaderAtCloser, *ObjectInfo, error) {
 81+	s.mu.RLock()
 82+	defer s.mu.RUnlock()
 83+
 84+	if !strings.HasPrefix(fpath, "/") {
 85+		fpath = "/" + fpath
 86+	}
 87+
 88+	objInfo := &ObjectInfo{
 89+		LastModified: time.Time{},
 90+		Metadata:     nil,
 91+		UserMetadata: map[string]string{},
 92+	}
 93+
 94+	dat, ok := s.storage[bucket.Path][fpath]
 95+	if !ok {
 96+		return nil, objInfo, fmt.Errorf("object does not exist: %s", fpath)
 97+	}
 98+
 99+	objInfo.Size = int64(len([]byte(dat)))
100+	reader := utils.NopReadAndReaderAtCloser(strings.NewReader(dat))
101+	return reader, objInfo, nil
102+}
103+
104+func (s *StorageMemory) PutObject(bucket Bucket, fpath string, contents io.Reader, entry *utils.FileEntry) (string, int64, error) {
105+	s.mu.Lock()
106+	defer s.mu.Unlock()
107+
108+	d, err := io.ReadAll(contents)
109+	if err != nil {
110+		return "", 0, err
111+	}
112+
113+	s.storage[bucket.Path][fpath] = string(d)
114+	return fmt.Sprintf("%s%s", bucket.Path, fpath), int64(len(d)), nil
115+}
116+
117+func (s *StorageMemory) DeleteObject(bucket Bucket, fpath string) error {
118+	s.mu.Lock()
119+	defer s.mu.Unlock()
120+
121+	delete(s.storage[bucket.Path], fpath)
122+	return nil
123+}
124+
125+func (s *StorageMemory) ListBuckets() ([]string, error) {
126+	s.mu.RLock()
127+	defer s.mu.RUnlock()
128+
129+	buckets := []string{}
130+	for key := range s.storage {
131+		buckets = append(buckets, key)
132+	}
133+	return buckets, nil
134+}
135+
136+func (s *StorageMemory) ListObjects(bucket Bucket, dir string, recursive bool) ([]os.FileInfo, error) {
137+	s.mu.RLock()
138+	defer s.mu.RUnlock()
139+
140+	var fileList []os.FileInfo
141+
142+	resolved := dir
143+
144+	if !strings.HasPrefix(resolved, "/") {
145+		resolved = "/" + resolved
146+	}
147+
148+	objects := s.storage[bucket.Path]
149+	// dir is actually an object
150+	oval, ok := objects[resolved]
151+	if ok {
152+		fileList = append(fileList, &utils.VirtualFile{
153+			FName:    filepath.Base(resolved),
154+			FIsDir:   false,
155+			FSize:    int64(len([]byte(oval))),
156+			FModTime: time.Time{},
157+		})
158+		return fileList, nil
159+	}
160+
161+	for key, val := range objects {
162+		if !strings.HasPrefix(key, resolved) {
163+			continue
164+		}
165+
166+		rep := strings.Replace(key, resolved, "", 1)
167+		fdir := filepath.Dir(rep)
168+		fname := filepath.Base(rep)
169+		paths := strings.Split(fdir, "/")
170+
171+		if fdir == "/" {
172+			ffname := filepath.Base(resolved)
173+			fileList = append(fileList, &utils.VirtualFile{
174+				FName:  ffname,
175+				FIsDir: true,
176+			})
177+		}
178+
179+		for _, p := range paths {
180+			if p == "" || p == "/" || p == "." {
181+				continue
182+			}
183+			fileList = append(fileList, &utils.VirtualFile{
184+				FName:  p,
185+				FIsDir: true,
186+			})
187+		}
188+
189+		trimRes := strings.TrimSuffix(resolved, "/")
190+		dirKey := filepath.Dir(key)
191+		if recursive {
192+			fileList = append(fileList, &utils.VirtualFile{
193+				FName:    fname,
194+				FIsDir:   false,
195+				FSize:    int64(len([]byte(val))),
196+				FModTime: time.Time{},
197+			})
198+		} else if resolved == dirKey || trimRes == dirKey {
199+			fileList = append(fileList, &utils.VirtualFile{
200+				FName:    fname,
201+				FIsDir:   false,
202+				FSize:    int64(len([]byte(val))),
203+				FModTime: time.Time{},
204+			})
205+		}
206+	}
207+
208+	return fileList, nil
209+}
A pkg/pobj/storage/minio.go
+215, -0
  1@@ -0,0 +1,215 @@
  2+package storage
  3+
  4+import (
  5+	"context"
  6+	"errors"
  7+	"fmt"
  8+	"io"
  9+	"net/url"
 10+	"os"
 11+	"strconv"
 12+	"strings"
 13+	"time"
 14+
 15+	"github.com/minio/madmin-go/v3"
 16+	"github.com/minio/minio-go/v7"
 17+	"github.com/minio/minio-go/v7/pkg/credentials"
 18+	"github.com/picosh/pico/pkg/send/utils"
 19+)
 20+
 21+type StorageMinio struct {
 22+	Client *minio.Client
 23+	Admin  *madmin.AdminClient
 24+}
 25+
 26+var _ ObjectStorage = &StorageMinio{}
 27+var _ ObjectStorage = (*StorageMinio)(nil)
 28+
 29+func NewStorageMinio(address, user, pass string) (*StorageMinio, error) {
 30+	endpoint, err := url.Parse(address)
 31+	if err != nil {
 32+		return nil, err
 33+	}
 34+	ssl := endpoint.Scheme == "https"
 35+
 36+	mClient, err := minio.New(endpoint.Host, &minio.Options{
 37+		Creds:  credentials.NewStaticV4(user, pass, ""),
 38+		Secure: ssl,
 39+	})
 40+	if err != nil {
 41+		return nil, err
 42+	}
 43+
 44+	aClient, err := madmin.New(
 45+		endpoint.Host,
 46+		user,
 47+		pass,
 48+		ssl,
 49+	)
 50+	if err != nil {
 51+		return nil, err
 52+	}
 53+
 54+	mini := &StorageMinio{
 55+		Client: mClient,
 56+		Admin:  aClient,
 57+	}
 58+	return mini, err
 59+}
 60+
 61+func (s *StorageMinio) GetBucket(name string) (Bucket, error) {
 62+	bucket := Bucket{
 63+		Name: name,
 64+	}
 65+
 66+	exists, err := s.Client.BucketExists(context.TODO(), bucket.Name)
 67+	if err != nil || !exists {
 68+		if err == nil {
 69+			err = errors.New("bucket does not exist")
 70+		}
 71+		return bucket, err
 72+	}
 73+
 74+	return bucket, nil
 75+}
 76+
 77+func (s *StorageMinio) UpsertBucket(name string) (Bucket, error) {
 78+	bucket, err := s.GetBucket(name)
 79+	if err == nil {
 80+		return bucket, nil
 81+	}
 82+
 83+	err = s.Client.MakeBucket(context.TODO(), name, minio.MakeBucketOptions{})
 84+	if err != nil {
 85+		return bucket, err
 86+	}
 87+
 88+	return bucket, nil
 89+}
 90+
 91+func (s *StorageMinio) GetBucketQuota(bucket Bucket) (uint64, error) {
 92+	info, err := s.Admin.AccountInfo(context.TODO(), madmin.AccountOpts{})
 93+	if err != nil {
 94+		return 0, nil
 95+	}
 96+	for _, b := range info.Buckets {
 97+		if b.Name == bucket.Name {
 98+			return b.Size, nil
 99+		}
100+	}
101+
102+	return 0, fmt.Errorf("%s bucket not found in account info", bucket.Name)
103+}
104+
105+func (s *StorageMinio) ListBuckets() ([]string, error) {
106+	bcks := []string{}
107+	buckets, err := s.Client.ListBuckets(context.Background())
108+	if err != nil {
109+		return bcks, err
110+	}
111+	for _, bucket := range buckets {
112+		bcks = append(bcks, bucket.Name)
113+	}
114+
115+	return bcks, nil
116+}
117+
118+func (s *StorageMinio) ListObjects(bucket Bucket, dir string, recursive bool) ([]os.FileInfo, error) {
119+	var fileList []os.FileInfo
120+
121+	resolved := strings.TrimPrefix(dir, "/")
122+
123+	opts := minio.ListObjectsOptions{Prefix: resolved, Recursive: recursive, WithMetadata: true}
124+	for obj := range s.Client.ListObjects(context.Background(), bucket.Name, opts) {
125+		if obj.Err != nil {
126+			return fileList, obj.Err
127+		}
128+
129+		isDir := strings.HasSuffix(obj.Key, string(os.PathSeparator))
130+
131+		modTime := obj.LastModified
132+
133+		if mtime, ok := obj.UserMetadata["Mtime"]; ok {
134+			mtimeUnix, err := strconv.Atoi(mtime)
135+			if err == nil {
136+				modTime = time.Unix(int64(mtimeUnix), 0)
137+			}
138+		}
139+
140+		info := &utils.VirtualFile{
141+			FName:    strings.TrimSuffix(strings.TrimPrefix(obj.Key, resolved), "/"),
142+			FIsDir:   isDir,
143+			FSize:    obj.Size,
144+			FModTime: modTime,
145+		}
146+		fileList = append(fileList, info)
147+	}
148+
149+	return fileList, nil
150+}
151+
152+func (s *StorageMinio) DeleteBucket(bucket Bucket) error {
153+	return s.Client.RemoveBucket(context.TODO(), bucket.Name)
154+}
155+
156+func (s *StorageMinio) GetObject(bucket Bucket, fpath string) (utils.ReadAndReaderAtCloser, *ObjectInfo, error) {
157+	objInfo := &ObjectInfo{
158+		Size:         0,
159+		LastModified: time.Time{},
160+		ETag:         "",
161+	}
162+
163+	info, err := s.Client.StatObject(context.Background(), bucket.Name, fpath, minio.StatObjectOptions{})
164+	if err != nil {
165+		return nil, objInfo, err
166+	}
167+
168+	objInfo.LastModified = info.LastModified
169+	objInfo.ETag = info.ETag
170+	objInfo.Metadata = info.Metadata
171+	objInfo.UserMetadata = info.UserMetadata
172+	objInfo.Size = info.Size
173+
174+	obj, err := s.Client.GetObject(context.Background(), bucket.Name, fpath, minio.GetObjectOptions{})
175+	if err != nil {
176+		return nil, objInfo, err
177+	}
178+
179+	if mtime, ok := info.UserMetadata["Mtime"]; ok {
180+		mtimeUnix, err := strconv.Atoi(mtime)
181+		if err == nil {
182+			objInfo.LastModified = time.Unix(int64(mtimeUnix), 0)
183+		}
184+	}
185+
186+	return obj, objInfo, nil
187+}
188+
189+func (s *StorageMinio) PutObject(bucket Bucket, fpath string, contents io.Reader, entry *utils.FileEntry) (string, int64, error) {
190+	opts := minio.PutObjectOptions{
191+		UserMetadata: map[string]string{
192+			"Mtime": fmt.Sprint(time.Now().Unix()),
193+		},
194+	}
195+
196+	if entry.Mtime > 0 {
197+		opts.UserMetadata["Mtime"] = fmt.Sprint(entry.Mtime)
198+	}
199+
200+	var objSize int64 = -1
201+	if entry.Size > 0 {
202+		objSize = entry.Size
203+	}
204+	info, err := s.Client.PutObject(context.TODO(), bucket.Name, fpath, contents, objSize, opts)
205+
206+	if err != nil {
207+		return "", 0, err
208+	}
209+
210+	return fmt.Sprintf("%s/%s", info.Bucket, info.Key), info.Size, nil
211+}
212+
213+func (s *StorageMinio) DeleteObject(bucket Bucket, fpath string) error {
214+	err := s.Client.RemoveObject(context.TODO(), bucket.Name, fpath, minio.RemoveObjectOptions{})
215+	return err
216+}
A pkg/pobj/storage/s3.go
+269, -0
  1@@ -0,0 +1,269 @@
  2+package storage
  3+
  4+import (
  5+	"bytes"
  6+	"context"
  7+	"errors"
  8+	"fmt"
  9+	"io"
 10+	"os"
 11+	"strings"
 12+	"time"
 13+
 14+	"github.com/aws/aws-sdk-go-v2/aws"
 15+	"github.com/aws/aws-sdk-go-v2/config"
 16+	"github.com/aws/aws-sdk-go-v2/credentials"
 17+	"github.com/aws/aws-sdk-go-v2/feature/s3/manager"
 18+	"github.com/aws/aws-sdk-go-v2/service/s3"
 19+	"github.com/aws/aws-sdk-go-v2/service/s3/types"
 20+	"github.com/aws/smithy-go"
 21+	"github.com/picosh/pico/pkg/send/utils"
 22+)
 23+
 24+type StorageS3 struct {
 25+	Client *s3.Client
 26+	Region string
 27+}
 28+
 29+var _ ObjectStorage = &StorageS3{}
 30+var _ ObjectStorage = (*StorageS3)(nil)
 31+
 32+func NewStorageS3(region, key, secret string) (*StorageS3, error) {
 33+	creds := credentials.NewStaticCredentialsProvider(key, secret, "")
 34+	cfg, err := config.LoadDefaultConfig(
 35+		context.TODO(),
 36+		config.WithRegion(region),
 37+		config.WithCredentialsProvider(creds),
 38+	)
 39+	if err != nil {
 40+		return nil, err
 41+	}
 42+
 43+	client := s3.NewFromConfig(cfg)
 44+	return &StorageS3{Client: client}, nil
 45+}
 46+
 47+func (s *StorageS3) GetBucket(name string) (Bucket, error) {
 48+	bucket := Bucket{
 49+		Name: name,
 50+	}
 51+
 52+	_, err := s.Client.HeadBucket(context.TODO(), &s3.HeadBucketInput{
 53+		Bucket: aws.String(name),
 54+	})
 55+	if err != nil {
 56+		var apiError smithy.APIError
 57+		if errors.As(err, &apiError) {
 58+			switch apiError.(type) {
 59+			case *types.NotFound:
 60+				return bucket, fmt.Errorf("bucket not found")
 61+			default:
 62+				return bucket, err
 63+			}
 64+		}
 65+	}
 66+	return bucket, nil
 67+}
 68+
 69+func (s *StorageS3) UpsertBucket(name string) (Bucket, error) {
 70+	bucket, err := s.GetBucket(name)
 71+	if err == nil {
 72+		return bucket, nil
 73+	}
 74+
 75+	_, err = s.Client.CreateBucket(context.TODO(), &s3.CreateBucketInput{
 76+		Bucket: aws.String(name),
 77+		CreateBucketConfiguration: &types.CreateBucketConfiguration{
 78+			LocationConstraint: types.BucketLocationConstraint(s.Region),
 79+		},
 80+	})
 81+
 82+	return bucket, err
 83+}
 84+
 85+func (s *StorageS3) GetBucketQuota(bucket Bucket) (uint64, error) {
 86+	var totalSize uint64
 87+	paginator := s3.NewListObjectsV2Paginator(s.Client, &s3.ListObjectsV2Input{
 88+		Bucket: aws.String(bucket.Name),
 89+	})
 90+
 91+	for paginator.HasMorePages() {
 92+		page, err := paginator.NextPage(context.TODO())
 93+		if err != nil {
 94+			return 0, err
 95+		}
 96+
 97+		for _, object := range page.Contents {
 98+			totalSize += uint64(*object.Size)
 99+		}
100+	}
101+
102+	return totalSize, nil
103+}
104+
105+func (s *StorageS3) ListBuckets() ([]string, error) {
106+	bcks := []string{}
107+	maxBuckets := int32(1000)
108+	result, err := s.Client.ListBuckets(context.TODO(), &s3.ListBucketsInput{MaxBuckets: &maxBuckets})
109+	if err != nil {
110+		return bcks, err
111+	}
112+
113+	for _, bucket := range result.Buckets {
114+		bcks = append(bcks, *bucket.Name)
115+	}
116+
117+	return bcks, nil
118+}
119+
120+func (s *StorageS3) ListObjects(bucket Bucket, dir string, recursive bool) ([]os.FileInfo, error) {
121+	var fileList []os.FileInfo
122+
123+	prefix := strings.TrimPrefix(dir, "/")
124+	input := &s3.ListObjectsV2Input{
125+		Bucket: aws.String(bucket.Name),
126+		Prefix: aws.String(prefix),
127+	}
128+	if !recursive {
129+		input.Delimiter = aws.String("/")
130+	}
131+
132+	paginator := s3.NewListObjectsV2Paginator(s.Client, input)
133+
134+	for paginator.HasMorePages() {
135+		page, err := paginator.NextPage(context.TODO())
136+		if err != nil {
137+			return fileList, err
138+		}
139+
140+		for _, pref := range page.CommonPrefixes {
141+			modTime := time.Time{}
142+			fname := strings.TrimSuffix(strings.TrimPrefix(*pref.Prefix, prefix), "/")
143+			info := &utils.VirtualFile{
144+				FName:    fname,
145+				FIsDir:   true,
146+				FSize:    0,
147+				FModTime: modTime,
148+			}
149+			fileList = append(fileList, info)
150+		}
151+
152+		for _, obj := range page.Contents {
153+			modTime := obj.LastModified
154+			fname := strings.TrimSuffix(strings.TrimPrefix(*obj.Key, prefix), "/")
155+			info := &utils.VirtualFile{
156+				FName:    fname,
157+				FIsDir:   false,
158+				FSize:    *obj.Size,
159+				FModTime: *modTime,
160+			}
161+			fileList = append(fileList, info)
162+		}
163+	}
164+
165+	return fileList, nil
166+}
167+
168+func (s *StorageS3) deleteAllObjects(bucket Bucket) error {
169+	paginator := s3.NewListObjectsV2Paginator(s.Client, &s3.ListObjectsV2Input{
170+		Bucket: aws.String(bucket.Name),
171+	})
172+
173+	for paginator.HasMorePages() {
174+		page, err := paginator.NextPage(context.TODO())
175+		if err != nil {
176+			return err
177+		}
178+
179+		var objectIdentifiers []types.ObjectIdentifier
180+		for _, object := range page.Contents {
181+			objectIdentifiers = append(objectIdentifiers, types.ObjectIdentifier{Key: object.Key})
182+		}
183+
184+		_, err = s.Client.DeleteObjects(context.TODO(), &s3.DeleteObjectsInput{
185+			Bucket: aws.String(bucket.Name),
186+			Delete: &types.Delete{
187+				Objects: objectIdentifiers,
188+				Quiet:   aws.Bool(true),
189+			},
190+		})
191+		if err != nil {
192+			return err
193+		}
194+	}
195+
196+	return nil
197+}
198+
199+func (s *StorageS3) DeleteBucket(bucket Bucket) error {
200+	err := s.deleteAllObjects(bucket)
201+	if err != nil {
202+		return err
203+	}
204+
205+	_, err = s.Client.DeleteBucket(context.TODO(), &s3.DeleteBucketInput{
206+		Bucket: aws.String(bucket.Name),
207+	})
208+	return err
209+}
210+
211+func (s *StorageS3) GetObject(bucket Bucket, fpath string) (utils.ReadAndReaderAtCloser, *ObjectInfo, error) {
212+	input := &s3.GetObjectInput{
213+		Bucket: aws.String(bucket.Name),
214+		Key:    aws.String(fpath),
215+	}
216+
217+	objInfo := &ObjectInfo{
218+		LastModified: time.Time{},
219+		Metadata:     nil,
220+		UserMetadata: map[string]string{},
221+	}
222+
223+	result, err := s.Client.GetObject(context.TODO(), input)
224+	if err != nil {
225+		return nil, objInfo, err
226+	}
227+
228+	objInfo.UserMetadata = result.Metadata
229+	objInfo.ETag = *result.ETag
230+	objInfo.Size = *result.ContentLength
231+	objInfo.LastModified = *result.LastModified
232+
233+	// unfortunately we have to read the object into memory because we
234+	// require io.ReadAt
235+	data, err := io.ReadAll(result.Body)
236+	if err != nil {
237+		return nil, objInfo, err
238+	}
239+	defer result.Body.Close()
240+
241+	// Create a bytes.Reader which implements io.ReaderAt
242+	body := bytes.NewReader(data)
243+	content := utils.NopReadAndReaderAtCloser(body)
244+
245+	return content, objInfo, nil
246+}
247+
248+func (s *StorageS3) PutObject(bucket Bucket, fpath string, contents io.Reader, entry *utils.FileEntry) (string, int64, error) {
249+	key := strings.TrimPrefix(fpath, "/")
250+	uploader := manager.NewUploader(s.Client)
251+	info, err := uploader.Upload(context.TODO(), &s3.PutObjectInput{
252+		Bucket: aws.String(bucket.Name),
253+		Key:    aws.String(key),
254+		Body:   contents,
255+	})
256+	if err != nil {
257+		return "", 0, err
258+	}
259+
260+	return fmt.Sprintf("%s/%s", bucket.Name, *info.Key), entry.Size, nil
261+}
262+
263+func (s *StorageS3) DeleteObject(bucket Bucket, fpath string) error {
264+	key := strings.TrimPrefix(fpath, "/")
265+	_, err := s.Client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
266+		Bucket: aws.String(bucket.Name),
267+		Key:    aws.String(key),
268+	})
269+	return err
270+}
A pkg/pobj/storage/storage.go
+37, -0
 1@@ -0,0 +1,37 @@
 2+package storage
 3+
 4+import (
 5+	"io"
 6+	"net/http"
 7+	"os"
 8+	"time"
 9+
10+	"github.com/picosh/pico/pkg/send/utils"
11+)
12+
13+type Bucket struct {
14+	Name string
15+	Path string
16+	Root string
17+}
18+
19+type ObjectStorage interface {
20+	GetBucket(name string) (Bucket, error)
21+	GetBucketQuota(bucket Bucket) (uint64, error)
22+	UpsertBucket(name string) (Bucket, error)
23+	ListBuckets() ([]string, error)
24+	DeleteBucket(bucket Bucket) error
25+
26+	GetObject(bucket Bucket, fpath string) (utils.ReadAndReaderAtCloser, *ObjectInfo, error)
27+	PutObject(bucket Bucket, fpath string, contents io.Reader, entry *utils.FileEntry) (string, int64, error)
28+	DeleteObject(bucket Bucket, fpath string) error
29+	ListObjects(bucket Bucket, dir string, recursive bool) ([]os.FileInfo, error)
30+}
31+
32+type ObjectInfo struct {
33+	Size         int64
34+	LastModified time.Time
35+	ETag         string
36+	Metadata     http.Header
37+	UserMetadata map[string]string
38+}
A pkg/pobj/util.go
+44, -0
 1@@ -0,0 +1,44 @@
 2+package pobj
 3+
 4+import (
 5+	"log/slog"
 6+	"os"
 7+
 8+	"github.com/picosh/pico/pkg/pobj/storage"
 9+)
10+
11+func GetEnv(key string, defaultVal string) string {
12+	if value, exists := os.LookupEnv(key); exists {
13+		return value
14+	}
15+	return defaultVal
16+}
17+
18+func EnvDriverDetector(logger *slog.Logger) (storage.ObjectStorage, error) {
19+	driver := GetEnv("OBJECT_DRIVER", "fs")
20+	logger.Info("driver detected", "driver", driver)
21+
22+	if driver == "memory" {
23+		return storage.NewStorageMemory(map[string]map[string]string{})
24+	} else if driver == "minio" {
25+		url := GetEnv("MINIO_URL", "")
26+		user := GetEnv("MINIO_ROOT_USER", "")
27+		pass := GetEnv("MINIO_ROOT_PASSWORD", "")
28+		logger.Info(
29+			"object config detected",
30+			"url", url,
31+			"user", user,
32+		)
33+		return storage.NewStorageMinio(url, user, pass)
34+	} else if driver == "s3" {
35+		region := GetEnv("AWS_REGION", "us-east-1")
36+		key := GetEnv("AWS_ACCESS_KEY_ID", "")
37+		secret := GetEnv("AWS_SECRET_ACCESS_KEY", "")
38+		return storage.NewStorageS3(region, key, secret)
39+	}
40+
41+	// implied driver == "fs"
42+	storageDir := GetEnv("OBJECT_URL", "./.storage")
43+	logger.Info("object config detected", "dir", storageDir)
44+	return storage.NewStorageFS(logger, storageDir)
45+}
R pssh/logger.go => pkg/pssh/logger.go
+1, -1
1@@ -4,7 +4,7 @@ import (
2 	"log/slog"
3 	"time"
4 
5-	"github.com/picosh/pico/db"
6+	"github.com/picosh/pico/pkg/db"
7 )
8 
9 type ctxLoggerKey struct{}
R pssh/pty.go => pkg/pssh/pty.go
+0, -0
R pssh/server.go => pkg/pssh/server.go
+0, -0
R pssh/server_test.go => pkg/pssh/server_test.go
+1, -1
1@@ -8,7 +8,7 @@ import (
2 	"testing"
3 	"time"
4 
5-	"github.com/picosh/pico/pssh"
6+	"github.com/picosh/pico/pkg/pssh"
7 	"golang.org/x/crypto/ssh"
8 )
9 
A pkg/send/auth/auth.go
+26, -0
 1@@ -0,0 +1,26 @@
 2+package auth
 3+
 4+import (
 5+	"github.com/picosh/pico/pkg/pssh"
 6+	"github.com/picosh/pico/pkg/send/utils"
 7+)
 8+
 9+func Middleware(writeHandler utils.CopyFromClientHandler) pssh.SSHServerMiddleware {
10+	return func(sshHandler pssh.SSHServerHandler) pssh.SSHServerHandler {
11+		return func(session *pssh.SSHServerConnSession) error {
12+			defer func() {
13+				if r := recover(); r != nil {
14+					writeHandler.GetLogger(session).Error("error running auth middleware", "err", r)
15+				}
16+			}()
17+
18+			err := writeHandler.Validate(session)
19+			if err != nil {
20+				utils.ErrorHandler(session, err)
21+				return err
22+			}
23+
24+			return sshHandler(session)
25+		}
26+	}
27+}
A pkg/send/list/list.go
+44, -0
 1@@ -0,0 +1,44 @@
 2+package list
 3+
 4+import (
 5+	"sort"
 6+	"strings"
 7+
 8+	"github.com/picosh/pico/pkg/pssh"
 9+	"github.com/picosh/pico/pkg/send/utils"
10+)
11+
12+func Middleware(writeHandler utils.CopyFromClientHandler) pssh.SSHServerMiddleware {
13+	return func(sshHandler pssh.SSHServerHandler) pssh.SSHServerHandler {
14+		return func(session *pssh.SSHServerConnSession) error {
15+			cmd := session.Command()
16+			if !(len(cmd) > 1 && cmd[0] == "command" && cmd[1] == "ls") {
17+				return sshHandler(session)
18+			}
19+
20+			fileList, err := writeHandler.List(session, "/", true, false)
21+			if err != nil {
22+				utils.ErrorHandler(session, err)
23+				return err
24+			}
25+
26+			var data []string
27+			for _, file := range fileList {
28+				name := strings.ReplaceAll(file.Name(), "/", "")
29+				if file.IsDir() {
30+					name += "/"
31+				}
32+
33+				data = append(data, name)
34+			}
35+
36+			sort.Strings(data)
37+
38+			_, err = session.Write([]byte(strings.Join(data, "\r\n")))
39+			if err != nil {
40+				utils.ErrorHandler(session, err)
41+			}
42+			return err
43+		}
44+	}
45+}
A pkg/send/pipe/pipe.go
+64, -0
 1@@ -0,0 +1,64 @@
 2+package pipe
 3+
 4+import (
 5+	"fmt"
 6+	"io/fs"
 7+	"strconv"
 8+	"strings"
 9+	"time"
10+
11+	"github.com/picosh/pico/pkg/pssh"
12+	"github.com/picosh/pico/pkg/send/utils"
13+)
14+
15+func Middleware(writeHandler utils.CopyFromClientHandler, ext string) pssh.SSHServerMiddleware {
16+	return func(sshHandler pssh.SSHServerHandler) pssh.SSHServerHandler {
17+		return func(session *pssh.SSHServerConnSession) error {
18+			_, _, activePty := session.Pty()
19+			if activePty {
20+				err := session.Exit(0)
21+				err = session.Close()
22+				return err
23+			}
24+
25+			cmd := session.Command()
26+
27+			name := ""
28+			if len(cmd) > 0 {
29+				name = strings.TrimSpace(cmd[0])
30+				if strings.Contains(name, "=") {
31+					name = ""
32+				}
33+			}
34+
35+			postTime := time.Now()
36+
37+			if name == "" {
38+				name = fmt.Sprintf("%s%s", strconv.Itoa(int(postTime.UnixNano())), ext)
39+			}
40+
41+			result, err := writeHandler.Write(session, &utils.FileEntry{
42+				Filepath: name,
43+				Mode:     fs.FileMode(0777),
44+				Size:     0,
45+				Mtime:    postTime.Unix(),
46+				Atime:    postTime.Unix(),
47+				Reader:   session,
48+			})
49+			if err != nil {
50+				utils.ErrorHandler(session, err)
51+				return err
52+			}
53+
54+			if result != "" {
55+				_, err = session.Write([]byte(fmt.Sprintf("%s\r\n", result)))
56+				if err != nil {
57+					utils.ErrorHandler(session, err)
58+				}
59+				return err
60+			}
61+
62+			return sshHandler(session)
63+		}
64+	}
65+}
A pkg/send/protocols/rsync/rsync.go
+244, -0
  1@@ -0,0 +1,244 @@
  2+package rsync
  3+
  4+import (
  5+	"errors"
  6+	"fmt"
  7+	"io/fs"
  8+	"os"
  9+	"path"
 10+	"slices"
 11+	"strings"
 12+
 13+	"github.com/picosh/go-rsync-receiver/rsyncopts"
 14+	"github.com/picosh/go-rsync-receiver/rsyncreceiver"
 15+	"github.com/picosh/go-rsync-receiver/rsyncsender"
 16+	rsyncutils "github.com/picosh/go-rsync-receiver/utils"
 17+	"github.com/picosh/pico/pkg/pssh"
 18+	"github.com/picosh/pico/pkg/send/utils"
 19+)
 20+
 21+type handler struct {
 22+	session      *pssh.SSHServerConnSession
 23+	writeHandler utils.CopyFromClientHandler
 24+	root         string
 25+	recursive    bool
 26+	ignoreTimes  bool
 27+}
 28+
 29+func (h *handler) List(rPath string) ([]fs.FileInfo, error) {
 30+	isDir := false
 31+	if rPath == "." {
 32+		rPath = "/"
 33+		isDir = true
 34+	}
 35+
 36+	list, err := h.writeHandler.List(h.session, rPath, isDir, h.recursive)
 37+	if err != nil {
 38+		return nil, err
 39+	}
 40+
 41+	var dirs []string
 42+
 43+	var newList []fs.FileInfo
 44+
 45+	for _, f := range list {
 46+		if !f.IsDir() && f.Size() == 0 {
 47+			continue
 48+		}
 49+
 50+		fname := f.Name()
 51+		if strings.HasPrefix(f.Name(), "/") {
 52+			fname = path.Join(rPath, f.Name())
 53+		}
 54+
 55+		if fname == "" && !f.IsDir() {
 56+			fname = path.Base(rPath)
 57+		}
 58+
 59+		newFile := &utils.VirtualFile{
 60+			FName:    fname,
 61+			FIsDir:   f.IsDir(),
 62+			FSize:    f.Size(),
 63+			FModTime: f.ModTime(),
 64+			FSys:     f.Sys(),
 65+		}
 66+
 67+		newList = append(newList, newFile)
 68+
 69+		parts := strings.Split(newFile.Name(), string(os.PathSeparator))
 70+		lastDir := newFile.Name()
 71+		for i := 0; i < len(parts); i++ {
 72+			lastDir, _ = path.Split(lastDir)
 73+			if lastDir == "" {
 74+				continue
 75+			}
 76+
 77+			lastDir = lastDir[:len(lastDir)-1]
 78+			dirs = append(dirs, lastDir)
 79+		}
 80+	}
 81+
 82+	for _, dir := range dirs {
 83+		newList = append(newList, &utils.VirtualFile{
 84+			FName:  dir,
 85+			FIsDir: true,
 86+		})
 87+	}
 88+
 89+	slices.Reverse(newList)
 90+
 91+	onlyEmpty := true
 92+	for _, f := range newList {
 93+		if f.Name() != "" {
 94+			onlyEmpty = false
 95+		}
 96+	}
 97+
 98+	if len(newList) == 0 || onlyEmpty {
 99+		return nil, errors.New("no files to send, the directory may not exist or could be empty")
100+	}
101+
102+	return newList, nil
103+}
104+
105+func (h *handler) Read(file *rsyncutils.SenderFile) (os.FileInfo, rsyncutils.ReaderAtCloser, error) {
106+	filePath := file.WPath
107+
108+	if strings.HasSuffix(h.root, file.WPath) {
109+		filePath = h.root
110+	} else if !strings.HasPrefix(filePath, h.root) {
111+		filePath = path.Join(h.root, file.Path, file.WPath)
112+	}
113+
114+	return h.writeHandler.Read(h.session, &utils.FileEntry{Filepath: filePath})
115+}
116+
117+func (h *handler) Put(file *rsyncutils.ReceiverFile) (int64, error) {
118+	fileEntry := &utils.FileEntry{
119+		Filepath: path.Join("/", h.root, file.Name),
120+		Mode:     fs.FileMode(0600),
121+		Size:     file.Length,
122+		Mtime:    file.ModTime.Unix(),
123+		Atime:    file.ModTime.Unix(),
124+	}
125+	fileEntry.Reader = file.Reader
126+
127+	msg, err := h.writeHandler.Write(h.session, fileEntry)
128+	if err != nil {
129+		errMsg := fmt.Sprintf("%s\r\n", err.Error())
130+		_, err = h.session.Stderr().Write([]byte(errMsg))
131+	}
132+	if msg != "" {
133+		nMsg := fmt.Sprintf("%s\r\n", msg)
134+		_, err = h.session.Stderr().Write([]byte(nMsg))
135+	}
136+	return 0, err
137+}
138+
139+func (h *handler) Remove(willReceive []*rsyncutils.ReceiverFile) error {
140+	entries, err := h.writeHandler.List(h.session, path.Join("/", h.root), true, true)
141+	if err != nil {
142+		return err
143+	}
144+
145+	var toDelete []string
146+
147+	for _, entry := range entries {
148+		exists := slices.ContainsFunc(willReceive, func(rf *rsyncutils.ReceiverFile) bool {
149+			return rf.Name == entry.Name()
150+		})
151+
152+		if !exists && entry.Name() != "._pico_keep_dir" {
153+			toDelete = append(toDelete, entry.Name())
154+		}
155+	}
156+
157+	var errs []error
158+
159+	for _, file := range toDelete {
160+		errs = append(errs, h.writeHandler.Delete(h.session, &utils.FileEntry{Filepath: path.Join("/", h.root, file)}))
161+		_, err = h.session.Stderr().Write([]byte(fmt.Sprintf("deleting %s\r\n", file)))
162+		errs = append(errs, err)
163+	}
164+
165+	return errors.Join(errs...)
166+}
167+
168+func Middleware(writeHandler utils.CopyFromClientHandler) pssh.SSHServerMiddleware {
169+	return func(sshHandler pssh.SSHServerHandler) pssh.SSHServerHandler {
170+		return func(session *pssh.SSHServerConnSession) error {
171+			cmd := session.Command()
172+			if len(cmd) == 0 || cmd[0] != "rsync" {
173+				return sshHandler(session)
174+			}
175+
176+			logger := writeHandler.GetLogger(session).With(
177+				"rsync", true,
178+				"cmd", cmd,
179+			)
180+
181+			defer func() {
182+				if r := recover(); r != nil {
183+					logger.Error("error running rsync middleware", "err", r)
184+					_, _ = session.Stderr().Write([]byte("error running rsync middleware, check the flags you are using\r\n"))
185+				}
186+			}()
187+
188+			cmdFlags := session.Command()
189+
190+			optsCtx, err := rsyncopts.ParseArguments(cmdFlags[1:], true)
191+			if err != nil {
192+				fmt.Fprintf(session.Stderr(), "error parsing rsync arguments: %s\r\n", err.Error())
193+				return err
194+			}
195+
196+			if optsCtx.Options.Compress() {
197+				err := fmt.Errorf("compression is currently unsupported")
198+				fmt.Fprintf(session.Stderr(), "error: %s\r\n", err.Error())
199+				return err
200+			}
201+
202+			if optsCtx.Options.AlwaysChecksum() {
203+				err := fmt.Errorf("checksum is currently unsupported")
204+				fmt.Fprintf(session.Stderr(), "error: %s\r\n", err.Error())
205+				return err
206+			}
207+
208+			if len(optsCtx.RemainingArgs) != 2 {
209+				err := fmt.Errorf("missing source and destination arguments")
210+				fmt.Fprintf(session.Stderr(), "error: %s\r\n", err.Error())
211+				return err
212+			}
213+
214+			root := strings.TrimPrefix(optsCtx.RemainingArgs[len(optsCtx.RemainingArgs)-1], "/")
215+			if root == "" {
216+				root = "/"
217+			}
218+
219+			fileHandler := &handler{
220+				session:      session,
221+				writeHandler: writeHandler,
222+				root:         root,
223+				recursive:    optsCtx.Options.Recurse(),
224+				ignoreTimes:  !optsCtx.Options.PreserveMTimes(),
225+			}
226+
227+			for _, arg := range cmd {
228+				if arg == "--sender" {
229+					err := rsyncsender.ClientRun(logger, optsCtx.Options, session, fileHandler, []string{fileHandler.root}, true)
230+					if err != nil {
231+						logger.Error("error running rsync sender", "err", err)
232+					}
233+					return err
234+				}
235+			}
236+
237+			err = rsyncreceiver.ClientRun(logger, optsCtx.Options, session, fileHandler, []string{fileHandler.root}, true)
238+			if err != nil {
239+				logger.Error("error running rsync receiver", "err", err)
240+			}
241+
242+			return err
243+		}
244+	}
245+}
A pkg/send/protocols/scp/copy_from_client.go
+141, -0
  1@@ -0,0 +1,141 @@
  2+package scp
  3+
  4+import (
  5+	"bufio"
  6+	"errors"
  7+	"fmt"
  8+	"io"
  9+	"io/fs"
 10+	"path/filepath"
 11+	"regexp"
 12+	"strconv"
 13+
 14+	"github.com/picosh/pico/pkg/pssh"
 15+	"github.com/picosh/pico/pkg/send/utils"
 16+)
 17+
 18+var (
 19+	reTimestamp = regexp.MustCompile(`^T(\d{10}) 0 (\d{10}) 0$`)
 20+	reNewFolder = regexp.MustCompile(`^D(\d{4}) 0 (.*)$`)
 21+	reNewFile   = regexp.MustCompile(`^C(\d{4}) (\d+) (.*)$`)
 22+)
 23+
 24+type parseError struct {
 25+	subject string
 26+}
 27+
 28+func (e parseError) Error() string {
 29+	return fmt.Sprintf("failed to parse: %q", e.subject)
 30+}
 31+
 32+func copyFromClient(session *pssh.SSHServerConnSession, info Info, handler utils.CopyFromClientHandler) error {
 33+	// accepts the request
 34+	_, _ = session.Write(utils.NULL)
 35+
 36+	writeErrors := []error{}
 37+	writeSuccess := []string{}
 38+
 39+	var (
 40+		path  = info.Path
 41+		r     = bufio.NewReader(session)
 42+		mtime int64
 43+		atime int64
 44+	)
 45+
 46+	for {
 47+		line, _, err := r.ReadLine()
 48+		if err != nil {
 49+			if errors.Is(err, io.EOF) {
 50+				break
 51+			}
 52+			return fmt.Errorf("failed to read line: %w", err)
 53+		}
 54+
 55+		if matches := reTimestamp.FindAllStringSubmatch(string(line), 2); matches != nil {
 56+			mtime, err = strconv.ParseInt(matches[0][1], 10, 64)
 57+			if err != nil {
 58+				return parseError{string(line)}
 59+			}
 60+			atime, err = strconv.ParseInt(matches[0][2], 10, 64)
 61+			if err != nil {
 62+				return parseError{string(line)}
 63+			}
 64+
 65+			// accepts the header
 66+			_, _ = session.Write(utils.NULL)
 67+			continue
 68+		}
 69+
 70+		if matches := reNewFile.FindAllStringSubmatch(string(line), 3); matches != nil {
 71+			if len(matches) != 1 || len(matches[0]) != 4 {
 72+				return parseError{string(line)}
 73+			}
 74+
 75+			mode, err := strconv.ParseUint(matches[0][1], 8, 32)
 76+			if err != nil {
 77+				return parseError{string(line)}
 78+			}
 79+
 80+			size, err := strconv.ParseInt(matches[0][2], 10, 64)
 81+			if err != nil {
 82+				return parseError{string(line)}
 83+			}
 84+			name := matches[0][3]
 85+
 86+			// accepts the header
 87+			_, _ = session.Write(utils.NULL)
 88+
 89+			result, err := handler.Write(session, &utils.FileEntry{
 90+				Filepath: filepath.Join(path, name),
 91+				Mode:     fs.FileMode(mode),
 92+				Size:     size,
 93+				Mtime:    mtime,
 94+				Atime:    atime,
 95+				Reader:   utils.NewLimitReader(r, int(size)),
 96+			})
 97+
 98+			if err == nil {
 99+				writeSuccess = append(writeSuccess, result)
100+			} else {
101+				writeErrors = append(writeErrors, err)
102+				fmt.Printf("failed to write file: %q: %v\n", name, err)
103+			}
104+
105+			// read the trailing nil char
106+			_, _ = r.ReadByte() // TODO: check if it is indeed a utils.NULL?
107+
108+			mtime = 0
109+			atime = 0
110+			// says 'hey im done'
111+			_, _ = session.Write(utils.NULL)
112+			continue
113+		}
114+
115+		if matches := reNewFolder.FindAllStringSubmatch(string(line), 2); matches != nil {
116+			if len(matches) != 1 || len(matches[0]) != 3 {
117+				return parseError{string(line)}
118+			}
119+
120+			name := matches[0][2]
121+			path = filepath.Join(path, name)
122+			// says 'hey im done'
123+			_, _ = session.Write(utils.NULL)
124+			continue
125+		}
126+
127+		if string(line) == "E" {
128+			path = filepath.Dir(path)
129+
130+			// says 'hey im done'
131+			_, _ = session.Write(utils.NULL)
132+			continue
133+		}
134+
135+		return fmt.Errorf("unhandled input: %q", string(line))
136+	}
137+
138+	utils.PrintMsg(session, writeSuccess, writeErrors)
139+
140+	_, _ = session.Write(utils.NULL)
141+	return nil
142+}
A pkg/send/protocols/scp/copy_to_client.go
+12, -0
 1@@ -0,0 +1,12 @@
 2+package scp
 3+
 4+import (
 5+	"errors"
 6+
 7+	"github.com/picosh/pico/pkg/pssh"
 8+	"github.com/picosh/pico/pkg/send/utils"
 9+)
10+
11+func copyToClient(session *pssh.SSHServerConnSession, info Info, handler utils.CopyFromClientHandler) error {
12+	return errors.New("unsupported, use rsync or sftp")
13+}
A pkg/send/protocols/scp/scp.go
+107, -0
  1@@ -0,0 +1,107 @@
  2+package scp
  3+
  4+import (
  5+	"fmt"
  6+
  7+	"github.com/picosh/pico/pkg/pssh"
  8+	"github.com/picosh/pico/pkg/send/utils"
  9+)
 10+
 11+func Middleware(writeHandler utils.CopyFromClientHandler) pssh.SSHServerMiddleware {
 12+	return func(sshHandler pssh.SSHServerHandler) pssh.SSHServerHandler {
 13+		return func(session *pssh.SSHServerConnSession) error {
 14+			cmd := session.Command()
 15+			if len(cmd) == 0 || cmd[0] != "scp" {
 16+				return sshHandler(session)
 17+			}
 18+
 19+			logger := writeHandler.GetLogger(session).With(
 20+				"scp", true,
 21+				"cmd", cmd,
 22+			)
 23+
 24+			defer func() {
 25+				if r := recover(); r != nil {
 26+					logger.Error("error running scp middleware", "err", r)
 27+					_, _ = session.Stderr().Write([]byte("error running scp middleware, check the flags you are using\r\n"))
 28+				}
 29+			}()
 30+
 31+			info := GetInfo(cmd)
 32+			if !info.Ok {
 33+				return sshHandler(session)
 34+			}
 35+
 36+			var err error
 37+
 38+			switch info.Op {
 39+			case OpCopyToClient:
 40+				if writeHandler == nil {
 41+					err = fmt.Errorf("no handler provided for scp -t")
 42+					break
 43+				}
 44+				err = copyToClient(session, info, writeHandler)
 45+			case OpCopyFromClient:
 46+				if writeHandler == nil {
 47+					err = fmt.Errorf("no handler provided for scp -t")
 48+					break
 49+				}
 50+				err = copyFromClient(session, info, writeHandler)
 51+			}
 52+			if err != nil {
 53+				utils.ErrorHandler(session, err)
 54+			}
 55+
 56+			return err
 57+		}
 58+	}
 59+}
 60+
 61+// Op defines which kind of SCP Operation is going on.
 62+type Op byte
 63+
 64+const (
 65+	// OpCopyToClient is when a file is being copied from the server to the client.
 66+	OpCopyToClient Op = 'f'
 67+
 68+	// OpCopyFromClient is when a file is being copied from the client into the server.
 69+	OpCopyFromClient Op = 't'
 70+)
 71+
 72+// Info provides some information about the current SCP Operation.
 73+type Info struct {
 74+	// Ok is true if the current session is a SCP.
 75+	Ok bool
 76+
 77+	// Recursice is true if its a recursive SCP.
 78+	Recursive bool
 79+
 80+	// Path is the server path of the scp operation.
 81+	Path string
 82+
 83+	// Op is the SCP operation kind.
 84+	Op Op
 85+}
 86+
 87+func GetInfo(cmd []string) Info {
 88+	info := Info{}
 89+	if len(cmd) == 0 || cmd[0] != "scp" {
 90+		return info
 91+	}
 92+
 93+	for i, p := range cmd {
 94+		switch p {
 95+		case "-r":
 96+			info.Recursive = true
 97+		case "-f":
 98+			info.Op = OpCopyToClient
 99+			info.Path = cmd[i+1]
100+		case "-t":
101+			info.Op = OpCopyFromClient
102+			info.Path = cmd[i+1]
103+		}
104+	}
105+
106+	info.Ok = true
107+	return info
108+}
A pkg/send/protocols/send.go
+17, -0
 1@@ -0,0 +1,17 @@
 2+package protocols
 3+
 4+// func Middleware(writeHandler utils.CopyFromClientHandler) ssh.Option {
 5+// 	return func(server *ssh.Server) error {
 6+// 		err := wish.WithMiddleware(
 7+// 			pipe.Middleware(writeHandler, ""),
 8+// 			scp.Middleware(writeHandler),
 9+// 			rsync.Middleware(writeHandler),
10+// 			auth.Middleware(writeHandler),
11+// 		)(server)
12+// 		if err != nil {
13+// 			return err
14+// 		}
15+
16+// 		return sftp.SSHOption(writeHandler)(server)
17+// 	}
18+// }
A pkg/send/protocols/sftp/handler.go
+176, -0
  1@@ -0,0 +1,176 @@
  2+package sftp
  3+
  4+import (
  5+	"bytes"
  6+	"errors"
  7+	"fmt"
  8+	"io"
  9+	"io/fs"
 10+	"os"
 11+	"path/filepath"
 12+
 13+	"slices"
 14+
 15+	"github.com/picosh/pico/pkg/pssh"
 16+	"github.com/picosh/pico/pkg/send/utils"
 17+	"github.com/pkg/sftp"
 18+)
 19+
 20+type listerat []os.FileInfo
 21+
 22+func (f listerat) ListAt(ls []os.FileInfo, offset int64) (int, error) {
 23+	var n int
 24+	if offset >= int64(len(f)) {
 25+		return 0, io.EOF
 26+	}
 27+	n = copy(ls, f[offset:])
 28+	if n < len(ls) {
 29+		return n, io.EOF
 30+	}
 31+	return n, nil
 32+}
 33+
 34+type handler struct {
 35+	session      *pssh.SSHServerConnSession
 36+	writeHandler utils.CopyFromClientHandler
 37+}
 38+
 39+func (f *handler) Filecmd(r *sftp.Request) error {
 40+	switch r.Method {
 41+	case "Rmdir", "Remove":
 42+		entry := toFileEntry(r)
 43+
 44+		if r.Method == "Rmdir" {
 45+			entry.Mode = os.ModeDir
 46+		}
 47+
 48+		return f.writeHandler.Delete(f.session, entry)
 49+	case "Mkdir":
 50+		entry := toFileEntry(r)
 51+
 52+		entry.Mode = os.ModeDir
 53+
 54+		_, err := f.writeHandler.Write(f.session, entry)
 55+
 56+		return err
 57+	case "Setstat":
 58+		return nil
 59+	}
 60+	return errors.New("unsupported")
 61+}
 62+
 63+func (f *handler) Filelist(r *sftp.Request) (sftp.ListerAt, error) {
 64+	switch r.Method {
 65+	case "List", "Stat":
 66+		list := r.Method == "List"
 67+
 68+		listData, err := f.writeHandler.List(f.session, r.Filepath, list, false)
 69+		if err != nil {
 70+			return nil, err
 71+		}
 72+
 73+		// an empty string from minio or exact match from filepath base name is what we want
 74+
 75+		if !list {
 76+			listData = slices.DeleteFunc(listData, func(f os.FileInfo) bool {
 77+				return !(f.Name() == "" || f.Name() == filepath.Base(r.Filepath))
 78+			})
 79+		}
 80+
 81+		if r.Filepath == "/" {
 82+			listData = slices.DeleteFunc(listData, func(f os.FileInfo) bool {
 83+				return f.Name() == "/"
 84+			})
 85+			listData = slices.Insert(listData, 0, os.FileInfo(&utils.VirtualFile{
 86+				FName:  ".",
 87+				FIsDir: true,
 88+			}))
 89+		}
 90+
 91+		return listerat(listData), nil
 92+	}
 93+
 94+	return nil, errors.New("unsupported")
 95+}
 96+
 97+func toFileEntry(r *sftp.Request) *utils.FileEntry {
 98+	attrs := r.Attributes()
 99+	var size int64 = 0
100+	var mtime int64 = 0
101+	var atime int64 = 0
102+	var mode fs.FileMode
103+	if attrs != nil {
104+		mode = attrs.FileMode()
105+		size = int64(attrs.Size)
106+		mtime = int64(attrs.Mtime)
107+		atime = int64(attrs.Atime)
108+	}
109+
110+	entry := &utils.FileEntry{
111+		Filepath: r.Filepath,
112+		Mode:     mode,
113+		Size:     size,
114+		Mtime:    mtime,
115+		Atime:    atime,
116+	}
117+	return entry
118+}
119+
120+func (f *handler) Filewrite(r *sftp.Request) (io.WriterAt, error) {
121+	entry := toFileEntry(r)
122+	entry.Reader = bytes.NewReader([]byte{})
123+
124+	_, err := f.writeHandler.Write(f.session, entry)
125+	if err != nil {
126+		return nil, err
127+	}
128+
129+	buf := &buffer{}
130+	entry.Reader = buf
131+
132+	return fakeWrite{fileEntry: entry, buf: buf, handler: f}, nil
133+}
134+
135+func (f *handler) Fileread(r *sftp.Request) (io.ReaderAt, error) {
136+	if r.Filepath == "/" {
137+		return nil, os.ErrInvalid
138+	}
139+
140+	fileEntry := toFileEntry(r)
141+	_, reader, err := f.writeHandler.Read(f.session, fileEntry)
142+
143+	return reader, err
144+}
145+
146+type handlererr struct {
147+	Handler *handler
148+}
149+
150+func (f *handlererr) Filecmd(r *sftp.Request) error {
151+	err := f.Handler.Filecmd(r)
152+	if err != nil {
153+		fmt.Fprintln(f.Handler.session.Stderr(), err)
154+	}
155+	return err
156+}
157+func (f *handlererr) Filelist(r *sftp.Request) (sftp.ListerAt, error) {
158+	result, err := f.Handler.Filelist(r)
159+	if err != nil {
160+		fmt.Fprintln(f.Handler.session.Stderr(), err)
161+	}
162+	return result, err
163+}
164+func (f *handlererr) Filewrite(r *sftp.Request) (io.WriterAt, error) {
165+	result, err := f.Handler.Filewrite(r)
166+	if err != nil {
167+		fmt.Fprintln(f.Handler.session.Stderr(), err)
168+	}
169+	return result, err
170+}
171+func (f *handlererr) Fileread(r *sftp.Request) (io.ReaderAt, error) {
172+	result, err := f.Handler.Fileread(r)
173+	if err != nil {
174+		fmt.Fprintln(f.Handler.session.Stderr(), err)
175+	}
176+	return result, err
177+}
A pkg/send/protocols/sftp/sftp.go
+69, -0
 1@@ -0,0 +1,69 @@
 2+package sftp
 3+
 4+import (
 5+	"errors"
 6+	"fmt"
 7+	"io"
 8+
 9+	"github.com/picosh/pico/pkg/pssh"
10+	"github.com/picosh/pico/pkg/send/utils"
11+	"github.com/pkg/sftp"
12+)
13+
14+// func SSHOption(writeHandler utils.CopyFromClientHandler) ssh.Option {
15+// 	return func(server *ssh.Server) error {
16+// 		if server.SubsystemHandlers == nil {
17+// 			server.SubsystemHandlers = map[string]ssh.SubsystemHandler{}
18+// 		}
19+
20+// 		server.SubsystemHandlers["sftp"] = SubsystemHandler(writeHandler)
21+// 		return nil
22+// 	}
23+// }
24+
25+func Middleware(writeHandler utils.CopyFromClientHandler) pssh.SSHServerMiddleware {
26+	return func(next pssh.SSHServerHandler) pssh.SSHServerHandler {
27+		return func(session *pssh.SSHServerConnSession) error {
28+			logger := writeHandler.GetLogger(session).With(
29+				"sftp", true,
30+			)
31+
32+			defer func() {
33+				if r := recover(); r != nil {
34+					logger.Error("error running sftp middleware", "err", r)
35+					fmt.Fprintln(session, "error running sftp middleware, check the flags you are using")
36+				}
37+			}()
38+
39+			err := writeHandler.Validate(session)
40+			if err != nil {
41+				fmt.Fprintln(session.Stderr(), err)
42+				return err
43+			}
44+
45+			handler := &handlererr{
46+				Handler: &handler{
47+					session:      session,
48+					writeHandler: writeHandler,
49+				},
50+			}
51+
52+			handlers := sftp.Handlers{
53+				FilePut:  handler,
54+				FileList: handler,
55+				FileGet:  handler,
56+				FileCmd:  handler,
57+			}
58+
59+			requestServer := sftp.NewRequestServer(session, handlers)
60+
61+			err = requestServer.Serve()
62+			if err != nil && !errors.Is(err, io.EOF) {
63+				fmt.Fprintln(session.Stderr(), err)
64+				logger.Error("Error serving sftp subsystem", "err", err)
65+			}
66+
67+			return err
68+		}
69+	}
70+}
A pkg/send/protocols/sftp/writer.go
+75, -0
 1@@ -0,0 +1,75 @@
 2+package sftp
 3+
 4+import (
 5+	"fmt"
 6+	"io"
 7+	"sync"
 8+
 9+	"github.com/picosh/pico/pkg/send/utils"
10+)
11+
12+type buffer struct {
13+	buf []byte
14+	m   sync.Mutex
15+	off int
16+}
17+
18+func (b *buffer) WriteAt(p []byte, pos int64) (n int, err error) {
19+	pLen := len(p)
20+	expLen := pos + int64(pLen)
21+	b.m.Lock()
22+	defer b.m.Unlock()
23+	if int64(len(b.buf)) < expLen {
24+		if int64(cap(b.buf)) < expLen {
25+			newBuf := make([]byte, expLen)
26+			copy(newBuf, b.buf)
27+			b.buf = newBuf
28+		}
29+		b.buf = b.buf[:expLen]
30+	}
31+	copy(b.buf[pos:], p)
32+	return pLen, nil
33+}
34+
35+func (b *buffer) Read(p []byte) (n int, err error) {
36+	b.m.Lock()
37+	defer b.m.Unlock()
38+	if len(b.buf) <= b.off {
39+		if len(p) == 0 {
40+			return 0, nil
41+		}
42+		return 0, io.EOF
43+	}
44+	n = copy(p, b.buf[b.off:])
45+	b.off += n
46+	return n, nil
47+}
48+
49+func (b *buffer) Close() error {
50+	b.buf = []byte{}
51+	return nil
52+}
53+
54+type fakeWrite struct {
55+	fileEntry *utils.FileEntry
56+	handler   *handler
57+	buf       *buffer
58+}
59+
60+func (f fakeWrite) WriteAt(p []byte, off int64) (int, error) {
61+	return f.buf.WriteAt(p, off)
62+}
63+
64+func (f fakeWrite) Close() error {
65+	msg, err := f.handler.writeHandler.Write(f.handler.session, f.fileEntry)
66+	if err != nil {
67+		errMsg := fmt.Sprintf("%s\r\n", err.Error())
68+		_, err = f.handler.session.Stderr().Write([]byte(errMsg))
69+	}
70+	if msg != "" {
71+		nMsg := fmt.Sprintf("%s\r\n", msg)
72+		_, err = f.handler.session.Stderr().Write([]byte(nMsg))
73+	}
74+	f.buf.Close()
75+	return err
76+}
A pkg/send/proxy/middleware.go
+26, -0
 1@@ -0,0 +1,26 @@
 2+package proxy
 3+
 4+// type Router func(sh ssh.Handler, s ssh.Session) []wish.Middleware
 5+
 6+// func withMiddleware(mdw ...wish.Middleware) ssh.Handler {
 7+// 	handler := func(s ssh.Session) {}
 8+// 	for _, mw := range mdw {
 9+// 		handler = mw(handler)
10+// 	}
11+// 	return handler
12+// }
13+
14+// func WithProxy(router Router, otherMiddleware ...wish.Middleware) ssh.Option {
15+// 	mdw := func(sh ssh.Handler) ssh.Handler {
16+// 		return func(s ssh.Session) {
17+// 			mw := router(sh, s)
18+// 			fn := withMiddleware(mw...)
19+// 			fn(s)
20+// 		}
21+// 	}
22+
23+// 	newMiddleware := []wish.Middleware{mdw}
24+// 	newMiddleware = append(newMiddleware, otherMiddleware...)
25+
26+// 	return wish.WithMiddleware(newMiddleware...)
27+// }
A pkg/send/utils/file.go
+31, -0
 1@@ -0,0 +1,31 @@
 2+package utils
 3+
 4+import (
 5+	"os"
 6+	"time"
 7+)
 8+
 9+type VirtualFile struct {
10+	FName    string
11+	FIsDir   bool
12+	FSize    int64
13+	FModTime time.Time
14+	FSys     any
15+}
16+
17+func (f *VirtualFile) Name() string { return f.FName }
18+func (f *VirtualFile) Size() int64  { return f.FSize }
19+func (f *VirtualFile) Mode() os.FileMode {
20+	if f.FIsDir {
21+		return os.FileMode(0755) | os.ModeDir
22+	}
23+	return os.FileMode(0644)
24+}
25+func (f *VirtualFile) ModTime() time.Time {
26+	if f.FModTime.IsZero() {
27+		return time.Now()
28+	}
29+	return f.FModTime
30+}
31+func (f *VirtualFile) IsDir() bool { return f.FIsDir }
32+func (f *VirtualFile) Sys() any    { return f.FSys }
A pkg/send/utils/io.go
+26, -0
 1@@ -0,0 +1,26 @@
 2+package utils
 3+
 4+import (
 5+	"io"
 6+)
 7+
 8+type ReadAndReaderAt interface {
 9+	io.ReaderAt
10+	io.Reader
11+}
12+
13+type ReadAndReaderAtCloser interface {
14+	io.Reader
15+	io.ReaderAt
16+	io.ReadCloser
17+}
18+
19+func NopReadAndReaderAtCloser(r ReadAndReaderAt) ReadAndReaderAtCloser {
20+	return nopReadAndReaderAt{r}
21+}
22+
23+type nopReadAndReaderAt struct {
24+	ReadAndReaderAt
25+}
26+
27+func (nopReadAndReaderAt) Close() error { return nil }
A pkg/send/utils/limit_reader.go
+35, -0
 1@@ -0,0 +1,35 @@
 2+package utils
 3+
 4+import (
 5+	"io"
 6+	"sync"
 7+)
 8+
 9+func NewLimitReader(r io.Reader, limit int) io.Reader {
10+	return &LimitReader{
11+		r:    r,
12+		left: limit,
13+	}
14+}
15+
16+type LimitReader struct {
17+	r io.Reader
18+
19+	lock sync.Mutex
20+	left int
21+}
22+
23+func (r *LimitReader) Read(b []byte) (int, error) {
24+	r.lock.Lock()
25+	defer r.lock.Unlock()
26+
27+	if r.left <= 0 {
28+		return 0, io.EOF
29+	}
30+	if len(b) > r.left {
31+		b = b[0:r.left]
32+	}
33+	n, err := r.r.Read(b)
34+	r.left -= n
35+	return n, err
36+}
A pkg/send/utils/limit_reader_test.go
+44, -0
 1@@ -0,0 +1,44 @@
 2+package utils
 3+
 4+import (
 5+	"bytes"
 6+	"io"
 7+	"testing"
 8+
 9+	"github.com/matryer/is"
10+)
11+
12+func TestLimitedReader(t *testing.T) {
13+	t.Run("partial", func(t *testing.T) {
14+		is := is.New(t)
15+		var b bytes.Buffer
16+		b.WriteString("writing some bytes")
17+		r := NewLimitReader(&b, 7)
18+
19+		bts, err := io.ReadAll(r)
20+		is.NoErr(err)
21+		is.Equal("writing", string(bts))
22+	})
23+
24+	t.Run("full", func(t *testing.T) {
25+		is := is.New(t)
26+		var b bytes.Buffer
27+		b.WriteString("some text")
28+		r := NewLimitReader(&b, b.Len())
29+
30+		bts, err := io.ReadAll(r)
31+		is.NoErr(err)
32+		is.Equal("some text", string(bts))
33+	})
34+
35+	t.Run("pass limit", func(t *testing.T) {
36+		is := is.New(t)
37+		var b bytes.Buffer
38+		b.WriteString("another text")
39+		r := NewLimitReader(&b, b.Len()+10)
40+
41+		bts, err := io.ReadAll(r)
42+		is.NoErr(err)
43+		is.Equal("another text", string(bts))
44+	})
45+}
A pkg/send/utils/utils.go
+101, -0
  1@@ -0,0 +1,101 @@
  2+package utils
  3+
  4+import (
  5+	"encoding/base64"
  6+	"fmt"
  7+	"io"
  8+	"io/fs"
  9+	"log/slog"
 10+	"os"
 11+	"path/filepath"
 12+	"strconv"
 13+
 14+	"github.com/picosh/pico/pkg/pssh"
 15+)
 16+
 17+// NULL is an array with a single NULL byte.
 18+var NULL = []byte{'\x00'}
 19+
 20+// FileEntry is an Entry that reads from a Reader, defining a file and
 21+// its contents.
 22+type FileEntry struct {
 23+	Filepath string
 24+	Mode     fs.FileMode
 25+	Size     int64
 26+	Reader   io.Reader
 27+	Atime    int64
 28+	Mtime    int64
 29+	Metadata map[string]string
 30+}
 31+
 32+// Write a file to the given writer.
 33+func (e *FileEntry) Write(w io.Writer) error {
 34+	if e.Mtime > 0 && e.Atime > 0 {
 35+		if _, err := fmt.Fprintf(w, "T%d 0 %d 0\n", e.Mtime, e.Atime); err != nil {
 36+			return fmt.Errorf("failed to write file: %q: %w", e.Filepath, err)
 37+		}
 38+	}
 39+	fname := filepath.Base(e.Filepath)
 40+	if _, err := fmt.Fprintf(w, "C%s %d %s\n", octalPerms(e.Mode), e.Size, fname); err != nil {
 41+		return fmt.Errorf("failed to write file: %q: %w", e.Filepath, err)
 42+	}
 43+
 44+	if _, err := io.Copy(w, e.Reader); err != nil {
 45+		return fmt.Errorf("failed to read file: %q: %w", e.Filepath, err)
 46+	}
 47+
 48+	if _, err := w.Write(NULL); err != nil {
 49+		return fmt.Errorf("failed to write file: %q: %w", e.Filepath, err)
 50+	}
 51+	return nil
 52+}
 53+
 54+func octalPerms(info fs.FileMode) string {
 55+	return "0" + strconv.FormatUint(uint64(info.Perm()), 8)
 56+}
 57+
 58+// CopyFromClientHandler is a handler that can be implemented to handle files
 59+// being copied from the client to the server.
 60+type CopyFromClientHandler interface {
 61+	// Write should write the given file.
 62+	Delete(*pssh.SSHServerConnSession, *FileEntry) error
 63+	Write(*pssh.SSHServerConnSession, *FileEntry) (string, error)
 64+	Read(*pssh.SSHServerConnSession, *FileEntry) (os.FileInfo, ReadAndReaderAtCloser, error)
 65+	List(*pssh.SSHServerConnSession, string, bool, bool) ([]os.FileInfo, error)
 66+	GetLogger(*pssh.SSHServerConnSession) *slog.Logger
 67+	Validate(*pssh.SSHServerConnSession) error
 68+}
 69+
 70+func KeyText(session *pssh.SSHServerConnSession) (string, error) {
 71+	if session.PublicKey() == nil {
 72+		return "", fmt.Errorf("session doesn't have public key")
 73+	}
 74+	kb := base64.StdEncoding.EncodeToString(session.PublicKey().Marshal())
 75+	return fmt.Sprintf("%s %s", session.PublicKey().Type(), kb), nil
 76+}
 77+
 78+func ErrorHandler(session *pssh.SSHServerConnSession, err error) {
 79+	_, _ = fmt.Fprint(session.Stderr(), err, "\r\n")
 80+	_ = session.Exit(1)
 81+	_ = session.Close()
 82+}
 83+
 84+func PrintMsg(session *pssh.SSHServerConnSession, stdout []string, stderr []error) {
 85+	output := ""
 86+	if len(stdout) > 0 {
 87+		for _, msg := range stdout {
 88+			if msg != "" {
 89+				output += fmt.Sprintf("%s\r\n", msg)
 90+			}
 91+		}
 92+		_, _ = fmt.Fprintln(session.Stderr(), output)
 93+	}
 94+
 95+	outputErr := ""
 96+	if len(stderr) > 0 {
 97+		for _, err := range stderr {
 98+			outputErr += fmt.Sprintf("%v\r\n", err)
 99+		}
100+		_, _ = fmt.Fprintln(session.Stderr(), outputErr)
101+	}
102+}
R shared/analytics.go => pkg/shared/analytics.go
+1, -1
1@@ -15,7 +15,7 @@ import (
2 	"strings"
3 	"time"
4 
5-	"github.com/picosh/pico/db"
6+	"github.com/picosh/pico/pkg/db"
7 	"github.com/picosh/utils/pipe/metrics"
8 	"github.com/simplesurance/go-ip-anonymizer/ipanonymizer"
9 	"github.com/x-way/crawlerdetect"
R shared/api.go => pkg/shared/api.go
+1, -1
1@@ -8,7 +8,7 @@ import (
2 	"os"
3 	"strings"
4 
5-	"github.com/picosh/pico/db"
6+	"github.com/picosh/pico/pkg/db"
7 	"github.com/picosh/utils"
8 	"golang.org/x/crypto/ssh"
9 )
R shared/bucket.go => pkg/shared/bucket.go
+1, -1
1@@ -6,7 +6,7 @@ import (
2 	"path/filepath"
3 	"strings"
4 
5-	"github.com/picosh/send/utils"
6+	"github.com/picosh/pico/pkg/send/utils"
7 )
8 
9 func GetImgsBucketName(userID string) string {
R shared/config.go => pkg/shared/config.go
+1, -1
1@@ -12,7 +12,7 @@ import (
2 	"strings"
3 	"time"
4 
5-	"github.com/picosh/pico/db"
6+	"github.com/picosh/pico/pkg/db"
7 	"github.com/picosh/utils"
8 
9 	pipeLogger "github.com/picosh/utils/pipe/log"
R shared/feed.go => pkg/shared/feed.go
+1, -1
1@@ -5,7 +5,7 @@ import (
2 	"time"
3 
4 	"github.com/gorilla/feeds"
5-	"github.com/picosh/pico/db"
6+	"github.com/picosh/pico/pkg/db"
7 )
8 
9 func UserFeed(me db.DB, userID, token string) (*feeds.Feed, error) {
R shared/listparser.go => pkg/shared/listparser.go
+0, -0
R shared/mdparser.go => pkg/shared/mdparser.go
+0, -0
R shared/pubsub.go => pkg/shared/pubsub.go
+0, -0
R shared/router.go => pkg/shared/router.go
+3, -3
 1@@ -10,9 +10,9 @@ import (
 2 	"regexp"
 3 	"strings"
 4 
 5-	"github.com/picosh/pico/db"
 6-	"github.com/picosh/pico/pssh"
 7-	"github.com/picosh/pico/shared/storage"
 8+	"github.com/picosh/pico/pkg/db"
 9+	"github.com/picosh/pico/pkg/pssh"
10+	"github.com/picosh/pico/pkg/shared/storage"
11 )
12 
13 type Route struct {
R shared/senpai.go => pkg/shared/senpai.go
+1, -1
1@@ -7,7 +7,7 @@ import (
2 
3 	"git.sr.ht/~delthas/senpai"
4 	"github.com/containerd/console"
5-	"github.com/picosh/pico/pssh"
6+	"github.com/picosh/pico/pkg/pssh"
7 )
8 
9 type consoleData struct {
R shared/ssh.go => pkg/shared/ssh.go
+1, -1
1@@ -4,7 +4,7 @@ import (
2 	"fmt"
3 	"log/slog"
4 
5-	"github.com/picosh/pico/db"
6+	"github.com/picosh/pico/pkg/db"
7 	"github.com/picosh/utils"
8 	"golang.org/x/crypto/ssh"
9 )
R shared/storage/fs.go => pkg/shared/storage/fs.go
+1, -1
1@@ -8,7 +8,7 @@ import (
2 	"path/filepath"
3 	"strings"
4 
5-	sst "github.com/picosh/pobj/storage"
6+	sst "github.com/picosh/pico/pkg/pobj/storage"
7 )
8 
9 type StorageFS struct {
R shared/storage/memory.go => pkg/shared/storage/memory.go
+1, -1
1@@ -5,7 +5,7 @@ import (
2 	"net/http"
3 	"time"
4 
5-	sst "github.com/picosh/pobj/storage"
6+	sst "github.com/picosh/pico/pkg/pobj/storage"
7 )
8 
9 type StorageMemory struct {
R shared/storage/minio.go => pkg/shared/storage/minio.go
+1, -1
1@@ -8,7 +8,7 @@ import (
2 	"path/filepath"
3 	"strings"
4 
5-	sst "github.com/picosh/pobj/storage"
6+	sst "github.com/picosh/pico/pkg/pobj/storage"
7 )
8 
9 type StorageMinio struct {
R shared/storage/proxy.go => pkg/shared/storage/proxy.go
+1, -1
1@@ -15,7 +15,7 @@ import (
2 	"strings"
3 	"time"
4 
5-	"github.com/picosh/pobj/storage"
6+	"github.com/picosh/pico/pkg/pobj/storage"
7 )
8 
9 func GetMimeType(fpath string) string {
R shared/storage/proxy_test.go => pkg/shared/storage/proxy_test.go
+0, -0
R shared/storage/ratio.go => pkg/shared/storage/ratio.go
+0, -0
R shared/storage/storage.go => pkg/shared/storage/storage.go
+1, -1
1@@ -3,7 +3,7 @@ package storage
2 import (
3 	"io"
4 
5-	sst "github.com/picosh/pobj/storage"
6+	sst "github.com/picosh/pico/pkg/pobj/storage"
7 )
8 
9 type StorageServe interface {
R shared/tunnel.go => pkg/shared/tunnel.go
+0, -0
R tui/analytics.go => pkg/tui/analytics.go
+1, -1
1@@ -10,7 +10,7 @@ import (
2 	"git.sr.ht/~rockorager/vaxis/vxfw/list"
3 	"git.sr.ht/~rockorager/vaxis/vxfw/richtext"
4 	"git.sr.ht/~rockorager/vaxis/vxfw/text"
5-	"github.com/picosh/pico/db"
6+	"github.com/picosh/pico/pkg/db"
7 	"github.com/picosh/utils"
8 )
9 
R tui/border.go => pkg/tui/border.go
+0, -0
R tui/chat.go => pkg/tui/chat.go
+0, -0
R tui/group.go => pkg/tui/group.go
+0, -0
R tui/info.go => pkg/tui/info.go
+1, -1
1@@ -7,7 +7,7 @@ import (
2 	"git.sr.ht/~rockorager/vaxis"
3 	"git.sr.ht/~rockorager/vaxis/vxfw"
4 	"git.sr.ht/~rockorager/vaxis/vxfw/text"
5-	"github.com/picosh/pico/db"
6+	"github.com/picosh/pico/pkg/db"
7 )
8 
9 type UsageInfo struct {
R tui/input.go => pkg/tui/input.go
+0, -0
R tui/kv.go => pkg/tui/kv.go
+0, -0
R tui/logs.go => pkg/tui/logs.go
+1, -1
1@@ -13,7 +13,7 @@ import (
2 	"git.sr.ht/~rockorager/vaxis/vxfw/list"
3 	"git.sr.ht/~rockorager/vaxis/vxfw/richtext"
4 	"git.sr.ht/~rockorager/vaxis/vxfw/text"
5-	"github.com/picosh/pico/shared"
6+	"github.com/picosh/pico/pkg/shared"
7 	"github.com/picosh/utils"
8 	pipeLogger "github.com/picosh/utils/pipe/log"
9 )
R tui/menu.go => pkg/tui/menu.go
+1, -1
1@@ -5,7 +5,7 @@ import (
2 	"git.sr.ht/~rockorager/vaxis/vxfw"
3 	"git.sr.ht/~rockorager/vaxis/vxfw/list"
4 	"git.sr.ht/~rockorager/vaxis/vxfw/text"
5-	"github.com/picosh/pico/db"
6+	"github.com/picosh/pico/pkg/db"
7 )
8 
9 var menuChoices = []string{
R tui/pager.go => pkg/tui/pager.go
+0, -0
R tui/plus.go => pkg/tui/plus.go
+0, -0
R tui/pubkeys.go => pkg/tui/pubkeys.go
+1, -1
1@@ -10,7 +10,7 @@ import (
2 	"git.sr.ht/~rockorager/vaxis/vxfw/list"
3 	"git.sr.ht/~rockorager/vaxis/vxfw/richtext"
4 	"git.sr.ht/~rockorager/vaxis/vxfw/text"
5-	"github.com/picosh/pico/db"
6+	"github.com/picosh/pico/pkg/db"
7 	"github.com/picosh/utils"
8 	"golang.org/x/crypto/ssh"
9 )
R tui/senpai.go => pkg/tui/senpai.go
+1, -1
1@@ -3,7 +3,7 @@ package tui
2 import (
3 	"io"
4 
5-	"github.com/picosh/pico/shared"
6+	"github.com/picosh/pico/pkg/shared"
7 )
8 
9 type SenpaiCmd struct {
R tui/signup.go => pkg/tui/signup.go
+1, -1
1@@ -8,7 +8,7 @@ import (
2 	"git.sr.ht/~rockorager/vaxis/vxfw/button"
3 	"git.sr.ht/~rockorager/vaxis/vxfw/richtext"
4 	"git.sr.ht/~rockorager/vaxis/vxfw/text"
5-	"github.com/picosh/pico/db"
6+	"github.com/picosh/pico/pkg/db"
7 	"github.com/picosh/utils"
8 	"golang.org/x/crypto/ssh"
9 )
R tui/tokens.go => pkg/tui/tokens.go
+1, -1
1@@ -10,7 +10,7 @@ import (
2 	"git.sr.ht/~rockorager/vaxis/vxfw/list"
3 	"git.sr.ht/~rockorager/vaxis/vxfw/richtext"
4 	"git.sr.ht/~rockorager/vaxis/vxfw/text"
5-	"github.com/picosh/pico/db"
6+	"github.com/picosh/pico/pkg/db"
7 )
8 
9 type TokensPage struct {
R tui/ui.go => pkg/tui/ui.go
+3, -3
 1@@ -9,9 +9,9 @@ import (
 2 	"git.sr.ht/~rockorager/vaxis"
 3 	"git.sr.ht/~rockorager/vaxis/vxfw"
 4 	"git.sr.ht/~rockorager/vaxis/vxfw/richtext"
 5-	"github.com/picosh/pico/db"
 6-	"github.com/picosh/pico/pssh"
 7-	"github.com/picosh/pico/shared"
 8+	"github.com/picosh/pico/pkg/db"
 9+	"github.com/picosh/pico/pkg/pssh"
10+	"github.com/picosh/pico/pkg/shared"
11 	"github.com/picosh/utils"
12 )
13 
A pkg/tunkit/ptun.go
+108, -0
  1@@ -0,0 +1,108 @@
  2+package tunkit
  3+
  4+import (
  5+	"errors"
  6+	"io"
  7+	"log/slog"
  8+	"net"
  9+	"sync"
 10+
 11+	"github.com/picosh/pico/pkg/pssh"
 12+	"golang.org/x/crypto/ssh"
 13+)
 14+
 15+type forwardedTCPPayload struct {
 16+	Addr       string
 17+	Port       uint32
 18+	OriginAddr string
 19+	OriginPort uint32
 20+}
 21+
 22+type Tunnel interface {
 23+	CreateConn(ctx *pssh.SSHServerConnSession) (net.Conn, error)
 24+	GetLogger() *slog.Logger
 25+	Close(ctx *pssh.SSHServerConnSession) error
 26+}
 27+
 28+func LocalForwardHandler(handler Tunnel) pssh.SSHServerChannelMiddleware {
 29+	return func(newChan ssh.NewChannel, sc *pssh.SSHServerConn) error {
 30+		check := &forwardedTCPPayload{}
 31+		err := ssh.Unmarshal(newChan.ExtraData(), check)
 32+		logger := handler.GetLogger()
 33+		if err != nil {
 34+			logger.Error(
 35+				"error unmarshaling information",
 36+				"err", err,
 37+			)
 38+			return err
 39+		}
 40+
 41+		log := logger.With(
 42+			"addr", check.Addr,
 43+			"port", check.Port,
 44+			"origAddr", check.OriginAddr,
 45+			"origPort", check.OriginPort,
 46+		)
 47+		log.Info("local forward request")
 48+
 49+		ch, reqs, err := newChan.Accept()
 50+		if err != nil {
 51+			log.Error("cannot accept new channel", "err", err)
 52+			return err
 53+		}
 54+
 55+		ctx := &pssh.SSHServerConnSession{
 56+			Channel:       ch,
 57+			SSHServerConn: sc,
 58+		}
 59+
 60+		go ssh.DiscardRequests(reqs)
 61+
 62+		go func() {
 63+			downConn, err := handler.CreateConn(ctx)
 64+			if err != nil {
 65+				log.Error("unable to connect to conn", "err", err)
 66+				ch.Close()
 67+				return
 68+			}
 69+			defer downConn.Close()
 70+
 71+			var wg sync.WaitGroup
 72+			wg.Add(2)
 73+
 74+			go func() {
 75+				defer wg.Done()
 76+				defer func() {
 77+					_ = ch.CloseWrite()
 78+				}()
 79+				defer downConn.Close()
 80+				_, err := io.Copy(ch, downConn)
 81+				if err != nil {
 82+					if !errors.Is(err, net.ErrClosed) {
 83+						log.Error("io copy", "err", err)
 84+					}
 85+				}
 86+			}()
 87+			go func() {
 88+				defer wg.Done()
 89+				defer ch.Close()
 90+				defer downConn.Close()
 91+				_, err := io.Copy(downConn, ch)
 92+				if err != nil {
 93+					if !errors.Is(err, net.ErrClosed) {
 94+						log.Error("io copy", "err", err)
 95+					}
 96+				}
 97+			}()
 98+
 99+			wg.Wait()
100+		}()
101+
102+		<-ctx.Done()
103+		err = handler.Close(ctx)
104+		if err != nil {
105+			log.Error("tunnel handler error", "err", err)
106+		}
107+		return err
108+	}
109+}
A pkg/tunkit/web-handler.go
+92, -0
 1@@ -0,0 +1,92 @@
 2+package tunkit
 3+
 4+import (
 5+	"fmt"
 6+	"log/slog"
 7+	"net"
 8+	"os"
 9+
10+	"github.com/picosh/pico/pkg/pssh"
11+)
12+
13+type ctxAddressKey struct{}
14+
15+func getAddressCtx(ctx *pssh.SSHServerConnSession) (string, error) {
16+	address, ok := ctx.Value(ctxAddressKey{}).(string)
17+	if address == "" || !ok {
18+		return address, fmt.Errorf("address not set on `*pssh.SSHServerConnSession()` for connection")
19+	}
20+	return address, nil
21+}
22+func setAddressCtx(ctx *pssh.SSHServerConnSession, address string) {
23+	ctx.SetValue(ctxAddressKey{}, address)
24+}
25+
26+type WebTunnelHandler struct {
27+	HttpHandler HttpHandlerFn
28+	Logger      *slog.Logger
29+}
30+
31+func NewWebTunnelHandler(handler HttpHandlerFn, logger *slog.Logger) *WebTunnelHandler {
32+	return &WebTunnelHandler{
33+		HttpHandler: handler,
34+		Logger:      logger,
35+	}
36+}
37+
38+func (wt *WebTunnelHandler) GetLogger() *slog.Logger {
39+	return wt.Logger
40+}
41+
42+func (wt *WebTunnelHandler) GetHttpHandler() HttpHandlerFn {
43+	return wt.HttpHandler
44+}
45+
46+func (wt *WebTunnelHandler) Close(ctx *pssh.SSHServerConnSession) error {
47+	listener, err := getListenerCtx(ctx)
48+	if err != nil {
49+		return err
50+	}
51+
52+	if listener != nil {
53+		_ = listener.Close()
54+		setListenerCtx(ctx, nil)
55+	}
56+
57+	return nil
58+}
59+
60+func (wt *WebTunnelHandler) CreateListener(ctx *pssh.SSHServerConnSession) (net.Listener, error) {
61+	tempFile, err := os.CreateTemp("", "")
62+	if err != nil {
63+		return nil, err
64+	}
65+
66+	tempFile.Close()
67+	address := tempFile.Name()
68+	os.Remove(address)
69+
70+	connListener, err := net.Listen("unix", address)
71+	if err != nil {
72+		return nil, err
73+	}
74+	setAddressCtx(ctx, address)
75+	setListenerCtx(ctx, connListener)
76+
77+	return connListener, nil
78+}
79+
80+func (wt *WebTunnelHandler) CreateConn(ctx *pssh.SSHServerConnSession) (net.Conn, error) {
81+	_, err := httpServe(wt, ctx, wt.GetLogger())
82+	if err != nil {
83+		wt.GetLogger().Info("unable to create listener", "err", err)
84+		return nil, err
85+	}
86+
87+	address, err := getAddressCtx(ctx)
88+	if err != nil {
89+		return nil, err
90+	}
91+
92+	return net.Dial("unix", address)
93+}
A pkg/tunkit/web.go
+57, -0
 1@@ -0,0 +1,57 @@
 2+package tunkit
 3+
 4+import (
 5+	"fmt"
 6+	"log/slog"
 7+	"net"
 8+	"net/http"
 9+
10+	"github.com/picosh/pico/pkg/pssh"
11+)
12+
13+type HttpHandlerFn = func(ctx *pssh.SSHServerConnSession) http.Handler
14+
15+type WebTunnel interface {
16+	GetHttpHandler() HttpHandlerFn
17+	CreateListener(ctx *pssh.SSHServerConnSession) (net.Listener, error)
18+	CreateConn(ctx *pssh.SSHServerConnSession) (net.Conn, error)
19+	GetLogger() *slog.Logger
20+	Close(ctx *pssh.SSHServerConnSession) error
21+}
22+
23+type ctxListenerKey struct{}
24+
25+func getListenerCtx(ctx *pssh.SSHServerConnSession) (net.Listener, error) {
26+	listener, ok := ctx.Value(ctxListenerKey{}).(net.Listener)
27+	if listener == nil || !ok {
28+		return nil, fmt.Errorf("listener not set on `*pssh.SSHServerConnSession()` for connection")
29+	}
30+	return listener, nil
31+}
32+
33+func setListenerCtx(ctx *pssh.SSHServerConnSession, listener net.Listener) {
34+	ctx.SetValue(ctxListenerKey{}, listener)
35+}
36+
37+func httpServe(handler WebTunnel, ctx *pssh.SSHServerConnSession, log *slog.Logger) (net.Listener, error) {
38+	cached, _ := getListenerCtx(ctx)
39+	if cached != nil {
40+		return cached, nil
41+	}
42+
43+	listener, err := handler.CreateListener(ctx)
44+	if err != nil {
45+		return nil, err
46+	}
47+	setListenerCtx(ctx, listener)
48+
49+	go func() {
50+		httpHandler := handler.GetHttpHandler()
51+		err := http.Serve(listener, httpHandler(ctx))
52+		if err != nil {
53+			log.Error("serving http content", "err", err)
54+		}
55+	}()
56+
57+	return listener, nil
58+}