Add support for file uploads
Co-authored-by: delthas <delthas@dille.cc>
This commit is contained in:
parent
ae10ac1af6
commit
b76cb6d5e6
|
@ -26,6 +26,7 @@ import (
|
||||||
"git.sr.ht/~emersion/soju/auth"
|
"git.sr.ht/~emersion/soju/auth"
|
||||||
"git.sr.ht/~emersion/soju/config"
|
"git.sr.ht/~emersion/soju/config"
|
||||||
"git.sr.ht/~emersion/soju/database"
|
"git.sr.ht/~emersion/soju/database"
|
||||||
|
"git.sr.ht/~emersion/soju/fileupload"
|
||||||
"git.sr.ht/~emersion/soju/identd"
|
"git.sr.ht/~emersion/soju/identd"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -89,6 +90,14 @@ func loadConfig() (*config.Server, *soju.Config, error) {
|
||||||
tlsCert.Store(&cert)
|
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{
|
cfg := &soju.Config{
|
||||||
Hostname: raw.Hostname,
|
Hostname: raw.Hostname,
|
||||||
Title: raw.Title,
|
Title: raw.Title,
|
||||||
|
@ -102,6 +111,7 @@ func loadConfig() (*config.Server, *soju.Config, error) {
|
||||||
EnableUsersOnAuth: raw.EnableUsersOnAuth,
|
EnableUsersOnAuth: raw.EnableUsersOnAuth,
|
||||||
MOTD: motd,
|
MOTD: motd,
|
||||||
Auth: auth,
|
Auth: auth,
|
||||||
|
FileUploader: fileUploader,
|
||||||
}
|
}
|
||||||
return raw, cfg, nil
|
return raw, cfg, nil
|
||||||
}
|
}
|
||||||
|
@ -145,8 +155,20 @@ func main() {
|
||||||
srv.SetConfig(serverCfg)
|
srv.SetConfig(serverCfg)
|
||||||
srv.Logger = soju.NewLogger(log.Writer(), debug)
|
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 := http.NewServeMux()
|
||||||
httpMux.Handle("/socket", srv)
|
httpMux.Handle("/socket", srv)
|
||||||
|
httpMux.Handle("/uploads", fileUploadHandler)
|
||||||
|
httpMux.Handle("/uploads/", fileUploadHandler)
|
||||||
|
|
||||||
for _, listen := range cfg.Listen {
|
for _, listen := range cfg.Listen {
|
||||||
listen := listen // copy
|
listen := listen // copy
|
||||||
|
|
|
@ -67,6 +67,10 @@ type Auth struct {
|
||||||
Driver, Source string
|
Driver, Source string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FileUpload struct {
|
||||||
|
Driver, Source string
|
||||||
|
}
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
Listen []string
|
Listen []string
|
||||||
TLS *TLS
|
TLS *TLS
|
||||||
|
@ -74,9 +78,10 @@ type Server struct {
|
||||||
Title string
|
Title string
|
||||||
MOTDPath string
|
MOTDPath string
|
||||||
|
|
||||||
DB DB
|
DB DB
|
||||||
MsgStore MsgStore
|
MsgStore MsgStore
|
||||||
Auth Auth
|
Auth Auth
|
||||||
|
FileUpload *FileUpload
|
||||||
|
|
||||||
HTTPOrigins []string
|
HTTPOrigins []string
|
||||||
AcceptProxyIPs IPSet
|
AcceptProxyIPs IPSet
|
||||||
|
@ -121,6 +126,7 @@ func Load(path string) (*Server, error) {
|
||||||
MessageStore []string `scfg:"message-store"`
|
MessageStore []string `scfg:"message-store"`
|
||||||
Log []string `scfg:"log"`
|
Log []string `scfg:"log"`
|
||||||
Auth []string `scfg:"auth"`
|
Auth []string `scfg:"auth"`
|
||||||
|
FileUpload []string `scfg:"file-upload"`
|
||||||
HTTPOrigin []string `scfg:"http-origin"`
|
HTTPOrigin []string `scfg:"http-origin"`
|
||||||
AcceptProxyIP []string `scfg:"accept-proxy-ip"`
|
AcceptProxyIP []string `scfg:"accept-proxy-ip"`
|
||||||
MaxUserNetworks int `scfg:"max-user-networks"`
|
MaxUserNetworks int `scfg:"max-user-networks"`
|
||||||
|
@ -194,6 +200,21 @@ func Load(path string) (*Server, error) {
|
||||||
}
|
}
|
||||||
srv.Auth = Auth{driver, source}
|
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
|
srv.HTTPOrigins = raw.HTTPOrigin
|
||||||
for _, s := range raw.AcceptProxyIP {
|
for _, s := range raw.AcceptProxyIP {
|
||||||
if s == "localhost" {
|
if s == "localhost" {
|
||||||
|
|
49
doc/ext/filehost.md
Normal file
49
doc/ext/filehost.md
Normal 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.
|
|
@ -1473,6 +1473,9 @@ func (dc *downstreamConn) welcome(ctx context.Context, user *user) error {
|
||||||
if dc.caps.IsEnabled("soju.im/webpush") {
|
if dc.caps.IsEnabled("soju.im/webpush") {
|
||||||
isupport = append(isupport, "VAPID="+dc.srv.webPush.VAPIDKeys.Public)
|
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 uc := dc.upstream(); uc != nil {
|
||||||
// If upstream doesn't support message-tags, indicate that we'll drop
|
// If upstream doesn't support message-tags, indicate that we'll drop
|
||||||
|
|
210
fileupload/fileupload.go
Normal file
210
fileupload/fileupload.go
Normal 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
95
fileupload/fs.go
Normal 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
|
||||||
|
}
|
|
@ -23,6 +23,7 @@ import (
|
||||||
"git.sr.ht/~emersion/soju/auth"
|
"git.sr.ht/~emersion/soju/auth"
|
||||||
"git.sr.ht/~emersion/soju/config"
|
"git.sr.ht/~emersion/soju/config"
|
||||||
"git.sr.ht/~emersion/soju/database"
|
"git.sr.ht/~emersion/soju/database"
|
||||||
|
"git.sr.ht/~emersion/soju/fileupload"
|
||||||
"git.sr.ht/~emersion/soju/identd"
|
"git.sr.ht/~emersion/soju/identd"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -149,6 +150,7 @@ type Config struct {
|
||||||
DisableInactiveUsersDelay time.Duration
|
DisableInactiveUsersDelay time.Duration
|
||||||
EnableUsersOnAuth bool
|
EnableUsersOnAuth bool
|
||||||
Auth auth.Authenticator
|
Auth auth.Authenticator
|
||||||
|
FileUploader fileupload.Uploader
|
||||||
}
|
}
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
|
|
Loading…
Reference in a new issue