Add support for file uploads

Co-authored-by: delthas <delthas@dille.cc>
This commit is contained in:
Simon Ser 2024-01-24 20:52:51 +01:00
parent ae10ac1af6
commit b76cb6d5e6
7 changed files with 405 additions and 3 deletions

View file

@ -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

View file

@ -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" {

49
doc/ext/filehost.md Normal file
View file

@ -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.

View file

@ -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

210
fileupload/fileupload.go Normal file
View file

@ -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
}

95
fileupload/fs.go Normal file
View file

@ -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
}

View file

@ -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 {