diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1e906fa4..0a67e00f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,7 +12,7 @@ jobs: build: # The type of runner that the job will run on runs-on: ubuntu-22.04 - + # Steps represent a sequence of tasks that will be executed as part of the job steps: # Checks-out your repository under $GITHUB_WORKSPACE @@ -29,7 +29,7 @@ jobs: - name: Setup go uses: actions/setup-go@v4.1.0 with: - go-version: 1.19 + go-version: 1.21.3 - name: Build run: go build -v ./... diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index 9ae3bea3..2a4f9e7a 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4.1.1.1.1 + uses: actions/checkout@v4.1.1 with: fetch-depth: 0 - diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 8a2cf155..09a116cf 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,3 +1,17 @@ builds: - - ldflags: + - env: + - CGO_ENABLED=0 + - GO111MODULE=on + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm + - arm64 + goarm: + - "6" + - "7" + ldflags: - -s -w -X main.BuildVersion={{.Version}} -X main.Commit={{.Commit}} -X main.BuildDate={{.Date}} diff --git a/README.md b/README.md index a3d88299..bc0a32b5 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ At the current stage, supported backend are: - [Google Drive](https://developers.google.com/drive) (see [doc](https://github.com/fclairamb/ftpserver/tree/master/fs/gdrive)) through [afero-gdrive](https://github.com/fclairamb/afero-gdrive) - [SFTP](https://en.wikipedia.org/wiki/SSH_File_Transfer_Protocol) through [afero's sftpfs](https://github.com/spf13/afero/) - Email through [go-mail](https://github.com/go-mail/mail) thanks to [@x-way](https://github.com/x-way) +- Telegram through [telebot](https://github.com/tucnak/telebot) by [@slayer](https://github.com/slayer), see [doc](fs/telegram/README.md) And with those are supported common parameters to switch them to read-only, enable login access, or use a temporary directory file (see [doc](https://github.com/fclairamb/ftpserver/tree/master/fs)). @@ -191,6 +192,16 @@ Here is a sample config file: "password": "password", "hostname": "192.168.168.11:22" } + }, + { + "user": "telegram", + "pass": "telegram", + "fs": "telegram", + "shared": true, + "params": { + "Token": "", + "ChatID": "" + } } ] } diff --git a/fs/fs.go b/fs/fs.go index aa01826e..24ea4bfc 100644 --- a/fs/fs.go +++ b/fs/fs.go @@ -15,6 +15,7 @@ import ( "github.com/fclairamb/ftpserver/fs/mail" "github.com/fclairamb/ftpserver/fs/s3" "github.com/fclairamb/ftpserver/fs/sftp" + "github.com/fclairamb/ftpserver/fs/telegram" ) // UnsupportedFsError is returned when the described file system is not supported @@ -45,6 +46,8 @@ func LoadFs(access *confpar.Access, logger log.Logger) (afero.Fs, error) { fs, err = gdrive.LoadFs(access, logger.With("component", "gdrive")) case "dropbox": fs, err = dropbox.LoadFs(access) + case "telegram": + fs, err = telegram.LoadFs(access, logger.With("component", "telegram")) default: fs, err = nil, &UnsupportedFsError{Type: access.Fs} } diff --git a/fs/telegram/README.md b/fs/telegram/README.md new file mode 100644 index 00000000..07bde2a7 --- /dev/null +++ b/fs/telegram/README.md @@ -0,0 +1,45 @@ +# FTPServer Telegram connector + +## Register bot + +Read about telegram bots at https://core.telegram.org/bots/tutorial. + +Bots are not allowed to contact users. You need to make the first contact from the user for which you want to set up the bot. + +### Quick start + +- Create a bot with [@BotFather](https://t.me/BotFather), let's say with username `my_ftp_bot` +- Get bot token from BotFather's response, use it as `Token` in config +- Get bot id by run `curl https://api.telegram.org/bot/getMe` +- Find `@my_ftp_bot` in telegram and start chat with it +- Send `/start` to bot +- Run `curl https://api.telegram.org/bot/getUpdates` and find your chat id in response, use it as `ChatID` in config + + +## Config example + +Please note about `shared` flag. If it's `true` then bot instance will be shared between all connections. +If it's `false` then each user (or even each ftp connection) will have own bot instance and it can lead to telegram bot flood protection. + +```json +{ + "version": 1, + "accesses": [ + { + "fs": "telegram", + "shared": true, + "user": "my_ftp_bot", + "pass": "my_secure_password", + "params": { + "Token": "", + "ChatID": "" + } + + } + ], + "passive_transfer_port_range": { + "start": 2122, + "end": 2130 + } +} +``` \ No newline at end of file diff --git a/fs/telegram/example.conf b/fs/telegram/example.conf new file mode 100644 index 00000000..3cb27ac0 --- /dev/null +++ b/fs/telegram/example.conf @@ -0,0 +1,20 @@ +{ + "version": 1, + "accesses": [ + { + "fs": "telegram", + "shared": true, + "user": "test", + "pass": "test", + "params": { + "Token": "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZ123456789", + "ChatID": "123456789" + } + + } + ], + "passive_transfer_port_range": { + "start": 2122, + "end": 2130 + } +} diff --git a/fs/telegram/fake_fs.go b/fs/telegram/fake_fs.go new file mode 100644 index 00000000..3d07e3dc --- /dev/null +++ b/fs/telegram/fake_fs.go @@ -0,0 +1,79 @@ +package telegram + +import ( + "os" + "sync" +) + +// fakeFilesystem is a really simple and limited fake filesystem intended for store temporary info about files +// since some ftp clients expect to perform mkdir() + stat() on files and directories before upload +type fakeFilesystem struct { + sync.Mutex + dict map[string]*FileInfo + // dir fakeDir +} + +type fakeDir struct { + name string + content []os.FileInfo +} + +// newFakeFilesystem creates a new fake filesystem +func newFakeFilesystem() *fakeFilesystem { + return &fakeFilesystem{ + dict: map[string]*FileInfo{}, + // dir: fakeDir{content: []os.FileInfo{}}, + } +} + +// mkdir creates a directory +func (f *fakeFilesystem) mkdir(name string, mode os.FileMode) { + f.Lock() + defer f.Unlock() + f.dict[name] = &FileInfo{&FileData{ + name: name, + dir: true, + mode: mode, + }} +} + +// create creates a file +func (f *fakeFilesystem) create(name string) { + f.Lock() + defer f.Unlock() + f.dict[name] = &FileInfo{&FileData{ + name: name, + dir: false, + }} +} + +// setSize sets the size of a file +func (f *fakeFilesystem) setSize(name string, size int64) { + f.Lock() + defer f.Unlock() + if fileInfo, found := f.dict[name]; found { + fileInfo.size = size + } +} + +// stat returns a file info +func (f *fakeFilesystem) stat(name string) *FileInfo { + f.Lock() + defer f.Unlock() + return f.dict[name] +} + +// remove removes a file +func (f *fakeFilesystem) remove(name string) { + f.Lock() + defer f.Unlock() + delete(f.dict, name) +} + +// exists checks if a file exists +func (f *fakeFilesystem) exists(name string) bool { + f.Lock() + defer f.Unlock() + _, ok := f.dict[name] + return ok +} diff --git a/fs/telegram/file_info.go b/fs/telegram/file_info.go new file mode 100644 index 00000000..b6d30fdc --- /dev/null +++ b/fs/telegram/file_info.go @@ -0,0 +1,37 @@ +package telegram + +import ( + "os" + "path/filepath" + "time" +) + +// FileData is a simple structure to store file information and implement os.FileInfo interface +type FileData struct { + name string + dir bool + mode os.FileMode + modtime time.Time + size int64 +} + +type FileInfo struct { + *FileData +} + +// Implements os.FileInfo +func (s *FileInfo) Name() string { + _, name := filepath.Split(s.name) + return name +} + +func (s *FileInfo) Mode() os.FileMode { return s.mode } +func (s *FileInfo) ModTime() time.Time { return s.modtime } +func (s *FileInfo) IsDir() bool { return s.dir } +func (s *FileInfo) Sys() interface{} { return nil } +func (s *FileInfo) Size() int64 { + if s.IsDir() { + return int64(42) + } + return s.size +} diff --git a/fs/telegram/telegram.go b/fs/telegram/telegram.go new file mode 100644 index 00000000..1a55f974 --- /dev/null +++ b/fs/telegram/telegram.go @@ -0,0 +1,360 @@ +// Package telegram provides a telegram access layer +package telegram + +import ( + "errors" + "fmt" + "io" + "path/filepath" + "strconv" + "strings" + + "os" + "sync/atomic" + "time" + + log "github.com/fclairamb/go-log" + tele "gopkg.in/telebot.v3" + + "github.com/spf13/afero" + + "github.com/fclairamb/ftpserver/config/confpar" + "gopkg.in/telebot.v3/middleware" +) + +// ErrNotImplemented is returned when something is not implemented +var ErrNotImplemented = errors.New("not implemented") + +// ErrNotFound is returned when something is not found +var ErrNotFound = errors.New("not found") + +// ErrInvalidParameter is returned when a parameter is invalid +var ErrInvalidParameter = errors.New("invalid parameter") + +// Fs is a write-only afero.Fs implementation using telegram as backend +type Fs struct { + // Bot is the telegram bot instance + Bot *tele.Bot + // ChatID is the telegram chat ID to send files to + ChatID int64 + // Logger is the logger, obviously + Logger log.Logger + + // fakeFs is a lightweight fake filesystem intended for store temporary info about files + // since some ftp clients expect to perform mkdir() + stat() on files and directories before upload + fakeFs *fakeFilesystem +} + +// File is the afero.File implementation +type File struct { + // Path is the file path + Path string + // Content is the file content + Content []byte + // Fs is the parent Fs + Fs *Fs + // At is the current position in the file + At int64 +} + +// imageExtensions is the list of supported image extensions +var imageExtensions = []string{".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".tif"} + +// videoExtensions is the list of supported video extensions +var videoExtensions = []string{".mp4", ".avi", ".mkv", ".mov", ".wmv", ".flv", ".webm", ".mpeg", ".mpg", ".m4v", ".3gp", ".3g2"} + +// textExtensions is the list of supported text extensions +var textExtensions = []string{".txt", ".md"} + +// audioExtensions is the list of supported audio extensions +var audioExtensions = []string{".mp3", ".ogg", ".flac", ".wav", ".m4a", ".opus"} + +// LoadFs loads a file system from an access description +func LoadFs(access *confpar.Access, logger log.Logger) (afero.Fs, error) { + + token := access.Params["Token"] + if token == "" { + return nil, fmt.Errorf("parameter Token is empty") + } + + chatID, err := strconv.ParseInt(access.Params["ChatID"], 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid ChatID parameter: %v", err) + } + + pref := tele.Settings{ + Token: token, + Poller: &tele.LongPoller{Timeout: 10 * time.Second}, + } + + bot, err := tele.NewBot(pref) + if err != nil { + logger.Error("telegram bot initialization", "err", err) + return nil, err + } + bot.Use(middleware.Logger()) + bot.Use(middleware.AutoRespond()) + + bot.Handle("/start", startHandler) + bot.Handle("/help", helpHandler) + + go func() { + // Run bot in the background + bot.Start() + }() + + fs := &Fs{ + Bot: bot, + Logger: logger, + ChatID: chatID, + fakeFs: newFakeFilesystem(), + } + + return fs, nil +} + +// Name of the file +func (f *File) Name() string { return f.Path } + +// Close closes the file transfer and does the actual transfer to telegram +func (f *File) Close() error { + if f.Fs == nil { + return ErrNotFound + } + + chat := tele.Chat{ID: f.Fs.ChatID} + var err error + basePath := filepath.Base(f.Path) + + if isExtension(f.Path, imageExtensions) { + photo := tele.Photo{File: tele.FromReader(f), Caption: basePath} + _, err = f.Fs.Bot.Send(&chat, &photo) + } else if isExtension(f.Path, videoExtensions) { + video := tele.Video{File: tele.FromReader(f), Caption: basePath} + _, err = f.Fs.Bot.Send(&chat, &video) + } else if isExtension(f.Path, audioExtensions) { + audio := tele.Audio{File: tele.FromReader(f), Caption: basePath} + _, err = f.Fs.Bot.Send(&chat, &audio) + } else if isExtension(f.Path, textExtensions) && len(f.Content) < 4096 { + if isExtension(f.Path, []string{".md"}) { + _, err = f.Fs.Bot.Send(&chat, string(f.Content), tele.ModeMarkdown) + } else { + _, err = f.Fs.Bot.Send(&chat, string(f.Content)) + } + } else { + document := tele.Document{File: tele.FromReader(f), Caption: basePath} + document.FileName = basePath + document.FileLocal = basePath + _, err = f.Fs.Bot.Send(&chat, &document) + } + f.Fs.Logger.Info("telegram Bot.Send()", "path", f.Path) + + if err != nil { + f.Fs.Logger.Error("telegram Bot.Send()", "err", err) + return err + } + + f.Fs.fakeFs.create(f.Path) + f.Fs.fakeFs.setSize(f.Path, int64(len(f.Content))) + + f.Content = []byte{} + f.At = 0 + + return nil +} + +// Read stores the received file content into the local buffer +func (f *File) Read(b []byte) (int, error) { + n := 0 + + if len(b) > 0 && int(f.At) == len(f.Content) { + return 0, io.EOF + } + + if len(f.Content)-int(f.At) >= len(b) { + n = len(b) + } else { + n = len(f.Content) - int(f.At) + } + + copy(b, f.Content[f.At:f.At+int64(n)]) + atomic.AddInt64(&f.At, int64(n)) + + return n, nil +} + +// ReadAt is not implemented +func (f *File) ReadAt(_ []byte, _ int64) (int, error) { + return 0, ErrNotImplemented +} + +// Truncate is not implemented +func (f *File) Truncate(_ int64) error { + return nil +} + +// Readdir is not implemented +func (f *File) Readdir(_ int) ([]os.FileInfo, error) { + return []os.FileInfo{}, nil +} + +// Readdirnames is not implemented +func (f *File) Readdirnames(_ int) ([]string, error) { + return []string{}, nil +} + +// Seek is not implemented +func (f *File) Seek(_ int64, _ int) (int64, error) { + return 0, nil +} + +// Stat for the file relies on the fake filesystem +func (f *File) Stat() (os.FileInfo, error) { + fileInfo := f.Fs.fakeFs.stat(f.Path) + + if fileInfo == nil { + return nil, &os.PathError{Op: "stat", Path: f.Path, Err: nil} + } + return fileInfo, nil +} + +// Sync is not implemented +func (f *File) Sync() error { + return nil +} + +// WriteString is not implemented +func (f *File) WriteString(s string) (int, error) { + return 0, ErrNotImplemented +} + +// WriteAt is not implemented +func (f *File) WriteAt(b []byte, off int64) (int, error) { + return 0, ErrNotImplemented +} + +func (f *File) Write(b []byte) (int, error) { + f.Content = append(f.Content, b...) + + return len(b), nil +} + +// Name of the filesystem +func (m *Fs) Name() string { + return "telegram" +} + +// Chtimes is not implemented +func (m *Fs) Chtimes(name string, atime, mtime time.Time) error { + return nil +} + +// Chmod is not implemented +func (m *Fs) Chmod(name string, mode os.FileMode) error { + return nil +} + +// Rename is not implemented +func (m *Fs) Rename(name string, newname string) error { + return nil +} + +// Chown is not implemented +func (m *Fs) Chown(string, int, int) error { + return nil +} + +// RemoveAll is not implemented +func (m *Fs) RemoveAll(name string) error { + return nil +} + +// Remove is not implemented +func (m *Fs) Remove(name string) error { + return nil +} + +// Mkdir +func (m *Fs) Mkdir(name string, mode os.FileMode) error { + m.fakeFs.mkdir(name, mode) + return nil +} + +// MkdirAll creates full path of directories +// like mkdir -p +func (m *Fs) MkdirAll(name string, mode os.FileMode) error { + path := strings.Split(name, "/") + for i := 0; i < len(path); i++ { + dir := strings.Join(path[:i+1], "/") + m.fakeFs.mkdir(dir, mode) + } + return nil +} + +// Open opens a file buffer +func (m *Fs) Open(name string) (afero.File, error) { + return &File{Path: name, Fs: m}, nil +} + +// Create creates a file buffer +func (m *Fs) Create(name string) (afero.File, error) { + m.fakeFs.create(name) + return &File{Path: name, Fs: m}, nil +} + +// OpenFile opens a file buffer +func (m *Fs) OpenFile(name string, flag int, mode os.FileMode) (afero.File, error) { + return &File{Path: name, Fs: m}, nil +} + +// Stat() fake implementation +func (m *Fs) Stat(name string) (os.FileInfo, error) { + fileInfo := m.fakeFs.stat(name) + + if fileInfo == nil { + return nil, &os.PathError{Op: "stat", Path: name, Err: nil} + } + return fileInfo, nil +} + +// LstatIfPossible is not implemented +func (m *Fs) LstatIfPossible(name string) (os.FileInfo, bool, error) { + return nil, false, &os.PathError{Op: "lstat", Path: name, Err: nil} +} + +func isExtension(filename string, extensions []string) bool { + extension := strings.ToLower(filepath.Ext(filename)) + for _, ext := range extensions { + if extension == ext { + return true + } + } + return false +} + +const readMeURL = "https://github.com/slayer/ftpserver" + +// /start command handler +func startHandler(c tele.Context) error { + err := helpHandler(c) + if err != nil { + return err + } + var chatID int64 + chat := c.Chat() + if chat != nil { + chatID = chat.ID + } + + err = c.Send(fmt.Sprintf("Current `ChatID` is `%d`", chatID), tele.ModeMarkdown) + return err +} + +func helpHandler(c tele.Context) error { + firstName := "" + if c.Sender() != nil { + firstName = c.Sender().FirstName + } + message := fmt.Sprintf("Hello %s!, you can read more about me at %s", firstName, readMeURL) + return c.Send(message) +} diff --git a/go.mod b/go.mod index 7c525ae8..c51d4d08 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/tidwall/sjson v1.2.5 golang.org/x/crypto v0.15.0 golang.org/x/oauth2 v0.14.0 + gopkg.in/telebot.v3 v3.1.4 ) require ( diff --git a/go.sum b/go.sum index 0262981f..e0285d55 100644 --- a/go.sum +++ b/go.sum @@ -1805,6 +1805,8 @@ gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMy gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o= gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk= gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw= +gopkg.in/telebot.v3 v3.1.4 h1:RMmN6FMKvh+j4sylImulZL1sgi7vMnCyD6NUsowkjK8= +gopkg.in/telebot.v3 v3.1.4/go.mod h1:GJKwwWqp9nSkIVN51eRKU78aB5f5OnQuWdwiIZfPbko= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=