diff --git a/cmd/soju/main.go b/cmd/soju/main.go index 35b73fc..16a6506 100644 --- a/cmd/soju/main.go +++ b/cmd/soju/main.go @@ -26,6 +26,7 @@ import ( "git.sr.ht/~emersion/soju/auth" "git.sr.ht/~emersion/soju/config" "git.sr.ht/~emersion/soju/database" + "git.sr.ht/~emersion/soju/fileupload" "git.sr.ht/~emersion/soju/identd" ) @@ -89,6 +90,14 @@ func loadConfig() (*config.Server, *soju.Config, error) { tlsCert.Store(&cert) } + var fileUploader fileupload.Uploader + if raw.FileUpload.Driver != "" { + fileUploader, err = fileupload.New(raw.FileUpload.Driver, raw.FileUpload.Source) + if err != nil { + return nil, nil, fmt.Errorf("failed to create file uploader: %v", err) + } + } + cfg := &soju.Config{ Hostname: raw.Hostname, Title: raw.Title, @@ -102,6 +111,7 @@ func loadConfig() (*config.Server, *soju.Config, error) { EnableUsersOnAuth: raw.EnableUsersOnAuth, MOTD: motd, Auth: auth, + FileUploader: fileUploader, } return raw, cfg, nil } @@ -145,8 +155,20 @@ func main() { srv.SetConfig(serverCfg) srv.Logger = soju.NewLogger(log.Writer(), debug) + fileUploadHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cfg := srv.Config() + h := fileupload.Handler{ + Uploader: cfg.FileUploader, + DB: db, + Auth: cfg.Auth, + } + h.ServeHTTP(w, r) + }) + httpMux := http.NewServeMux() httpMux.Handle("/socket", srv) + httpMux.Handle("/uploads", fileUploadHandler) + httpMux.Handle("/uploads/", fileUploadHandler) for _, listen := range cfg.Listen { listen := listen // copy diff --git a/config/config.go b/config/config.go index 1b40cbe..59f67c8 100644 --- a/config/config.go +++ b/config/config.go @@ -67,6 +67,10 @@ type Auth struct { Driver, Source string } +type FileUpload struct { + Driver, Source string +} + type Server struct { Listen []string TLS *TLS @@ -74,9 +78,10 @@ type Server struct { Title string MOTDPath string - DB DB - MsgStore MsgStore - Auth Auth + DB DB + MsgStore MsgStore + Auth Auth + FileUpload *FileUpload HTTPOrigins []string AcceptProxyIPs IPSet @@ -121,6 +126,7 @@ func Load(path string) (*Server, error) { MessageStore []string `scfg:"message-store"` Log []string `scfg:"log"` Auth []string `scfg:"auth"` + FileUpload []string `scfg:"file-upload"` HTTPOrigin []string `scfg:"http-origin"` AcceptProxyIP []string `scfg:"accept-proxy-ip"` MaxUserNetworks int `scfg:"max-user-networks"` @@ -194,6 +200,21 @@ func Load(path string) (*Server, error) { } srv.Auth = Auth{driver, source} } + if raw.FileUpload != nil { + driver, source, err := parseDriverSource("file-upload", raw.FileUpload) + if err != nil { + return nil, err + } + switch driver { + case "fs": + if source == "" { + return nil, fmt.Errorf("directive file-upload: driver %q requires a source", driver) + } + default: + return nil, fmt.Errorf("directive file-upload: unknown driver %q", driver) + } + srv.FileUpload = &FileUpload{driver, source} + } srv.HTTPOrigins = raw.HTTPOrigin for _, s := range raw.AcceptProxyIP { if s == "localhost" { diff --git a/doc/ext/filehost.md b/doc/ext/filehost.md new file mode 100644 index 0000000..f19643a --- /dev/null +++ b/doc/ext/filehost.md @@ -0,0 +1,49 @@ +--- +title: The Filehost ISUPPORT token +layout: spec +work-in-progress: true +copyrights: + - + name: "Val Lorentz" + email: "progval+ircv3@progval.net" + period: "2022" + - + name: "Simon Ser" + period: "2024" +--- + +# filehost + +This is a work-in-progress specification. + +# Motivation + +This specification offers a way for servers to advertise a hosting service for +users to upload files (such as text or images), so they can post them on IRC. + +## Architecture + +This specification introduces the `soju.im/FILEHOST` isupport token. + +Its value MUST be a URI and SHOULD use the `https` scheme. Clients MUST ignore +tokens with an URI scheme they don't support. Clients MUST refuse to use +unencrypted URI transports (such as plain `http`) if the IRC connection is +encrypted (e.g. via TLS). + +Servers MUST accept OPTIONS requests on the upload URI. Servers MAY return an +`Accept-Post` header field to indicate the MIME types they accept. + +When clients wish to upload a file using the server's recommended service, they +can send a request to the upload URI. The request method MUST be POST. Clients +SHOULD authenticate their HTTP request with the same credentials used on the +IRC connection (e.g. HTTP Basic for SASL PLAIN, HTTP Bearer for SASL +OAUTHBEARER). Clients SHOULD use the `Content-Type`, `Content-Disposition` and +`Content-Length` header fields to indicate the MIME type, name and size of the +file to be uploaded. + +On success, servers MUST reply with a `201 Created` status code and with a +`Location` header field indicating the URI of the uploaded file. Servers MUST +support HEAD and GET requests on the uploaded file URI. + +Clients SHOULD gracefully handle other common HTTP status codes that could +occur. diff --git a/downstream.go b/downstream.go index f28b40f..e5e83f3 100644 --- a/downstream.go +++ b/downstream.go @@ -1473,6 +1473,9 @@ func (dc *downstreamConn) welcome(ctx context.Context, user *user) error { if dc.caps.IsEnabled("soju.im/webpush") { isupport = append(isupport, "VAPID="+dc.srv.webPush.VAPIDKeys.Public) } + if dc.srv.Config().FileUploader != nil { + isupport = append(isupport, "soju.im/FILEHOST=https://"+dc.srv.Config().Hostname+"/upload") + } if uc := dc.upstream(); uc != nil { // If upstream doesn't support message-tags, indicate that we'll drop diff --git a/fileupload/fileupload.go b/fileupload/fileupload.go new file mode 100644 index 0000000..0dd90e7 --- /dev/null +++ b/fileupload/fileupload.go @@ -0,0 +1,210 @@ +package fileupload + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "mime" + "net/http" + "path" + "strings" + "time" + + "git.sr.ht/~emersion/soju/auth" + "git.sr.ht/~emersion/soju/database" +) + +const maxSize = 50 * 1024 * 1024 // 50 MiB + +type Uploader interface { + load(filename string) (basename string, modTime time.Time, content io.ReadSeekCloser, err error) + store(r io.Reader, username, mimeType, basename string) (outFilename string, err error) +} + +func New(driver, source string) (Uploader, error) { + switch driver { + case "fs": + return &fs{source}, nil + default: + return nil, fmt.Errorf("unknown file upload driver %q", driver) + } +} + +type Handler struct { + Uploader Uploader + Auth auth.Authenticator + DB database.Database +} + +func (h *Handler) ServeHTTP(resp http.ResponseWriter, req *http.Request) { + resp.Header().Set("Content-Security-Policy", "sandbox; default-src 'none'; script-src 'none';") + + if h.Uploader == nil { + http.NotFound(resp, req) + return + } + + switch req.Method { + case http.MethodOptions: + resp.WriteHeader(http.StatusNoContent) + case http.MethodHead, http.MethodGet: + h.fetch(resp, req) + case http.MethodPost: + h.store(resp, req) + default: + http.Error(resp, "only OPTIONS, HEAD, GET and POST are allowed", http.StatusMethodNotAllowed) + } +} + +func (h *Handler) fetch(resp http.ResponseWriter, req *http.Request) { + prefix := "/uploads/" + if !strings.HasPrefix(req.URL.Path, prefix) { + http.Error(resp, "invalid path", http.StatusNotFound) + return + } + + filename := strings.TrimPrefix(req.URL.Path, prefix) + filename = path.Join("/", filename)[1:] // prevent directory traversal + if filename == "" { + http.Error(resp, "invalid path", http.StatusNotFound) + return + } + + basename, modTime, content, err := h.Uploader.load(filename) + if err != nil { + http.Error(resp, "failed to open file", http.StatusNotFound) + return + } + defer content.Close() + + contentDisp := mime.FormatMediaType("attachment", map[string]string{ + "filename": basename, + }) + resp.Header().Set("Content-Disposition", contentDisp) + + http.ServeContent(resp, req, basename, modTime, content) +} + +func (h *Handler) store(resp http.ResponseWriter, req *http.Request) { + if req.URL.Path != "/uploads" { + http.Error(resp, "invalid path", http.StatusNotFound) + return + } + + authz := req.Header.Get("Authorization") + if authz == "" { + http.Error(resp, "missing Authorization header", http.StatusUnauthorized) + return + } + + var ( + username string + err error + ) + scheme, param, _ := strings.Cut(authz, " ") + switch strings.ToLower(scheme) { + case "basic": + plainAuth, ok := h.Auth.(auth.PlainAuthenticator) + if !ok { + http.Error(resp, "Basic scheme in Authorization header not supported", http.StatusBadRequest) + return + } + var password string + username, password, ok = req.BasicAuth() + if !ok { + http.Error(resp, "invalid Authorization header", http.StatusBadRequest) + return + } + err = plainAuth.AuthPlain(req.Context(), h.DB, username, password) + case "bearer": + oauthAuth, ok := h.Auth.(auth.OAuthBearerAuthenticator) + if !ok { + http.Error(resp, "Bearer scheme in Authorization header not supported", http.StatusBadRequest) + return + } + username, err = oauthAuth.AuthOAuthBearer(req.Context(), h.DB, param) + default: + http.Error(resp, "unsupported Authorization header scheme", http.StatusBadRequest) + return + } + if err != nil { + var msg string + if authErr, ok := err.(*auth.Error); ok { + msg = authErr.ExternalMsg + } else { + msg = "authentication failed" + } + http.Error(resp, msg, http.StatusForbidden) + return + } + + var mimeType string + if contentType := req.Header.Get("Content-Type"); contentType != "" { + var ( + params map[string]string + err error + ) + mimeType, params, err = mime.ParseMediaType(contentType) + if err != nil { + http.Error(resp, "failed to parse Content-Type", http.StatusBadRequest) + return + } + if mimeType == "application/octet-stream" { + mimeType = "" + } + + switch strings.ToLower(params["charset"]) { + case "", "utf-8", "us-ascii": + // OK + default: + http.Error(resp, "unsupported charset", http.StatusUnsupportedMediaType) + return + } + } + + var basename string + if contentDisp := req.Header.Get("Content-Disposition"); contentDisp != "" { + _, params, err := mime.ParseMediaType(contentDisp) + if err != nil { + http.Error(resp, "failed to parse Content-Disposition", http.StatusBadRequest) + return + } + basename = path.Base(params["filename"]) + } + + r := &limitedReader{r: req.Body, n: maxSize} + outFilename, err := h.Uploader.store(r, username, mimeType, basename) + if err != nil { + http.Error(resp, "failed to write file", http.StatusInternalServerError) + return + } + + resp.Header().Set("Location", "/uploads/"+outFilename) + resp.WriteHeader(http.StatusCreated) +} + +type limitedReader struct { + r io.Reader + n int64 +} + +func (lr *limitedReader) Read(p []byte) (n int, err error) { + if lr.n <= 0 { + return 0, fmt.Errorf("file too large") + } + if int64(len(p)) > lr.n { + p = p[0:lr.n] + } + n, err = lr.r.Read(p) + lr.n -= int64(n) + return n, err +} + +func generateToken(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/fileupload/fs.go b/fileupload/fs.go new file mode 100644 index 0000000..88f0001 --- /dev/null +++ b/fileupload/fs.go @@ -0,0 +1,95 @@ +package fileupload + +import ( + "fmt" + "io" + "mime" + "os" + "path/filepath" + "strings" + "time" +) + +type fs struct { + dir string +} + +var _ Uploader = (*fs)(nil) + +func (fs *fs) load(filename string) (basename string, modTime time.Time, content io.ReadSeekCloser, err error) { + f, err := os.Open(filepath.Join(fs.dir, filepath.FromSlash(filename))) + if err != nil { + return "", time.Time{}, nil, err + } + + fi, err := f.Stat() + if err != nil { + f.Close() + return "", time.Time{}, nil, err + } else if fi.IsDir() { + f.Close() + return "", time.Time{}, nil, fmt.Errorf("file is a directory") + } + + basename = filepath.Base(filename) + if i := strings.IndexByte(basename, '-'); i >= 0 { + basename = basename[i+1:] + } + + return basename, fi.ModTime(), f, nil +} + +func (fs *fs) store(r io.Reader, username, mimeType, origBasename string) (outFilename string, err error) { + origBasename = filepath.Base(origBasename) + + var suffix string + if origBasename == "" && mimeType != "" { + exts, _ := mime.ExtensionsByType(mimeType) + if len(exts) > 0 { + suffix = exts[0] + } + } + + dir := filepath.Join(fs.dir, username) + if err := os.MkdirAll(dir, 0700); err != nil { + return "", fmt.Errorf("failed to create user upload directory: %v", err) + } + + var f *os.File + for i := 0; i < 100; i++ { + tokenLen := 8 + if origBasename != "" && i == 0 { + tokenLen = 4 + } + prefix, err := generateToken(tokenLen) + if err != nil { + return "", fmt.Errorf("failed to generate file base: %v", err) + } + + basename := prefix + if origBasename != "" { + basename += "-" + origBasename + } + basename += suffix + + f, err = os.OpenFile(filepath.Join(dir, basename), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err == nil { + break + } else if !os.IsExist(err) { + return "", fmt.Errorf("failed to open file: %v", err) + } + } + if f == nil { + return "", fmt.Errorf("failed to pick filename") + } + defer f.Close() + + if _, err := io.Copy(f, r); err != nil { + return "", fmt.Errorf("failed to write file: %v", err) + } + if err := f.Close(); err != nil { + return "", fmt.Errorf("failed to close file: %v", err) + } + + return username + "/" + filepath.Base(f.Name()), nil +} diff --git a/server.go b/server.go index d26e873..710bc39 100644 --- a/server.go +++ b/server.go @@ -23,6 +23,7 @@ import ( "git.sr.ht/~emersion/soju/auth" "git.sr.ht/~emersion/soju/config" "git.sr.ht/~emersion/soju/database" + "git.sr.ht/~emersion/soju/fileupload" "git.sr.ht/~emersion/soju/identd" ) @@ -149,6 +150,7 @@ type Config struct { DisableInactiveUsersDelay time.Duration EnableUsersOnAuth bool Auth auth.Authenticator + FileUploader fileupload.Uploader } type Server struct {