├── .gitignore ├── README.md ├── bin └── sync.go ├── main.go ├── routes └── router.go ├── storage └── .echoed ├── typecho ├── models │ ├── db.go │ └── plugin.go ├── plugin_parser.go └── ziputil │ └── zip.go └── views └── index.html /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | .env 4 | working 5 | packages 6 | echoed 7 | storage/packages.json -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Echoed 2 | ========================== 3 | 4 | Typecho应用商店服务端,也就是之前的typecho-app-store的升级版 5 | 6 | [](https://travis-ci.org/chekun/echoed) 7 | 8 | ## 实现原理 9 | 10 | 目前Typecho的插件和主题集中在下面的两个仓库 11 | 12 | - https://github.com/typecho/plugins 13 | - https://github.com/typecho/themes 14 | 15 | 本服务解析这个仓库插件/主题文件的Meta信息入库打包,提供展示和下载! 16 | 17 | ## 参与开发 18 | 19 | > 待完善 -------------------------------------------------------------------------------- /bin/sync.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io/ioutil" 5 | "log" 6 | "os" 7 | "os/exec" 8 | "strings" 9 | 10 | "github.com/chekun/echoed/typecho" 11 | "github.com/chekun/echoed/typecho/models" 12 | "github.com/joho/godotenv" 13 | ) 14 | 15 | func runCommand(cmd *exec.Cmd) { 16 | stdout, err := cmd.CombinedOutput() 17 | if err != nil { 18 | log.Fatal(err.Error()) 19 | } 20 | log.Printf("%s\n", string(stdout)) 21 | } 22 | 23 | func gitWork(repositoryPath, repository string) string { 24 | os.Chdir(repositoryPath) 25 | 26 | repoFolder := strings.Replace(repository, "/", "-", -1) 27 | 28 | if _, err := os.Stat(repoFolder); os.IsNotExist(err) { 29 | //文件夹不存在,全新clone 30 | gitPath := "https://github.com/" + repository + ".git" 31 | log.Println("cloning", gitPath) 32 | cmd := exec.Command("git", "clone", gitPath, repoFolder) 33 | runCommand(cmd) 34 | //处理submodule 35 | os.Chdir(repoFolder) 36 | log.Println("git submodule update --init") 37 | cmd = exec.Command("git", "submodule", "update", "--init") 38 | runCommand(cmd) 39 | os.Chdir("../") 40 | } 41 | 42 | os.Chdir(repoFolder) 43 | 44 | log.Println("git stash") 45 | cmd := exec.Command("git", "stash") 46 | runCommand(cmd) 47 | log.Println("git fetch origin") 48 | cmd = exec.Command("git", "fetch", "origin") 49 | runCommand(cmd) 50 | log.Println("git merge origin/master") 51 | cmd = exec.Command("git", "merge", "origin/master") 52 | runCommand(cmd) 53 | log.Println("git submodule udpate --init") 54 | cmd = exec.Command("git", "submodule", "update", "--init") 55 | runCommand(cmd) 56 | log.Println("git submodule foreach git pull origin master") 57 | cmd = exec.Command("git", "submodule", "foreach", "git", "pull", "origin", "master") 58 | runCommand(cmd) 59 | os.Chdir("../") 60 | 61 | return repoFolder 62 | } 63 | 64 | func main() { 65 | 66 | binPath, _ := os.Getwd() 67 | 68 | os.Setenv("ROOT_PATH", binPath+"/../") 69 | 70 | err := godotenv.Load("../.env") 71 | if err != nil { 72 | log.Fatal("Error loading .env file") 73 | } 74 | 75 | models.NewDb() 76 | 77 | repositoryPath := os.Getenv("WORKING_DIRECTORY") 78 | 79 | //插件处理 80 | pluginRepository := os.Getenv("PLUGIN_REPOSITORY") 81 | 82 | repoFolder := gitWork(repositoryPath, pluginRepository) 83 | 84 | files, _ := ioutil.ReadDir(repoFolder) 85 | for _, file := range files { 86 | fileName := file.Name() 87 | if fileName == ".gitignore" || fileName == ".git" || fileName == ".gitattributes" || fileName == ".gitmodules" { 88 | continue 89 | } 90 | if file.IsDir() { 91 | //插件解析Plugin.php中的信息,然后打包待下载 92 | plugin := typecho.Parse(repoFolder+"/"+fileName+"/Plugin.php", fileName, repoFolder, true) 93 | if plugin.Package != "" { 94 | models.UpdatePlugin(&plugin) 95 | 96 | } 97 | continue 98 | } 99 | } 100 | 101 | //主题处理 102 | themeRepository := os.Getenv("THEME_REPOSITORY") 103 | repoFolder = gitWork(repositoryPath, themeRepository) 104 | 105 | files, _ = ioutil.ReadDir(repoFolder) 106 | for _, file := range files { 107 | fileName := file.Name() 108 | if fileName == ".gitignore" || fileName == ".git" || fileName == ".gitattributes" || fileName == ".gitmodules" { 109 | continue 110 | } 111 | if file.IsDir() { 112 | //主题解析index中的信息,然后打包待下载 113 | plugin := typecho.Parse(repoFolder+"/"+fileName+"/index.php", fileName, repoFolder, true) 114 | plugin.Type = 1 115 | if plugin.Name != "" { 116 | models.UpdatePlugin(&plugin) 117 | } 118 | continue 119 | } 120 | } 121 | 122 | json, err := models.ToJson() 123 | if err != nil { 124 | log.Println("write json failed") 125 | } 126 | ioutil.WriteFile(binPath+"/../storage/packages.json", json, 0755) 127 | 128 | log.Println("Sync Succeeded!") 129 | } 130 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/chekun/echoed/routes" 8 | "github.com/joho/godotenv" 9 | "github.com/kataras/iris/adaptors/httprouter" 10 | "github.com/kataras/iris/adaptors/view" 11 | "gopkg.in/kataras/iris.v6" 12 | ) 13 | 14 | func main() { 15 | 16 | err := godotenv.Load() 17 | if err != nil { 18 | log.Fatal("Error loading .env file") 19 | } 20 | 21 | app := iris.New() 22 | app.Adapt( 23 | iris.DevLogger(), 24 | httprouter.New(), 25 | view.HTML("./views", ".html").Reload(os.Getenv("DEBUG") == "true"), 26 | ) 27 | 28 | routes.InitRouters(app) 29 | 30 | app.Listen(":6300") 31 | 32 | } 33 | -------------------------------------------------------------------------------- /routes/router.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "io/ioutil" 5 | "strings" 6 | 7 | "github.com/chekun/echoed/typecho/models" 8 | 9 | "fmt" 10 | "os" 11 | 12 | "gopkg.in/kataras/iris.v6" 13 | ) 14 | 15 | const ECHOED_VERSION = "1.0.0-beta1" 16 | 17 | func InitRouters(app *iris.Framework) { 18 | app.Get("/", home) 19 | app.Get("/packages.json", packages) 20 | app.Get("/themes/:name/download/:version", downloadTheme) 21 | app.Get("/plugins/:name/download/:version", downloadPlugin) 22 | } 23 | 24 | func home(ctx *iris.Context) { 25 | ctx.Render("index.html", iris.Map{"v": ECHOED_VERSION}) 26 | } 27 | 28 | func packages(ctx *iris.Context) { 29 | models.NewDb() 30 | f, _ := ioutil.ReadFile("storage/packages.json") 31 | ctx.Write(f) 32 | } 33 | 34 | func downloadTheme(ctx *iris.Context) { 35 | path, name := downloadPackage("themes", ctx) 36 | ctx.SendFile(path, name) 37 | } 38 | 39 | func downloadPlugin(ctx *iris.Context) { 40 | path, name := downloadPackage("plugins", ctx) 41 | ctx.SendFile(path, name) 42 | } 43 | 44 | func downloadPackage(pType string, ctx *iris.Context) (string, string) { 45 | path := fmt.Sprintf("%s/%s/%s/%s-%s", 46 | os.Getenv("PACKAGES_DIRECTORY"), 47 | pType, 48 | ctx.Param("name"), 49 | ctx.Param("name"), 50 | ctx.Param("version")) 51 | models.NewDb() 52 | models.CountVersionDownload( 53 | ctx.Param("name"), 54 | strings.Replace(ctx.Param("version"), ".zip", "", -1), 55 | ) 56 | return path, ctx.Param("name") + "-" + ctx.Param("version") 57 | } 58 | -------------------------------------------------------------------------------- /storage/.echoed: -------------------------------------------------------------------------------- 1 | _____ _ _ __ _ 2 | /__ \_ _ _ __ ___ ___| |__ ___ /_\ _ __ _ __ / _\ |_ ___ _ __ ___ 3 | / /\/ | | | '_ \ / _ \/ __| '_ \ / _ \ //_\\| '_ \| '_ \ \ \| __/ _ \| '__/ _ \ 4 | / / | |_| | |_) | __/ (__| | | | (_) | / _ \ |_) | |_) | _\ \ || (_) | | | __/ 5 | \/ \__, | .__/ \___|\___|_| |_|\___/ \_/ \_/ .__/| .__/ \__/\__\___/|_| \___| 6 | |___/|_| |_| |_| 7 | 8 | The zip file is compressed by Echoed(as known as Typecho App Store). 9 | More information please visit https://typecho.chekun.me/. -------------------------------------------------------------------------------- /typecho/models/db.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | _ "github.com/go-sql-driver/mysql" 5 | "github.com/jinzhu/gorm" 6 | _ "github.com/jinzhu/gorm/dialects/mysql" 7 | "os" 8 | ) 9 | 10 | var db *gorm.DB 11 | 12 | func init() { 13 | db = nil 14 | } 15 | 16 | func NewDb() *gorm.DB { 17 | if db != nil { 18 | return db 19 | } 20 | var err error 21 | db, err = gorm.Open(os.Getenv("DB_DRIVER"), os.Getenv("DB_DSN")) 22 | if err != nil { 23 | panic(err) 24 | } 25 | return db 26 | } 27 | -------------------------------------------------------------------------------- /typecho/models/plugin.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "strings" 8 | "time" 9 | 10 | "github.com/chekun/echoed/typecho" 11 | "github.com/chekun/echoed/typecho/ziputil" 12 | ) 13 | 14 | type Plugin struct { 15 | Id int 16 | Package string 17 | Type int 18 | CreatedAt time.Time 19 | UpdatedAt time.Time 20 | Version []*Version 21 | } 22 | 23 | func (p *Plugin) TableName() string { 24 | return "plugins" 25 | } 26 | 27 | type Version struct { 28 | Id int 29 | Name string 30 | PluginId int 31 | Plugin *Plugin `gorm:"ForeignKey:PluginId"` 32 | Author string 33 | Version string 34 | Description string 35 | Link string 36 | Require string 37 | Readme string 38 | Downloads int 39 | CreatedAt time.Time 40 | UpdatedAt time.Time 41 | } 42 | 43 | func (v *Version) TableName() string { 44 | return "plugin_versions" 45 | } 46 | 47 | func UpdatePlugin(p *typecho.Plugin) { 48 | if IsPluginExisted(p.Package) { 49 | AppendVersion(p) 50 | } else { 51 | AddNewPlugin(p) 52 | } 53 | } 54 | 55 | func GetAllPlugins() []*Plugin { 56 | var plugins []*Plugin 57 | db.Preload("Version").Find(&plugins) 58 | return plugins 59 | } 60 | 61 | func AddNewPlugin(p *typecho.Plugin) { 62 | plugin := new(Plugin) 63 | plugin.Package = p.Package 64 | plugin.Type = p.Type 65 | if db.Create(&plugin); !db.NewRecord(plugin) { 66 | AppendVersion(p) 67 | } 68 | } 69 | 70 | func cleanString(str string) string { 71 | str = strings.Replace(str, " ", "", -1) 72 | str = strings.Replace(str, "\n", "", -1) 73 | str = strings.Replace(str, "\r", "", -1) 74 | str = strings.Replace(str, "\t", "", -1) 75 | return str 76 | } 77 | 78 | func AppendVersion(p *typecho.Plugin) { 79 | plugin := FindPlugin(p.Package) 80 | if !IsVersionExisted(plugin.Id, p.Version) { 81 | version := new(Version) 82 | version.Plugin = plugin 83 | version.Author = cleanString(p.Author) 84 | version.Name = cleanString(p.Name) 85 | version.Description = cleanString(p.Description) 86 | version.Link = cleanString(p.Link) 87 | version.Require = cleanString(p.Require) 88 | version.Version = cleanString(p.Version) 89 | version.Readme = p.Readme 90 | if db.Create(&version); !db.NewRecord(version) { 91 | zipPlugin(cleanString(p.Package), cleanString(p.Version), p.Source, p.Type) 92 | db.Model(&plugin).Update("updated_at", time.Now()) 93 | } 94 | } 95 | } 96 | 97 | func FindPlugin(name string) *Plugin { 98 | plugin := new(Plugin) 99 | db.Where("package = ?", name).First(&plugin) 100 | return plugin 101 | } 102 | 103 | func IsPluginExisted(name string) bool { 104 | plugin := new(Plugin) 105 | var count int 106 | db.Model(plugin).Where("package = ?", name).Count(&count) 107 | return count > 0 108 | } 109 | 110 | func IsVersionExisted(pluginId int, v string) bool { 111 | version := new(Version) 112 | var count int 113 | db.Model(version).Where("plugin_id = ?", pluginId).Count(&count) 114 | return count > 0 115 | } 116 | 117 | func CountVersionDownload(name, version string) { 118 | versionObj := new(Version) 119 | if err := db.Model(versionObj).Where("`name` = ? AND `version` = ?", name, version).First(&versionObj).Error; err == nil { 120 | versionObj.Downloads++ 121 | db.Save(&versionObj) 122 | } 123 | } 124 | 125 | func zipPlugin(name, version, repo string, pType int) { 126 | 127 | storagePath := os.Getenv("PACKAGES_DIRECTORY") 128 | repoPath := os.Getenv("WORKING_DIRECTORY") 129 | 130 | zipFile := "" 131 | if pType == 0 { 132 | zipFile = storagePath + "/plugins/" + name + "/" + name + "-" + version + ".zip" 133 | } else { 134 | zipFile = storagePath + "/themes/" + name + "/" + name + "-" + version + ".zip" 135 | } 136 | directory := repoPath + "/" + repo + "/" + name + "/" 137 | 138 | err := ziputil.Zip(zipFile, directory) 139 | if err != nil { 140 | fmt.Println(name, version, err) 141 | } 142 | } 143 | 144 | type VersionJson struct { 145 | Version string `json:"version"` 146 | Author string `json:"author"` 147 | Description string `json:"description"` 148 | Link string `json:"link"` 149 | Require string `json:"require"` 150 | CreatedAt time.Time `json:"created_at"` 151 | } 152 | 153 | type PluginJson struct { 154 | Name string `json:"name"` 155 | Type string `json:"type"` 156 | Versions []VersionJson `json:"versions"` 157 | } 158 | 159 | type PluginsJson struct { 160 | Packages []PluginJson `json:"packages"` 161 | } 162 | 163 | func ToJson() ([]byte, error) { 164 | var pluginsJson PluginsJson 165 | plugins := GetAllPlugins() 166 | for _, plugin := range plugins { 167 | var pluginJson PluginJson 168 | pluginJson.Name = plugin.Package 169 | if plugin.Type == 0 { 170 | pluginJson.Type = "plugin" 171 | } else { 172 | pluginJson.Type = "theme" 173 | } 174 | 175 | for _, version := range plugin.Version { 176 | versionJson := VersionJson{} 177 | versionJson.Version = version.Version 178 | versionJson.Author = version.Author 179 | versionJson.CreatedAt = version.CreatedAt 180 | versionJson.Description = version.Description 181 | versionJson.Link = version.Link 182 | versionJson.Require = version.Require 183 | 184 | pluginJson.Versions = append(pluginJson.Versions, versionJson) 185 | } 186 | 187 | pluginsJson.Packages = append(pluginsJson.Packages, pluginJson) 188 | } 189 | 190 | bytes, err := json.Marshal(pluginsJson) 191 | if err != nil { 192 | return []byte{}, err 193 | } 194 | return bytes, nil 195 | } 196 | -------------------------------------------------------------------------------- /typecho/plugin_parser.go: -------------------------------------------------------------------------------- 1 | package typecho 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "path/filepath" 7 | "regexp" 8 | "strings" 9 | ) 10 | 11 | type Plugin struct { 12 | Package string 13 | Name string 14 | Description string 15 | Author string 16 | Version string 17 | Link string 18 | Require string 19 | Source string 20 | Readme string 21 | Type int 22 | } 23 | 24 | func Parse(path, packageName, repo string, retry bool) Plugin { 25 | 26 | plugin := Plugin{ 27 | "", 28 | "", 29 | "", 30 | "", 31 | "", 32 | "", 33 | "*", 34 | "", 35 | "", 36 | 0, 37 | } 38 | 39 | pluginContent, err := ioutil.ReadFile(path) 40 | if err != nil { 41 | if retry { 42 | os.Rename(strings.Replace(path, "Plugin.php", "plugin.php", 1), path) 43 | plugin = Parse(path, packageName, repo, false) 44 | } 45 | return plugin 46 | } 47 | 48 | reString := `/\*\*([\s\S]*?)\*/` 49 | re, _ := regexp.Compile(reString) 50 | matches := re.FindAllString(string(pluginContent), -1) 51 | wantedMatch := "" 52 | 53 | for _, match := range matches { 54 | if strings.Contains(match, "@package") { 55 | wantedMatch = match 56 | break 57 | } 58 | } 59 | 60 | lines := strings.Split(wantedMatch, "\n") 61 | 62 | for _, line := range lines { 63 | line = strings.Replace(line, "/**", "", -1) 64 | line = strings.Replace(line, "*/", "", 1) 65 | line = strings.Replace(line, "*", "", 1) 66 | line = strings.Trim(line, " ") 67 | if line == "" { 68 | continue 69 | } 70 | if strings.HasPrefix(line, "@package") { 71 | plugin.Name = strings.Trim(strings.Replace(line, "@package", "", 1), " ") 72 | continue 73 | } 74 | if strings.HasPrefix(line, "@author") { 75 | plugin.Author = strings.Trim(strings.Replace(line, "@author", "", 1), " ") 76 | continue 77 | } 78 | if strings.HasPrefix(line, "@version") { 79 | plugin.Version = strings.Trim(strings.Replace(line, "@version", "", 1), " ") 80 | continue 81 | } 82 | if strings.HasPrefix(line, "@link") { 83 | plugin.Link = strings.Trim(strings.Replace(line, "@link", "", 1), " ") 84 | continue 85 | } 86 | if strings.HasPrefix(line, "@dependence") { 87 | plugin.Require = strings.Trim(strings.Replace(line, "@dependence", "", 1), " ") 88 | continue 89 | } 90 | if !strings.HasPrefix(line, "@") { 91 | plugin.Description = plugin.Description + line 92 | } 93 | 94 | } 95 | plugin.Source = repo 96 | plugin.Package = packageName 97 | 98 | //读取readme.md 99 | 100 | pluginPath := filepath.Dir(path) 101 | readmeName := "" 102 | if _, err = os.Stat(pluginPath + "/README.md"); err == nil { 103 | readmeName = "README.md" 104 | } else if _, err = os.Stat(pluginPath + "/Readme.md"); err == nil { 105 | readmeName = "Readme.md" 106 | } else if _, err = os.Stat(pluginPath + "/readme.md"); err == nil { 107 | readmeName = "readme.md" 108 | } else if _, err = os.Stat(pluginPath + "/README"); err == nil { 109 | readmeName = "README" 110 | } 111 | 112 | if readme, err := ioutil.ReadFile(pluginPath + "/" + readmeName); err == nil { 113 | plugin.Readme = string(readme) 114 | } 115 | 116 | if !retry { 117 | os.Remove(path) 118 | } 119 | return plugin 120 | } 121 | -------------------------------------------------------------------------------- /typecho/ziputil/zip.go: -------------------------------------------------------------------------------- 1 | package ziputil 2 | 3 | //forked from https://github.com/yanolab/ziputil 4 | import ( 5 | "archive/zip" 6 | "io" 7 | "io/ioutil" 8 | "os" 9 | "path/filepath" 10 | ) 11 | 12 | type ZipFile struct { 13 | writer *zip.Writer 14 | } 15 | 16 | func Create(filename string) (*ZipFile, error) { 17 | file, err := os.Create(filename) 18 | if err != nil { 19 | return nil, err 20 | } 21 | return &ZipFile{writer: zip.NewWriter(file)}, nil 22 | } 23 | 24 | func (z *ZipFile) Close() error { 25 | return z.writer.Close() 26 | } 27 | 28 | func (z *ZipFile) AddEntryN(path string, names ...string) error { 29 | for _, name := range names { 30 | zipPath := filepath.Join(path, name) 31 | err := z.AddEntry(zipPath, name) 32 | if err != nil { 33 | return err 34 | } 35 | } 36 | return nil 37 | } 38 | 39 | func (z *ZipFile) AddEntry(path, name string) error { 40 | fi, err := os.Stat(name) 41 | if err != nil { 42 | return err 43 | } 44 | 45 | fh, err := zip.FileInfoHeader(fi) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | if fi.IsDir() { 51 | if len(path) > 0 { 52 | path = path + "/" 53 | } else { 54 | path = "./" 55 | } 56 | } 57 | 58 | fh.Name = path 59 | 60 | entry, err := z.writer.CreateHeader(fh) 61 | if err != nil { 62 | return err 63 | } 64 | 65 | if fi.IsDir() { 66 | return nil 67 | } 68 | 69 | file, err := os.Open(name) 70 | if err != nil { 71 | return err 72 | } 73 | defer file.Close() 74 | 75 | _, err = io.Copy(entry, file) 76 | 77 | return err 78 | } 79 | 80 | func (z *ZipFile) AddDirectoryN(path string, names ...string) error { 81 | for _, name := range names { 82 | err := z.AddDirectory(path, name) 83 | if err != nil { 84 | return err 85 | } 86 | } 87 | return nil 88 | } 89 | 90 | func (z *ZipFile) AddDirectory(path, dirName string) error { 91 | z.AddEntry(path, dirName) 92 | 93 | files, err := ioutil.ReadDir(dirName) 94 | if err != nil { 95 | return err 96 | } 97 | 98 | for _, file := range files { 99 | localPath := filepath.Join(dirName, file.Name()) 100 | zipPath := filepath.Join(path, file.Name()) 101 | 102 | err = nil 103 | if file.IsDir() { 104 | err = z.AddDirectory(zipPath, localPath) 105 | } else { 106 | err = z.AddEntry(zipPath, localPath) 107 | } 108 | if err != nil { 109 | return err 110 | } 111 | } 112 | 113 | return nil 114 | } 115 | 116 | func Zip(zipFile, directory string) error { 117 | os.Mkdir(filepath.Dir(zipFile), 0755) 118 | 119 | zip, err := Create(zipFile) 120 | if err != nil { 121 | return err 122 | } 123 | err = zip.AddDirectory("./", directory) 124 | if err != nil { 125 | return err 126 | } 127 | echodFilePath := os.Getenv("ROOT_PATH") 128 | err = zip.AddEntry("./.echoed", echodFilePath+"storage/.echoed") 129 | if err != nil { 130 | return err 131 | } 132 | err = zip.Close() 133 | if err != nil { 134 | return err 135 | } 136 | return nil 137 | } 138 | -------------------------------------------------------------------------------- /views/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 |