diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..784cd07 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# idea +.idea + +# config +config.yml +dist/ \ No newline at end of file diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..6e185ed --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,40 @@ +# This is an example .goreleaser.yml file with some sensible defaults. +# Make sure to check the documentation at https://goreleaser.com + +# The lines below are called `modelines`. See `:help modeline` +# Feel free to remove those if you don't want/need to use them. +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +# vim: set ts=2 sw=2 tw=0 fo=cnqoj + +version: 1 + +builds: + - env: + - CGO_ENABLED=0 + goos: + - linux + - windows + - darwin + main: ./cmd/telebackup/main.go + +archives: + - format: tar.gz + # this name template makes the OS and Arch compatible with the results of `uname`. + name_template: >- + {{ .ProjectName }}_ + {{- title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + {{- if .Arm }}v{{ .Arm }}{{ end }} + # use zip for windows archives + format_overrides: + - goos: windows + format: zip + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cbf4632 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright(c) 2024 Alex + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files(the "Software"), to deal + in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/cmd/telebackup/main.go b/cmd/telebackup/main.go new file mode 100644 index 0000000..77f918a --- /dev/null +++ b/cmd/telebackup/main.go @@ -0,0 +1,76 @@ +package main + +import ( + "flag" + "fmt" + "github.com/amarnathcjd/gogram/telegram" + "os" + "strings" + "sync" + "telebackup/internal/compress" + "telebackup/internal/config" + "time" +) + +func main() { + configFile := flag.String("config", "config.yaml", "config file") + flag.Parse() + reader, err := os.ReadFile(*configFile) + if err != nil { + panic(err) + } + resultConfig, err := config.ParseConfig(reader) + if err != nil { + panic(err) + } + + client, _ := telegram.NewClient(telegram.ClientConfig{ + AppID: resultConfig.AppID, + AppHash: resultConfig.AppHash, + LogLevel: telegram.LogWarn, + }) + + if err := client.Connect(); err != nil { + panic(err) + } + + // Authenticate the client using the bot token + if err := client.LoginBot(resultConfig.BotToken); err != nil { + panic(err) + } + + wg := &sync.WaitGroup{} + for _, path := range resultConfig.Targets { + path := path + go func() { + tempFile, err := os.CreateTemp("", "telebackup-*.tar.gz") + if err != nil { + fmt.Println(err) + return + } + buf, _ := os.OpenFile(tempFile.Name(), os.O_CREATE|os.O_WRONLY, 0644) + err = compress.CompressPath(path, buf) + if err != nil { + fmt.Println(err) + return + } + dirs := strings.Split(path, "/") + lastDir := dirs[len(dirs)-1] + file, err := client.UploadFile(tempFile.Name(), &telegram.UploadOptions{FileName: lastDir + fmt.Sprintf("-%d.tar.gz", time.Now().Unix())}) + if err != nil { + fmt.Println(err) + return + } + _, err = client.SendMedia(resultConfig.Target, file, &telegram.MediaOptions{Caption: path}) + if err != nil { + fmt.Println(err) + return + } + fmt.Println("Done", path) + wg.Done() + }() + wg.Add(1) + } + wg.Wait() + +} diff --git a/config.example.yml b/config.example.yml new file mode 100644 index 0000000..5a43db7 --- /dev/null +++ b/config.example.yml @@ -0,0 +1,7 @@ +appId: 6 +appHash: eb06d4abfb49dc3eeb1aeb98ae0f581e +botToken: 1234567890:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +target: "@aiexz" +targets: + - /tmp/test + - /tmp/test2 \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c4f8370 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module telebackup + +go 1.21 + +require ( + github.com/amarnathcjd/gogram v0.0.0-20240120121202-00e0b33d4246 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/pkg/errors v0.9.1 // indirect + golang.org/x/net v0.20.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f87c583 --- /dev/null +++ b/go.sum @@ -0,0 +1,14 @@ +github.com/amarnathcjd/gogram v0.0.0-20240108060951-7677938237da h1:jBip+YR/VGALL+asJUu4bADfWBZ/sijQVBpDukBepRk= +github.com/amarnathcjd/gogram v0.0.0-20240108060951-7677938237da/go.mod h1:rFhZ9/ddkh6746YV1gL03f9myM4c5JYo0+bWIPRu4EU= +github.com/amarnathcjd/gogram v0.0.0-20240120121202-00e0b33d4246 h1:acIEBEb0GPPDi5R2DmfL1mcVgohkN4zp+JUkL6zU4JY= +github.com/amarnathcjd/gogram v0.0.0-20240120121202-00e0b33d4246/go.mod h1:rFhZ9/ddkh6746YV1gL03f9myM4c5JYo0+bWIPRu4EU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/compress/compress.go b/internal/compress/compress.go new file mode 100644 index 0000000..9efcfa6 --- /dev/null +++ b/internal/compress/compress.go @@ -0,0 +1,72 @@ +package compress + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" + "os" + "path" + "path/filepath" +) + +// CompressPath compresses the given path to the given writer using tar and gzip +func CompressPath(targetPath string, buf io.Writer) error { + // check if targetPath is directory + _, err := os.Stat(targetPath) + //get parent directory of targetPath + baseDir := path.Dir(targetPath) + + if err != nil { + return err + } + // taken from https://gist.github.com/mimoo/25fc9716e0f1353791f5908f94d6e726 + zr := gzip.NewWriter(buf) + tw := tar.NewWriter(zr) + _ = filepath.Walk(targetPath, func(file string, fi os.FileInfo, err error) error { + // generate tar header + header, err := tar.FileInfoHeader(fi, file) + if err != nil { + fmt.Println(err) + return err + } + + relPath, err := filepath.Rel(baseDir, file) + if err != nil { + fmt.Println(err) + return err + } + + header.Name = relPath + + // write header + if err := tw.WriteHeader(header); err != nil { + fmt.Println(err) + return err + } + // if not a dir, write file content + if !fi.IsDir() { + data, err := os.Open(file) + if err != nil { + fmt.Println(err) + return err + } + if _, err := io.Copy(tw, data); err != nil { + fmt.Println(err) + return err + } + } + return nil + }) + if err := tw.Close(); err != nil { + fmt.Println(err) + return err + } + // produce gzip + if err := zr.Close(); err != nil { + fmt.Println(err) + return err + } + return nil + +} diff --git a/internal/compress/compress_test.go b/internal/compress/compress_test.go new file mode 100644 index 0000000..7c9d299 --- /dev/null +++ b/internal/compress/compress_test.go @@ -0,0 +1,40 @@ +package compress + +import ( + "os" + "os/exec" + "path" + "testing" +) + +func TestCompressPath(t *testing.T) { + tarDir := t.TempDir() + tempDir := t.TempDir() + os.WriteFile(tempDir+"/test1.txt", []byte("test"), 0644) + testBuf, err := os.OpenFile(tarDir+"/test1.tar.gz", os.O_CREATE|os.O_WRONLY, 0644) + err = CompressPath(tempDir+"/test1.txt", testBuf) + if err != nil { + t.Fatal(err) + } + testBuf.Close() + cmd := exec.Command("tar", "-xvf", tarDir+"/test1.tar.gz", "-C", tarDir) + cmd.Run() + if _, err := os.Stat(tarDir + "/test1.txt"); os.IsNotExist(err) { + t.Fatal(err) + } + os.Remove(tarDir + "/test1.txt") + os.WriteFile(tempDir+"/test2.txt", []byte("test2"), 0644) + testBuf, err = os.OpenFile(tarDir+"/test2.tar.gz", os.O_CREATE|os.O_WRONLY, 0644) + err = CompressPath(tempDir, testBuf) + if err != nil { + t.Fatal(err) + } + testBuf.Close() + exec.Command("tar", "-xvf", tarDir+"/test2.tar.gz", "-C", tarDir).Run() + if _, err := os.Stat(tarDir + "/" + path.Base(tempDir) + "/test1.txt"); os.IsNotExist(err) { + t.Fatal(err) + } + if _, err := os.Stat(tarDir + "/" + path.Base(tempDir) + "/test2.txt"); os.IsNotExist(err) { + t.Fatal(err) + } +} diff --git a/internal/config/fields.go b/internal/config/fields.go new file mode 100644 index 0000000..c479700 --- /dev/null +++ b/internal/config/fields.go @@ -0,0 +1,15 @@ +package config + +// Config for the whole application +type Config struct { + // Telegram API ID credentials + AppID int32 `yaml:"appId"` + // Telegram API Hash credentials + AppHash string `yaml:"appHash"` + // Telegram Bot Token + BotToken string `yaml:"botToken"` + // Telegram user to send messages to + Target string `yaml:"target"` + // Optional: Mapping of paths and topics + Targets []string `yaml:"targets"` +} diff --git a/internal/config/parser.go b/internal/config/parser.go new file mode 100644 index 0000000..0e2aed6 --- /dev/null +++ b/internal/config/parser.go @@ -0,0 +1,13 @@ +package config + +import ( + "gopkg.in/yaml.v3" +) + +func ParseConfig(data []byte) (*Config, error) { + config := &Config{} + if err := yaml.Unmarshal(data, config); err != nil { + return nil, err + } + return config, nil +} diff --git a/internal/config/parser_test.go b/internal/config/parser_test.go new file mode 100644 index 0000000..3f09cd2 --- /dev/null +++ b/internal/config/parser_test.go @@ -0,0 +1,39 @@ +package config + +import "testing" + +func TestParseConfig(t *testing.T) { + data := []byte(`appId: 1 +appHash: 2 +botToken: 3 +target: "@test" +targets: + - /test + - /test2 +`) + config, err := ParseConfig(data) + if err != nil { + t.Error(err) + } + if config.AppID != 1 { + t.Error("ApiID not parsed correctly") + } + if config.AppHash != "2" { + t.Error("ApiHash not parsed correctly") + } + if config.BotToken != "3" { + t.Error("BotToken not parsed correctly") + } + if config.Target != "@test" { + t.Error("ChatID not parsed correctly") + } + if len(config.Targets) != 2 { + t.Error("Targets not parsed correctly") + } + if config.Targets[0] != "/test" { + t.Error("Topic not parsed correctly") + } + if config.Targets[1] != "/test2" { + t.Error("Topic not parsed correctly") + } +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..f739fcc --- /dev/null +++ b/readme.md @@ -0,0 +1,56 @@ +# Telebackup + +## What is Telebackup? +Telebackup is a simple backup tool for Telegram. It allows you to back up your local files to Telegram. It uses tar & gzip to compress the files and then sends them to Telegram chat + +## Features +- Supports multiple files/directories +- File upload up to 2GB + +## Installation +> [!NOTE] +> Releases will be available soon + +### Build from source +```bash +git clone https://github.com/aiexz/telebackup.git +cd telebackup +go build cmd/telebackup/main.go +``` + +## Usage +1. Create a Telegram bot using [BotFather](https://t.me/botfather) and get bot token +2. Get APP ID & API Hash from [my.telegram.org](https://my.telegram.org) or use provided in example +3. Edit `config.example.yml` and rename it to `config.yml` +4. Run `./telebackup` or `./telebackup --config /path/to/config.yml` + +## Configuration +```yaml +appId: 6 # Telegram APP ID +appHash: eb06d4abfb49dc3eeb1aeb98ae0f581e # Telegram API Hash +botToken: 123:AAA # Telegram Bot Token +target: "@aiexz" # Telegram chat/channel username +targets: + - /tmp/test # List of files/directories to backup + - /tmp/test2 +``` + +## Roadmap +- [ ] Make automated releases with GitHub Actions +- [ ] Handle files larger than 2GB +- [ ] Support for forums (chats with topics) +- [ ] Support for usage without username, just chat ID +- [ ] Encryption/password protection +- [ ] Signing backups + +## Contributing +All contributions are welcome. Feel free to open an issue or a pull request + +## Awesome libraries used +- [gogram](https://github.com/AmarnathCJD/gogram) - Awesome Telegram API library + +## Contact +- Telegram: [@aiexz](https://t.me/aiexz) + +## License +[MIT](LICENSE)