Quellcode durchsuchen

feat: add S3-compatible filesystem abstraction (s3fs) (#219)

* feat: add S3-compatible filesystem abstraction (s3fs)

Adds a new filesystem type 's3' that can mount any S3-compatible object
storage (AWS S3, MinIO, Wasabi, Backblaze B2, Cloudflare R2, etc.) as a
virtual drive in arozos, following the same patterns as smbfs and ftpfs.

Key design decisions
- RequireBuffer = true: S3 has no file-handle semantics; all I/O goes
  through WriteStream / ReadStream, exactly like ftpfs and webdavfs.
- Large-file / low-memory safety: ReadStream returns an io.ReadCloser
  backed by the live S3 HTTP response body — no data is buffered in RAM
  until the caller calls Read(). WriteStream passes the io.Reader straight
  to minio PutObject with size=-1, triggering the SDK's automatic
  multipart upload which never holds the full file in memory.
- Virtual directories: S3 has no native directories; empty objects whose
  keys end with '/' act as markers. ReadDir uses non-recursive listing
  with the '/' delimiter so common-prefix 'folders' appear as directory
  entries naturally.
- Rename is implemented as copy-then-delete (S3 has no native rename);
  directories are renamed by enumerating and moving every object under
  the prefix.
- Heartbeat calls BucketExists on a 5-second timeout.

New files
  src/mod/filesystem/abstractions/s3fs/s3fs.go         — abstraction impl
  src/mod/filesystem/abstractions/s3fs/s3FileWrapper.go — FileInfo/DirEntry

Modified files
  src/mod/filesystem/arozfs/arozfs.go   — add 's3' to IsNetworkDrive() and
                                          GetSupportedFileSystemTypes()
  src/mod/filesystem/filesystem.go      — parse S3 path/creds and instantiate
                                          S3FSAbstraction in NewFileSystemHandler
  src/go.mod / go.sum                   — add github.com/minio/minio-go/v7

Config path format
  Path     = "[http://]<endpoint>[:<port>]/<bucket>[/<prefix>]"
  Username = Access Key ID
  Password = Secret Access Key

  SSL is on by default; prefix the endpoint with 'http://' to use plain HTTP
  (e.g. local MinIO dev server on port 9000).

https://claude.ai/code/session_01JvZrrTv9E7C7KDAR3FbaWz

* refactor(s3fs): replace MinIO SDK with AWS SDK for Go v2

Swaps github.com/minio/minio-go/v7 for the official
github.com/aws/aws-sdk-go-v2 suite.  Functional behaviour is identical;
only the underlying HTTP client changes.

SDK packages now used (direct dependencies)
  aws-sdk-go-v2              — core types & aws.String / aws.ToString helpers
  aws-sdk-go-v2/config       — LoadDefaultConfig with static credentials
  aws-sdk-go-v2/credentials  — NewStaticCredentialsProvider
  aws-sdk-go-v2/service/s3   — HeadBucket, GetObject, PutObject, HeadObject,
                               DeleteObject, CopyObject, ListObjectsV2 +
                               paginator
  aws-sdk-go-v2/feature/s3/manager — Uploader (automatic multipart for large files)

S3-compatible endpoints (MinIO, Wasabi, R2, …) are handled via
  o.BaseEndpoint = scheme + "://" + endpoint
  o.UsePathStyle  = true
on the s3.Options — the standard AWS SDK v2 pattern for non-AWS backends.

Large-file / low-memory behaviour is unchanged:
  ReadStream  → resp.Body is the raw HTTP response io.ReadCloser
  WriteStream → manager.Uploader auto-switches to multipart above 5 MiB

https://claude.ai/code/session_01JvZrrTv9E7C7KDAR3FbaWz

* feat(ui): add S3 / S3-Compatible option to storage pool editor

Changes to src/web/SystemAO/storage/fshedit.html:

Dropdown
- Added 'S3 / S3-Compatible' item (value='s3') to the Filesystem Type
  selector, next to the other network-drive options.

isNetworkFs()
- Added 's3' so selecting S3 hides the local-disk fields and reveals
  the network auth section, consistent with smb/ftp/sftp/webdav.

handleFileSystemTypeChange()
- When S3 is chosen:
    • Shows the .s3hint info panel with endpoint path examples
      (AWS, MinIO http://, Wasabi, Cloudflare R2)
    • Renames Username → Access Key ID and Password → Secret Access Key
    • Updates placeholders with realistic example values
    • Sets a descriptive path placeholder
- When any other type is chosen the labels/placeholders revert to their
  generic defaults and the hint panel is hidden.

checkPathProtocol()
- Added s3:// prefix detection: strips the scheme and selects S3 type.
- Fixed the http(s):// branch so it does NOT clobber an already-selected
  S3 type (an S3 MinIO endpoint starts with http://).

Dropdown initialisation
- #fstype is now initialised separately with an onChange callback so
  handleFileSystemTypeChange fires when the user picks an option
  (Semantic UI dropdowns do not fire native change events).

https://claude.ai/code/session_01JvZrrTv9E7C7KDAR3FbaWz

* fix(metadata): enable RAW image rendering on buffered file systems (S3, FTP, WebDAV)

Fixes:
  [Media Server] Failed to render RAW image: RAW image rendering not
  supported for buffered file systems

Root cause
  Both RenderRAWImage and generateThumbnailForRAW guarded themselves
  with 'if fsh.RequireBuffer { return error }' — a blanket block that
  prevented RAW support on any streaming-only filesystem (S3, FTP,
  WebDAV).

RenderRAWImage
  This function only calls GetFileSize + ReadFile and returns raw bytes.
  It never opens a file handle.  Both operations are implemented by
  every FileSystemAbstraction, including buffered ones.  The guard was
  simply wrong — removed.

generateThumbnailForRAW
  The actual incompatibility was the use of fshAbs.Create() to write
  the cached thumbnail, since Create() is unsupported on buffered FS.
  Fix: encode the JPEG into a bytes.Buffer first, then persist via
  fshAbs.WriteFile(), which delegates to WriteStream() and therefore
  works on every filesystem type.  The RequireBuffer guard is removed.

No behaviour change for local / SMB / SFTP filesystems.

https://claude.ai/code/session_01JvZrrTv9E7C7KDAR3FbaWz

* fix: address Copilot review findings

go.mod
- Revert Go version back to 1.24.0 + toolchain go1.24.1; the bump to
  1.25 was an unintended side-effect of 'go mod tidy'.

s3fs/s3fs.go
- Region auto-discovery: build an initial client, call
  manager.GetBucketRegion to find the bucket's real AWS region, then
  rebuild the client with that region.  For non-AWS / S3-compatible
  services the header is absent and we silently stay with us-east-1.
- CopySource URL-encoding: added s3CopySource() helper that splits the
  key on '/' and url.PathEscape-encodes each segment individually, so
  keys containing spaces or reserved characters are handled correctly.
- Directory rename: propagate DeleteObject errors instead of discarding
  them with '_, _ = ...' to prevent silent partial-rename inconsistency.
- Glob(): replace ErrOperationNotSupported stub with a working
  implementation that calls ReadDir on the pattern's parent directory
  and filters entries through filepath.Match.

s3fs/s3FileWrapper.go
- S3FileInfo.Mode(): return fs.ModeDir | 0755 for directories so that
  info.Mode().IsDir() returns true for callers that rely on it.

raw.go
- Write thumbnail cache file with mode 0644 (not 0775) to avoid setting
  the executable bit on image files.

fshedit.html
- checkPathProtocol(): do not auto-select 'webdav' when the path starts
  with http(s)://, since that scheme is also used for explicit S3
  endpoints (e.g. http://minio:9000/bucket).  Leave the dropdown as-is
  and let the user pick the correct type.

https://claude.ai/code/session_01JvZrrTv9E7C7KDAR3FbaWz

---------

Co-authored-by: Claude <noreply@anthropic.com>
Alan Yeung vor 3 Wochen
Ursprung
Commit
1d59cb6d04

+ 27 - 8
src/go.mod

@@ -5,6 +5,11 @@ go 1.24.0
 toolchain go1.24.1
 
 require (
+	github.com/aws/aws-sdk-go-v2 v1.41.7
+	github.com/aws/aws-sdk-go-v2/config v1.32.18
+	github.com/aws/aws-sdk-go-v2/credentials v1.19.17
+	github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.19
+	github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0
 	github.com/boltdb/bolt v1.3.1
 	github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
 	github.com/disintegration/imaging v1.6.2
@@ -35,10 +40,10 @@ require (
 	github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
 	github.com/studio-b12/gowebdav v0.11.0
 	gitlab.com/NebulousLabs/go-upnp v0.0.0-20211002182029-11da932010b6
-	golang.org/x/crypto v0.44.0
+	golang.org/x/crypto v0.46.0
 	golang.org/x/image v0.33.0
 	golang.org/x/oauth2 v0.32.0
-	golang.org/x/sync v0.18.0
+	golang.org/x/sync v0.19.0
 )
 
 require (
@@ -47,6 +52,20 @@ require (
 	github.com/Microsoft/go-winio v0.6.2 // indirect
 	github.com/ProtonMail/go-crypto v1.3.0 // indirect
 	github.com/andybalholm/brotli v1.2.0 // indirect
+	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
+	github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect
+	github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect
+	github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
+	github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0 // indirect
+	github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect
+	github.com/aws/smithy-go v1.25.1 // indirect
 	github.com/cenkalti/backoff v2.2.1+incompatible // indirect
 	github.com/cloudflare/circl v1.6.1 // indirect
 	github.com/cyphar/filepath-securejoin v0.5.0 // indirect
@@ -64,7 +83,7 @@ require (
 	github.com/hashicorp/go-multierror v1.1.1 // indirect
 	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
 	github.com/kevinburke/ssh_config v1.4.0 // indirect
-	github.com/klauspost/compress v1.18.0 // indirect
+	github.com/klauspost/compress v1.18.2 // indirect
 	github.com/klauspost/cpuid/v2 v2.3.0 // indirect
 	github.com/klauspost/pgzip v1.2.6 // indirect
 	github.com/kr/fs v0.1.0 // indirect
@@ -78,11 +97,11 @@ require (
 	github.com/xanzy/ssh-agent v0.3.3 // indirect
 	github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
 	gitlab.com/NebulousLabs/fastrand v0.0.0-20181126182046-603482d69e40 // indirect
-	golang.org/x/mod v0.29.0 // indirect
-	golang.org/x/net v0.47.0 // indirect
-	golang.org/x/sys v0.38.0 // indirect
-	golang.org/x/text v0.31.0 // indirect
-	golang.org/x/tools v0.38.0 // indirect
+	golang.org/x/mod v0.30.0 // indirect
+	golang.org/x/net v0.48.0 // indirect
+	golang.org/x/sys v0.39.0 // indirect
+	golang.org/x/text v0.32.0 // indirect
+	golang.org/x/tools v0.39.0 // indirect
 	gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d // indirect
 	gopkg.in/sourcemap.v1 v1.0.5 // indirect
 	gopkg.in/warnings.v0 v0.1.2 // indirect

+ 56 - 18
src/go.sum

@@ -14,6 +14,44 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI
 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
+github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
+github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY=
+github.com/aws/aws-sdk-go-v2/config v1.32.18 h1:Hcia46bxhGgF3BaSnG8nSNCWmqTK6bj9xN9/FJ3WK6Q=
+github.com/aws/aws-sdk-go-v2/config v1.32.18/go.mod h1:zEjCAYmxqDadH1WX8CdBvmLKhUEUVFgKRQG38zjDmrY=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.17 h1:gP2nkGsS+KMvF/jfFz2Vv2qiiOqWKyPACSzPsqHgoW8=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.17/go.mod h1:Bsew3S/moG5iT77giPj1q8wb/s0RE5/QfH+ASjYtuQc=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg=
+github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.19 h1:VH0xfFwHfPYhu+EcxyCcw3VTZskpbA+/s0pTXwhSsL8=
+github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.19/go.mod h1:S/XkAXcnCpzwsjC9EU0BakuvreXfSTUADHb7rC7jvaQ=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lAsTJFeXWFDsDcWOU=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4=
+github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc=
+github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI=
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE=
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0 h1:nDARhv/oF55bcxF7rCI/4PDxOKnVXVWwDuDwCs2I2SQ=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg=
+github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk=
+github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
+github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
+github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
 github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4=
 github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps=
 github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
@@ -103,8 +141,8 @@ github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PW
 github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
 github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
 github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
-github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
-github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
+github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
+github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
 github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
 github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
 github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
@@ -207,8 +245,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
 golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
 golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
 golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
-golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
-golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
+golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
+golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
 golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
@@ -220,8 +258,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
 golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
-golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
-golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
+golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
+golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -236,8 +274,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
 golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
 golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
 golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
-golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
-golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
+golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
+golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
 golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
 golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -247,8 +285,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
 golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
-golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -267,8 +305,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
-golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
+golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
 golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -278,8 +316,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
 golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
 golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
 golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
-golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
-golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
+golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
+golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -290,8 +328,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
-golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
-golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
+golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
+golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
@@ -299,8 +337,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
 golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
-golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
-golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
+golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
+golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

+ 70 - 0
src/mod/filesystem/abstractions/s3fs/s3FileWrapper.go

@@ -0,0 +1,70 @@
+package s3fs
+
+import (
+	"io/fs"
+	"time"
+)
+
+/*
+	s3FileWrapper.go
+
+	S3 FileInfo and DirEntry wrappers for the arozos filesystem abstraction layer.
+*/
+
+// S3FileInfo implements os.FileInfo for S3 objects.
+type S3FileInfo struct {
+	name    string
+	size    int64
+	isDir   bool
+	modTime time.Time
+}
+
+func NewS3FileInfo(name string, size int64, isDir bool, modTime time.Time) *S3FileInfo {
+	return &S3FileInfo{
+		name:    name,
+		size:    size,
+		isDir:   isDir,
+		modTime: modTime,
+	}
+}
+
+func (fi *S3FileInfo) Name() string { return fi.name }
+func (fi *S3FileInfo) Size() int64  { return fi.size }
+func (fi *S3FileInfo) Mode() fs.FileMode {
+	if fi.isDir {
+		return fs.ModeDir | 0755
+	}
+	return 0664
+}
+func (fi *S3FileInfo) ModTime() time.Time { return fi.modTime }
+func (fi *S3FileInfo) IsDir() bool        { return fi.isDir }
+func (fi *S3FileInfo) Sys() interface{}   { return nil }
+
+// S3DirEntry implements fs.DirEntry for S3 objects.
+type S3DirEntry struct {
+	name    string
+	size    int64
+	isDir   bool
+	modTime time.Time
+}
+
+func NewS3DirEntry(name string, size int64, isDir bool, modTime time.Time) *S3DirEntry {
+	return &S3DirEntry{
+		name:    name,
+		size:    size,
+		isDir:   isDir,
+		modTime: modTime,
+	}
+}
+
+func (de *S3DirEntry) Name() string { return de.name }
+func (de *S3DirEntry) IsDir() bool  { return de.isDir }
+func (de *S3DirEntry) Type() fs.FileMode {
+	if de.isDir {
+		return fs.ModeDir
+	}
+	return 0
+}
+func (de *S3DirEntry) Info() (fs.FileInfo, error) {
+	return NewS3FileInfo(de.name, de.size, de.isDir, de.modTime), nil
+}

+ 682 - 0
src/mod/filesystem/abstractions/s3fs/s3fs.go

@@ -0,0 +1,682 @@
+package s3fs
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"io"
+	"io/fs"
+	"log"
+	"net/url"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"github.com/aws/aws-sdk-go-v2/aws"
+	awsconfig "github.com/aws/aws-sdk-go-v2/config"
+	"github.com/aws/aws-sdk-go-v2/credentials"
+	"github.com/aws/aws-sdk-go-v2/feature/s3/manager"
+	"github.com/aws/aws-sdk-go-v2/service/s3"
+	"github.com/aws/aws-sdk-go-v2/service/s3/types"
+	"imuslab.com/arozos/mod/filesystem/arozfs"
+)
+
+/*
+	s3fs.go
+
+	S3-Compatible Object Storage as File System Abstraction.
+
+	Supports AWS S3, MinIO, Wasabi, Backblaze B2, Cloudflare R2, and any other
+	S3-compatible storage provider.
+
+	Uses the official AWS SDK for Go v2 (aws-sdk-go-v2).  Non-AWS endpoints are
+	handled via BaseEndpoint + UsePathStyle on the S3 client options, which is the
+	standard pattern for S3-compatible services.
+
+	Large-file / low-memory design
+	  ReadStream  → s3.GetObject returns resp.Body, an io.ReadCloser that streams
+	                directly from S3.  No data is buffered in RAM until the caller
+	                calls Read().
+	  WriteStream → manager.Uploader wraps PutObject and automatically switches to
+	                multipart upload for payloads above the configurable threshold
+	                (default 5 MiB per part).  The entire file is never held in
+	                memory, making this safe on RAM-constrained devices.
+
+	RequireBuffer = true  (no file-handle semantics; Open/Create are unsupported)
+
+	Path format in the storage config
+	  Path     = "[http://]<endpoint>[:<port>]/<bucket>[/<prefix>]"
+	  Username = Access Key ID
+	  Password = Secret Access Key
+
+	Examples
+	  AWS S3           "s3.amazonaws.com/my-bucket"
+	  AWS (us-west-2)  "s3.us-west-2.amazonaws.com/my-bucket"
+	  MinIO (HTTP)     "http://192.168.1.100:9000/my-bucket/optional-prefix"
+	  Wasabi           "s3.wasabisys.com/my-bucket"
+	  Cloudflare R2    "<account-id>.r2.cloudflarestorage.com/my-bucket"
+
+	SSL is on by default.  Prepend "http://" to the endpoint to disable it
+	(useful for local MinIO / development setups).
+*/
+
+// S3FSAbstraction implements filesystem.FileSystemAbstraction backed by S3.
+type S3FSAbstraction struct {
+	uuid      string
+	hierarchy string
+	endpoint  string // bare host[:port], no scheme
+	bucket    string
+	prefix    string // optional root prefix inside the bucket (no leading/trailing slash)
+	accessKey string
+	secretKey string
+	useSSL    bool
+	client    *s3.Client
+	uploader  *manager.Uploader
+}
+
+// NewS3FSAbstraction creates and validates a new S3 filesystem abstraction.
+// The caller should NOT include a scheme in endpoint; pass useSSL to control
+// http vs https.
+func NewS3FSAbstraction(uuid, hierarchy, endpoint, bucket, prefix, accessKey, secretKey string, useSSL bool) (*S3FSAbstraction, error) {
+	scheme := "https"
+	if !useSSL {
+		scheme = "http"
+	}
+
+	credProvider := credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")
+
+	// newClient is a helper to build an s3.Client for a given signing region.
+	newClient := func(region string) (*s3.Client, error) {
+		cfg, err := awsconfig.LoadDefaultConfig(context.Background(),
+			awsconfig.WithRegion(region),
+			awsconfig.WithCredentialsProvider(credProvider),
+		)
+		if err != nil {
+			return nil, err
+		}
+		return s3.NewFromConfig(cfg, func(o *s3.Options) {
+			// For custom endpoints (MinIO, Wasabi, R2, …) point the client there.
+			// Leaving BaseEndpoint empty uses the default AWS resolver.
+			if endpoint != "" {
+				o.BaseEndpoint = aws.String(scheme + "://" + endpoint)
+			}
+			// Path-style addressing is required by MinIO and most S3-compatible
+			// services; virtual-hosted style is fine for AWS S3 regardless.
+			o.UsePathStyle = true
+		}), nil
+	}
+
+	// Bootstrap with the default signing region.
+	client, err := newClient("us-east-1")
+	if err != nil {
+		return nil, err
+	}
+
+	// Auto-discover the bucket's actual AWS region.
+	// manager.GetBucketRegion queries HeadBucket and reads the
+	// x-amz-bucket-region response header.  For non-AWS / S3-compatible
+	// services the header is absent; in that case we stay with us-east-1.
+	discoverCtx, discoverCancel := context.WithTimeout(context.Background(), 8*time.Second)
+	defer discoverCancel()
+
+	if bucketRegion, rerr := manager.GetBucketRegion(discoverCtx, client, bucket); rerr == nil && bucketRegion != "" && bucketRegion != "us-east-1" {
+		if rc, rerr2 := newClient(bucketRegion); rerr2 == nil {
+			client = rc
+			log.Printf("[S3 FS] Auto-discovered region %q for bucket %q\n", bucketRegion, bucket)
+		}
+	}
+
+	// Validate the bucket is reachable with the final client.
+	valCtx, valCancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer valCancel()
+
+	_, err = client.HeadBucket(valCtx, &s3.HeadBucketInput{
+		Bucket: aws.String(bucket),
+	})
+	if err != nil {
+		var nf *types.NotFound
+		if errors.As(err, &nf) {
+			return nil, os.ErrNotExist
+		}
+		return nil, err
+	}
+
+	uploader := manager.NewUploader(client)
+
+	prefix = strings.Trim(prefix, "/")
+	log.Printf("[S3 FS] Mounted s3://%s/%s (endpoint=%s ssl=%v)\n", bucket, prefix, endpoint, useSSL)
+
+	return &S3FSAbstraction{
+		uuid:      uuid,
+		hierarchy: hierarchy,
+		endpoint:  endpoint,
+		bucket:    bucket,
+		prefix:    prefix,
+		accessKey: accessKey,
+		secretKey: secretKey,
+		useSSL:    useSSL,
+		client:    client,
+		uploader:  uploader,
+	}, nil
+}
+
+// ---------------------------------------------------------------------------
+// Internal path helpers
+// ---------------------------------------------------------------------------
+
+// realPathToKey converts a filesystem real-path (e.g. "/videos/movie.mp4")
+// to an S3 object key (e.g. "myprefix/videos/movie.mp4").
+func (s *S3FSAbstraction) realPathToKey(realPath string) string {
+	realPath = strings.TrimPrefix(filterFilepath(realPath), "/")
+	if realPath == "" || realPath == "." {
+		return s.prefix
+	}
+	if s.prefix != "" {
+		return s.prefix + "/" + realPath
+	}
+	return realPath
+}
+
+// keyToRealPath is the inverse of realPathToKey.
+func (s *S3FSAbstraction) keyToRealPath(key string) string {
+	key = strings.TrimSuffix(key, "/")
+	if s.prefix != "" {
+		key = strings.TrimPrefix(key, s.prefix+"/")
+	}
+	if key == "" {
+		return "/"
+	}
+	return "/" + key
+}
+
+// ---------------------------------------------------------------------------
+// Fundamental Functions (FileSystemAbstraction interface)
+// ---------------------------------------------------------------------------
+
+func (s *S3FSAbstraction) Chmod(_ string, _ os.FileMode) error {
+	return arozfs.ErrOperationNotSupported
+}
+func (s *S3FSAbstraction) Chown(_ string, _, _ int) error {
+	return arozfs.ErrOperationNotSupported
+}
+func (s *S3FSAbstraction) Chtimes(_ string, _, _ time.Time) error {
+	return arozfs.ErrOperationNotSupported
+}
+
+// Create is not supported; use WriteStream instead.
+func (s *S3FSAbstraction) Create(_ string) (arozfs.File, error) {
+	return nil, arozfs.ErrOperationNotSupported
+}
+
+// Mkdir creates a zero-byte S3 "directory marker" object whose key ends with "/".
+func (s *S3FSAbstraction) Mkdir(filename string, _ os.FileMode) error {
+	key := s.realPathToKey(filename)
+	if !strings.HasSuffix(key, "/") {
+		key += "/"
+	}
+	ctx := context.Background()
+	_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
+		Bucket:      aws.String(s.bucket),
+		Key:         aws.String(key),
+		Body:        bytes.NewReader([]byte{}),
+		ContentType: aws.String("application/x-directory"),
+	})
+	return err
+}
+
+func (s *S3FSAbstraction) MkdirAll(filename string, mode os.FileMode) error {
+	return s.Mkdir(filename, mode)
+}
+
+func (s *S3FSAbstraction) Name() string { return s.bucket }
+
+// Open is not supported; use ReadStream instead.
+func (s *S3FSAbstraction) Open(_ string) (arozfs.File, error) {
+	return nil, arozfs.ErrOperationNotSupported
+}
+
+// OpenFile is not supported; use ReadStream / WriteStream instead.
+func (s *S3FSAbstraction) OpenFile(_ string, _ int, _ os.FileMode) (arozfs.File, error) {
+	return nil, arozfs.ErrOperationNotSupported
+}
+
+// Remove deletes a single object or an entire virtual directory (recursively).
+func (s *S3FSAbstraction) Remove(filename string) error {
+	if s.IsDir(filename) {
+		return s.RemoveAll(filename)
+	}
+	key := s.realPathToKey(filename)
+	ctx := context.Background()
+	_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
+		Bucket: aws.String(s.bucket),
+		Key:    aws.String(key),
+	})
+	return err
+}
+
+// RemoveAll deletes every object whose key starts with the given path prefix.
+func (s *S3FSAbstraction) RemoveAll(path string) error {
+	prefix := s.realPathToKey(path)
+	if prefix != "" && !strings.HasSuffix(prefix, "/") {
+		prefix += "/"
+	}
+	ctx := context.Background()
+
+	paginator := s3.NewListObjectsV2Paginator(s.client, &s3.ListObjectsV2Input{
+		Bucket:    aws.String(s.bucket),
+		Prefix:    aws.String(prefix),
+		Delimiter: aws.String(""),
+	})
+	for paginator.HasMorePages() {
+		page, err := paginator.NextPage(ctx)
+		if err != nil {
+			return err
+		}
+		for _, obj := range page.Contents {
+			_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
+				Bucket: aws.String(s.bucket),
+				Key:    obj.Key,
+			})
+			if err != nil {
+				return err
+			}
+		}
+	}
+
+	// Also remove any explicit directory marker at this path.
+	_, _ = s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
+		Bucket: aws.String(s.bucket),
+		Key:    aws.String(prefix),
+	})
+	return nil
+}
+
+// Rename copies all objects from oldname to newname then removes the originals.
+// S3 has no native rename; this is a copy-then-delete sequence.
+func (s *S3FSAbstraction) Rename(oldname, newname string) error {
+	ctx := context.Background()
+
+	if s.IsDir(oldname) {
+		oldPrefix := s.realPathToKey(oldname)
+		newPrefix := s.realPathToKey(newname)
+		if !strings.HasSuffix(oldPrefix, "/") {
+			oldPrefix += "/"
+		}
+		if !strings.HasSuffix(newPrefix, "/") {
+			newPrefix += "/"
+		}
+
+		paginator := s3.NewListObjectsV2Paginator(s.client, &s3.ListObjectsV2Input{
+			Bucket: aws.String(s.bucket),
+			Prefix: aws.String(oldPrefix),
+		})
+		for paginator.HasMorePages() {
+			page, err := paginator.NextPage(ctx)
+			if err != nil {
+				return err
+			}
+			for _, obj := range page.Contents {
+				srcKey := aws.ToString(obj.Key)
+				newKey := newPrefix + strings.TrimPrefix(srcKey, oldPrefix)
+				_, err := s.client.CopyObject(ctx, &s3.CopyObjectInput{
+					Bucket:     aws.String(s.bucket),
+					CopySource: aws.String(s3CopySource(s.bucket, srcKey)),
+					Key:        aws.String(newKey),
+				})
+				if err != nil {
+					return err
+				}
+				if _, err = s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
+					Bucket: aws.String(s.bucket),
+					Key:    obj.Key,
+				}); err != nil {
+					return err
+				}
+			}
+		}
+		return nil
+	}
+
+	oldKey := s.realPathToKey(oldname)
+	newKey := s.realPathToKey(newname)
+	_, err := s.client.CopyObject(ctx, &s3.CopyObjectInput{
+		Bucket:     aws.String(s.bucket),
+		CopySource: aws.String(s3CopySource(s.bucket, oldKey)),
+		Key:        aws.String(newKey),
+	})
+	if err != nil {
+		return err
+	}
+	_, err = s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
+		Bucket: aws.String(s.bucket),
+		Key:    aws.String(oldKey),
+	})
+	return err
+}
+
+// Stat returns object metadata.  Virtual directories are synthesised when any
+// objects exist under the path as a prefix.
+func (s *S3FSAbstraction) Stat(filename string) (os.FileInfo, error) {
+	if s.IsDir(filename) {
+		return NewS3FileInfo(arozfs.Base(filename), 0, true, time.Now()), nil
+	}
+	key := s.realPathToKey(filename)
+	ctx := context.Background()
+	resp, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
+		Bucket: aws.String(s.bucket),
+		Key:    aws.String(key),
+	})
+	if err != nil {
+		return nil, err
+	}
+	size := int64(0)
+	if resp.ContentLength != nil {
+		size = *resp.ContentLength
+	}
+	modTime := time.Now()
+	if resp.LastModified != nil {
+		modTime = *resp.LastModified
+	}
+	return NewS3FileInfo(arozfs.Base(key), size, false, modTime), nil
+}
+
+func (s *S3FSAbstraction) Close() error { return nil }
+
+// ---------------------------------------------------------------------------
+// Utility Functions (FileSystemAbstraction interface)
+// ---------------------------------------------------------------------------
+
+func (s *S3FSAbstraction) VirtualPathToRealPath(subpath, username string) (string, error) {
+	return arozfs.GenericVirtualPathToRealPathTranslator(s.uuid, s.hierarchy, subpath, username)
+}
+
+func (s *S3FSAbstraction) RealPathToVirtualPath(fullpath, username string) (string, error) {
+	return arozfs.GenericRealPathToVirtualPathTranslator(s.uuid, s.hierarchy, fullpath, username)
+}
+
+// FileExists returns true if an exact object exists with this key, or if the
+// path is a virtual directory (i.e. objects exist under it as a prefix).
+func (s *S3FSAbstraction) FileExists(realpath string) bool {
+	key := s.realPathToKey(realpath)
+	ctx := context.Background()
+	_, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
+		Bucket: aws.String(s.bucket),
+		Key:    aws.String(key),
+	})
+	if err == nil {
+		return true
+	}
+	return s.IsDir(realpath)
+}
+
+// IsDir returns true when the path represents a virtual S3 "directory":
+// either an explicit directory-marker object exists, or at least one object
+// has this path as a key prefix.
+func (s *S3FSAbstraction) IsDir(realpath string) bool {
+	key := s.realPathToKey(realpath)
+	// Bucket root (or configured prefix root) is always a directory.
+	if key == "" || key == s.prefix {
+		return true
+	}
+
+	prefix := key
+	if !strings.HasSuffix(prefix, "/") {
+		prefix += "/"
+	}
+
+	ctx := context.Background()
+
+	// Check for an explicit directory-marker object.
+	_, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
+		Bucket: aws.String(s.bucket),
+		Key:    aws.String(prefix),
+	})
+	if err == nil {
+		return true
+	}
+
+	// Check if any objects exist under this prefix.
+	resp, err := s.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
+		Bucket:  aws.String(s.bucket),
+		Prefix:  aws.String(prefix),
+		MaxKeys: aws.Int32(1),
+	})
+	if err == nil && len(resp.Contents) > 0 {
+		return true
+	}
+	return false
+}
+
+// Glob lists all entries in the same directory as realpathWildcard whose names
+// match the wildcard pattern.  It uses ReadDir so it works on all FS types.
+func (s *S3FSAbstraction) Glob(realpathWildcard string) ([]string, error) {
+	dir := arozfs.ToSlash(filepath.Dir(realpathWildcard))
+	entries, err := s.ReadDir(dir)
+	if err != nil {
+		return nil, err
+	}
+	var matches []string
+	for _, entry := range entries {
+		fullPath := arozfs.ToSlash(filepath.Join(dir, entry.Name()))
+		matched, err := filepath.Match(realpathWildcard, fullPath)
+		if err != nil {
+			return nil, err
+		}
+		if matched {
+			matches = append(matches, fullPath)
+		}
+	}
+	return matches, nil
+}
+
+func (s *S3FSAbstraction) GetFileSize(realpath string) int64 {
+	key := s.realPathToKey(realpath)
+	ctx := context.Background()
+	resp, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
+		Bucket: aws.String(s.bucket),
+		Key:    aws.String(key),
+	})
+	if err != nil || resp.ContentLength == nil {
+		return 0
+	}
+	return *resp.ContentLength
+}
+
+func (s *S3FSAbstraction) GetModTime(realpath string) (int64, error) {
+	key := s.realPathToKey(realpath)
+	ctx := context.Background()
+	resp, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
+		Bucket: aws.String(s.bucket),
+		Key:    aws.String(key),
+	})
+	if err != nil {
+		return 0, err
+	}
+	if resp.LastModified == nil {
+		return time.Now().Unix(), nil
+	}
+	return resp.LastModified.Unix(), nil
+}
+
+func (s *S3FSAbstraction) WriteFile(filename string, content []byte, mode os.FileMode) error {
+	return s.WriteStream(filename, bytes.NewReader(content), mode)
+}
+
+func (s *S3FSAbstraction) ReadFile(filename string) ([]byte, error) {
+	rc, err := s.ReadStream(filename)
+	if err != nil {
+		return nil, err
+	}
+	defer rc.Close()
+	return io.ReadAll(rc)
+}
+
+// ReadDir lists the immediate children of a virtual S3 directory.
+// Uses ListObjectsV2 with Delimiter "/" so that only the direct contents of
+// the requested level are returned (files and virtual sub-directories).
+func (s *S3FSAbstraction) ReadDir(dirname string) ([]fs.DirEntry, error) {
+	results := []fs.DirEntry{}
+
+	prefix := s.realPathToKey(dirname)
+	if prefix != "" && !strings.HasSuffix(prefix, "/") {
+		prefix += "/"
+	}
+
+	ctx := context.Background()
+	paginator := s3.NewListObjectsV2Paginator(s.client, &s3.ListObjectsV2Input{
+		Bucket:    aws.String(s.bucket),
+		Prefix:    aws.String(prefix),
+		Delimiter: aws.String("/"),
+	})
+
+	for paginator.HasMorePages() {
+		page, err := paginator.NextPage(ctx)
+		if err != nil {
+			return results, err
+		}
+
+		// CommonPrefixes are virtual sub-directories.
+		for _, cp := range page.CommonPrefixes {
+			cpKey := aws.ToString(cp.Prefix)
+			if cpKey == prefix {
+				continue
+			}
+			name := strings.TrimSuffix(strings.TrimPrefix(cpKey, prefix), "/")
+			if name == "" {
+				continue
+			}
+			results = append(results, NewS3DirEntry(name, 0, true, time.Now()))
+		}
+
+		// Contents are individual files at this level.
+		for _, obj := range page.Contents {
+			objKey := aws.ToString(obj.Key)
+			// Skip the directory marker for the current directory itself.
+			if objKey == prefix {
+				continue
+			}
+			name := strings.TrimPrefix(objKey, prefix)
+			name = strings.TrimSuffix(name, "/")
+			if name == "" {
+				continue
+			}
+			size := int64(0)
+			if obj.Size != nil {
+				size = *obj.Size
+			}
+			modTime := time.Now()
+			if obj.LastModified != nil {
+				modTime = *obj.LastModified
+			}
+			isDir := strings.HasSuffix(objKey, "/")
+			results = append(results, NewS3DirEntry(name, size, isDir, modTime))
+		}
+	}
+
+	return results, nil
+}
+
+// WriteStream uploads data from stream directly to S3.
+//
+// The manager.Uploader automatically uses multipart upload when the content
+// exceeds the part threshold (default 5 MiB per part).  The full file is
+// NEVER held in RAM, which is safe on memory-constrained devices (e.g. a
+// Raspberry Pi) even for multi-GiB files.
+func (s *S3FSAbstraction) WriteStream(filename string, stream io.Reader, _ os.FileMode) error {
+	key := s.realPathToKey(filename)
+	_, err := s.uploader.Upload(context.Background(), &s3.PutObjectInput{
+		Bucket:      aws.String(s.bucket),
+		Key:         aws.String(key),
+		Body:        stream,
+		ContentType: aws.String("application/octet-stream"),
+	})
+	return err
+}
+
+// ReadStream opens an S3 object and returns its body as an io.ReadCloser.
+//
+// The body is the raw HTTP response from S3; no data is loaded into memory
+// until the caller calls Read().  This allows serving or copying arbitrarily
+// large files on memory-constrained devices.
+func (s *S3FSAbstraction) ReadStream(filename string) (io.ReadCloser, error) {
+	key := s.realPathToKey(filename)
+	resp, err := s.client.GetObject(context.Background(), &s3.GetObjectInput{
+		Bucket: aws.String(s.bucket),
+		Key:    aws.String(key),
+	})
+	if err != nil {
+		return nil, err
+	}
+	return resp.Body, nil // io.ReadCloser backed directly by the S3 HTTP response
+}
+
+// Walk visits every node reachable from root (files and virtual directories),
+// calling walkFn for each.  It builds the tree level by level via ReadDir so
+// that virtual directory entries are naturally included.
+func (s *S3FSAbstraction) Walk(root string, walkFn filepath.WalkFunc) error {
+	rootInfo := NewS3FileInfo(arozfs.Base(root), 0, true, time.Now())
+	if err := walkFn(root, rootInfo, nil); err != nil {
+		return err
+	}
+	return s.walkDir(root, walkFn)
+}
+
+func (s *S3FSAbstraction) walkDir(dirPath string, walkFn filepath.WalkFunc) error {
+	entries, err := s.ReadDir(dirPath)
+	if err != nil {
+		_ = walkFn(dirPath, nil, err)
+		return err
+	}
+	for _, entry := range entries {
+		fullPath := arozfs.ToSlash(filepath.Join(dirPath, entry.Name()))
+		info, _ := entry.Info()
+		if err := walkFn(fullPath, info, nil); err != nil {
+			return err
+		}
+		if entry.IsDir() {
+			if err := s.walkDir(fullPath, walkFn); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
+// Heartbeat checks that the bucket is still reachable within 5 seconds.
+func (s *S3FSAbstraction) Heartbeat() error {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+	_, err := s.client.HeadBucket(ctx, &s3.HeadBucketInput{
+		Bucket: aws.String(s.bucket),
+	})
+	return err
+}
+
+// ---------------------------------------------------------------------------
+// Internal helpers
+// ---------------------------------------------------------------------------
+
+// s3CopySource builds a properly URL-encoded CopySource value for
+// s3.CopyObjectInput.  The AWS API requires each path segment of the key
+// to be percent-encoded (with %20 for spaces, etc.) while "/" separators
+// are kept as-is.  Using url.PathEscape on the whole key would encode "/" too,
+// so we encode each segment individually.
+func s3CopySource(bucket, key string) string {
+	segments := strings.Split(key, "/")
+	encoded := make([]string, len(segments))
+	for i, seg := range segments {
+		encoded[i] = url.PathEscape(seg)
+	}
+	return bucket + "/" + strings.Join(encoded, "/")
+}
+
+func filterFilepath(rawpath string) string {
+	rawpath = arozfs.ToSlash(filepath.Clean(strings.TrimSpace(rawpath)))
+	if strings.HasPrefix(rawpath, "./") {
+		return rawpath[1:]
+	} else if rawpath == "." || rawpath == "" {
+		return "/"
+	}
+	return rawpath
+}

+ 2 - 2
src/mod/filesystem/arozfs/arozfs.go

@@ -78,7 +78,7 @@ func NewRedirectionError(targetVpath string) error {
 
 // Check if a file system is network drive
 func IsNetworkDrive(fstype string) bool {
-	if fstype == "webdav" || fstype == "ftp" || fstype == "smb" || fstype == "sftp" {
+	if fstype == "webdav" || fstype == "ftp" || fstype == "smb" || fstype == "sftp" || fstype == "s3" {
 		return true
 	}
 
@@ -87,7 +87,7 @@ func IsNetworkDrive(fstype string) bool {
 
 // Get a list of supported file system types for mounting via arozos
 func GetSupportedFileSystemTypes() []string {
-	return []string{"ext4", "ext2", "ext3", "fat", "vfat", "ntfs", "webdav", "ftp", "smb", "sftp"}
+	return []string{"ext4", "ext2", "ext3", "fat", "vfat", "ntfs", "webdav", "ftp", "smb", "sftp", "s3"}
 }
 
 /*

+ 63 - 0
src/mod/filesystem/filesystem.go

@@ -27,6 +27,7 @@ import (
 	uuid "github.com/satori/go.uuid"
 	"imuslab.com/arozos/mod/filesystem/abstractions/ftpfs"
 	"imuslab.com/arozos/mod/filesystem/abstractions/localfs"
+	"imuslab.com/arozos/mod/filesystem/abstractions/s3fs"
 	sftpfs "imuslab.com/arozos/mod/filesystem/abstractions/sftpfs"
 	"imuslab.com/arozos/mod/filesystem/abstractions/smbfs"
 	"imuslab.com/arozos/mod/filesystem/abstractions/webdavfs"
@@ -308,6 +309,68 @@ func NewFileSystemHandler(option FileSystemOption, RuntimePersistenceConfig Runt
 			Closed:                false,
 		}, nil
 
+	} else if fstype == "s3" {
+		// S3-compatible object storage (AWS S3, MinIO, Wasabi, Backblaze B2, Cloudflare R2, …)
+		//
+		// Path format: "[http://]<endpoint>[:<port>]/<bucket>[/<prefix>]"
+		//   Username  = Access Key ID
+		//   Password  = Secret Access Key
+		//
+		// SSL is enabled by default.  Prefix the endpoint with "http://" to
+		// force plain HTTP (e.g. for local MinIO in development).
+
+		rawPath := option.Path
+		useSSL := true
+
+		if strings.HasPrefix(rawPath, "http://") {
+			useSSL = false
+			rawPath = strings.TrimPrefix(rawPath, "http://")
+		} else if strings.HasPrefix(rawPath, "https://") {
+			rawPath = strings.TrimPrefix(rawPath, "https://")
+		}
+
+		// Split into endpoint / bucket / optional prefix
+		pathChunks := strings.SplitN(rawPath, "/", 3)
+		if len(pathChunks) < 2 {
+			return nil, errors.New("invalid S3 path format; expected: [http://]<endpoint>/<bucket>[/<prefix>]")
+		}
+
+		endpoint := pathChunks[0]
+		bucket := pathChunks[1]
+		prefix := ""
+		if len(pathChunks) == 3 {
+			prefix = strings.Trim(pathChunks[2], "/")
+		}
+
+		s3fsh, err := s3fs.NewS3FSAbstraction(
+			option.Uuid,
+			option.Hierarchy,
+			endpoint,
+			bucket,
+			prefix,
+			option.Username,
+			option.Password,
+			useSSL,
+		)
+		if err != nil {
+			return nil, err
+		}
+
+		return &FileSystemHandler{
+			Name:                  option.Name,
+			UUID:                  option.Uuid,
+			Path:                  option.Path,
+			ReadOnly:              option.Access == arozfs.FsReadOnly,
+			RequireBuffer:         true, // S3 uses streaming only; no file-handle semantics
+			Hierarchy:             option.Hierarchy,
+			HierarchyConfig:       nil,
+			InitiationTime:        time.Now().Unix(),
+			FileSystemAbstraction: s3fsh,
+			Filesystem:            fstype,
+			StartOptions:          option,
+			Closed:                false,
+		}, nil
+
 	} else if option.Filesystem == "virtual" {
 		//Virtual filesystem, deprecated
 		log.Println("[File System] Deprecated file system type: Virtual")

+ 12 - 19
src/mod/filesystem/metadata/raw.go

@@ -16,10 +16,6 @@ import (
 
 // Generate thumbnail for RAW image files by extracting embedded JPEG
 func generateThumbnailForRAW(fsh *filesystem.FileSystemHandler, cacheFolder string, file string, generateOnly bool) (string, error) {
-	if fsh.RequireBuffer {
-		return "", errors.New("RAW thumbnail generation not supported for buffered file systems")
-	}
-
 	fshAbs := fsh.FileSystemAbstraction
 
 	// Check file size - skip files larger than 100MB to prevent memory issues
@@ -105,19 +101,18 @@ func generateThumbnailForRAW(fsh *filesystem.FileSystemHandler, cacheFolder stri
 		return "", errors.New("failed to crop thumbnail: " + err.Error())
 	}
 
+	// Encode the thumbnail into a buffer first so we can write it via WriteFile,
+	// which works on every filesystem type including buffered ones (S3, FTP, WebDAV).
+	var thumbBuf bytes.Buffer
+	if err = jpeg.Encode(&thumbBuf, croppedImg, &jpeg.Options{Quality: 90}); err != nil {
+		return "", errors.New("failed to encode thumbnail: " + err.Error())
+	}
+
 	// Create the thumbnail file with the full filename + .jpg
 	// e.g., DSC02977.ARW.jpg
 	outputPath := cacheFolder + filepath.Base(file) + ".jpg"
-	out, err := fshAbs.Create(outputPath)
-	if err != nil {
-		return "", err
-	}
-	defer out.Close()
-
-	// Write JPEG thumbnail
-	err = jpeg.Encode(out, croppedImg, &jpeg.Options{Quality: 90})
-	if err != nil {
-		return "", err
+	if err = fshAbs.WriteFile(outputPath, thumbBuf.Bytes(), 0644); err != nil {
+		return "", errors.New("failed to write thumbnail: " + err.Error())
 	}
 
 	if !generateOnly {
@@ -540,12 +535,10 @@ func getSizeForType(fieldType uint16) uint32 {
 	return 1
 }
 
-// Render full-size RAW image as JPEG for media serving
+// Render full-size RAW image as JPEG for media serving.
+// Works on all filesystem types, including buffered ones (S3, FTP, WebDAV),
+// because it only uses ReadFile / GetFileSize and never opens a file handle.
 func RenderRAWImage(fsh *filesystem.FileSystemHandler, file string) ([]byte, error) {
-	if fsh.RequireBuffer {
-		return nil, errors.New("RAW image rendering not supported for buffered file systems")
-	}
-
 	fshAbs := fsh.FileSystemAbstraction
 
 	// Check file size - skip files larger than 100MB to prevent memory issues

+ 58 - 12
src/web/SystemAO/storage/fshedit.html

@@ -145,6 +145,7 @@
                         <div class="item" data-value="smb">SMB</div>
                         <div class="item" data-value="ftp">FTP</div>
                         <div class="item" data-value="sftp">SFTP</div>
+                        <div class="item" data-value="s3">S3 / S3-Compatible</div>
                     </div>
                 </div>
             </div>
@@ -168,15 +169,32 @@
             <div class="networkfs" style="display:none;">
                 <div class="ui divider"></div>
                 <p>Security and Authentication</p>
+
+                <!-- S3-specific path format and credential guide (shown only for s3 type) -->
+                <div class="s3hint" style="display:none;">
+                    <div class="ui info message" style="margin-bottom:1em;">
+                        <div class="header"><i class="cloud icon"></i> S3 / S3-Compatible Storage</div>
+                        <p><b>Path format:</b></p>
+                        <ul>
+                            <li><code>s3.amazonaws.com/my-bucket</code> &mdash; AWS S3</li>
+                            <li><code>s3.us-west-2.amazonaws.com/my-bucket/prefix</code> &mdash; AWS S3 with region &amp; prefix</li>
+                            <li><code>http://192.168.1.100:9000/my-bucket</code> &mdash; MinIO (local, no SSL)</li>
+                            <li><code>s3.wasabisys.com/my-bucket</code> &mdash; Wasabi</li>
+                            <li><code>&lt;account&gt;.r2.cloudflarestorage.com/my-bucket</code> &mdash; Cloudflare R2</li>
+                        </ul>
+                        <p>Prepend <code>http://</code> to disable SSL (e.g. local MinIO). HTTPS is used by default.</p>
+                    </div>
+                </div>
+
                 <div class="field">
-                    <label>Username</label>
-                    <input type="text" name="username" placeholder="">
+                    <label id="usernameLabel">Username</label>
+                    <input type="text" name="username" id="usernameInput" placeholder="">
                 </div>
                 <div class="field">
-                    <label>Password</label>
-                    <input type="password" name="password" placeholder="">
+                    <label id="passwordLabel">Password</label>
+                    <input type="password" name="password" id="passwordInput" placeholder="">
                 </div>
-                <small>Leave Username / Password field empty for using the old config</small>
+                <small id="authHint">Leave Username / Password field empty for using the old config</small>
                 <br><br>
             </div>
             <button class="ui right floated button" onclick='handleCancel();'>Cancel</button>
@@ -188,7 +206,12 @@
         //Get target fsh uuid and group from hash
         var targetFSH = "";
         var opr = "set";
-        $(".ui.dropdown").dropdown();
+        // Initialise all dropdowns; wire the filesystem-type dropdown to the
+        // change handler so the S3 / local-fs sections toggle correctly.
+        $(".ui.dropdown").not("#fstype").dropdown();
+        $("#fstype").dropdown({
+            onChange: function(value){ handleFileSystemTypeChange(value); }
+        });
         $(".ui.checkbox").checkbox();
 
         $(document).ready(function(){
@@ -269,11 +292,15 @@
 
         function checkPathProtocol(object){
             var newPath = $(object).val();
-            if (newPath.startsWith("http://") || newPath.startsWith("https://")){
-                //WebDAV
-                $("#fstype").dropdown("set selected", "webdav");
-                //newPath = newPath.replace("http://", "");
-                //newPath = newPath.replace("https://", "");
+            if (newPath.startsWith("s3://")){
+                //S3-compatible (strip scheme, keep endpoint/bucket/prefix)
+                $("#fstype").dropdown("set selected", "s3");
+                newPath = newPath.replace("s3://", "");
+                $(object).val(newPath);
+            }else if (newPath.startsWith("http://") || newPath.startsWith("https://")){
+                // http(s):// is ambiguous — it could be WebDAV or an explicit
+                // S3-compatible endpoint (e.g. http://minio:9000/bucket).
+                // Do not auto-select a filesystem type; let the user choose.
                 $(object).val(newPath);
             }else if (newPath.startsWith("ftp://")){
                 //FTP
@@ -313,11 +340,30 @@
                 $(".localfs").show();
                 $(".networkfs").hide();
             }
+
+            // S3-specific credential labels and hint panel
+            if (fstype === "s3"){
+                $(".s3hint").show();
+                $("#usernameLabel").text("Access Key ID");
+                $("#passwordLabel").text("Secret Access Key");
+                $("#usernameInput").attr("placeholder", "e.g. AKIAIOSFODNN7EXAMPLE");
+                $("#passwordInput").attr("placeholder", "e.g. wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLE");
+                $("#authHint").text("Enter your S3 Access Key ID and Secret Access Key.");
+                $("input[name=path]").attr("placeholder", "e.g. s3.amazonaws.com/my-bucket or http://192.168.1.100:9000/my-bucket");
+            }else{
+                $(".s3hint").hide();
+                $("#usernameLabel").text("Username");
+                $("#passwordLabel").text("Password");
+                $("#usernameInput").attr("placeholder", "");
+                $("#passwordInput").attr("placeholder", "");
+                $("#authHint").text("Leave Username / Password field empty for using the old config");
+                $("input[name=path]").attr("placeholder", "e.g. /media/mydrive");
+            }
         }
 
         function isNetworkFs(name){
             name = name.trim();
-            if (name == "webdav" || name == "smb" || name == "ftp" || name == "sftp"){
+            if (name == "webdav" || name == "smb" || name == "ftp" || name == "sftp" || name == "s3"){
                 return true;
             }
             return false;