Compare commits

...

1 Commits

Author SHA1 Message Date
Jonas Franz 8011636a53
Replace web by repo sync 5 years ago
  1. 42
      auth/client.go
  2. 16
      cmd/run.go
  3. 50
      config/config.go
  4. 2
      go.mod
  5. 2
      go.sum
  6. 21
      jobs/issue_sync.go
  7. 51
      jobs/job.go
  8. 56
      jobs/repository_sync.go
  9. 63
      loader/gitea.go
  10. 22
      loader/loader.go
  11. 27
      loader/models.go
  12. 2
      models/models.go
  13. 30
      models/repository.go
  14. 83
      web/auth.go
  15. 71
      web/dashboard.go
  16. 44
      web/router.go

42
auth/client.go

@ -1,42 +0,0 @@ @@ -1,42 +0,0 @@
// staletea
// Copyright (C) 2019 Jonas Franz
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.

// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

package auth

import (
"fmt"
"gitea.com/jonasfranz/staletea/config"
"golang.org/x/oauth2"
)

func endpoint() oauth2.Endpoint {
// Endpoint is Google's OAuth 2.0 endpoint.
return oauth2.Endpoint{
AuthURL: fmt.Sprintf("%s/login/oauth/authorize", config.GiteaURL.Get().(string)),
TokenURL: fmt.Sprintf("%s/login/oauth/access_token", config.GiteaURL.Get().(string)),
AuthStyle: oauth2.AuthStyleInParams,
}
}

// Config returns an oauth2 config
func Config() *oauth2.Config {
return &oauth2.Config{
ClientID: config.GiteaClientID.Get().(string),
ClientSecret: config.GiteaClientSecret.Get().(string),
Endpoint: endpoint(),
RedirectURL: fmt.Sprintf("%s/callback", config.BaseURL.Get().(string)),
}
}

16
cmd/run.go

@ -17,7 +17,8 @@ @@ -17,7 +17,8 @@
package cmd

import (
"gitea.com/jonasfranz/staletea/web"
"gitea.com/jonasfranz/staletea/config"
"gitea.com/jonasfranz/staletea/jobs"
"github.com/urfave/cli"
)

@ -25,14 +26,13 @@ import ( @@ -25,14 +26,13 @@ import (
var CmdRun = cli.Command{
Action: run,
Name: "run",
Flags: []cli.Flag{
cli.StringFlag{
Name: "address",
Value: ":3030",
},
},
Flags: []cli.Flag{},
}

func run(ctx *cli.Context) error {
return web.StartServer(ctx.String("address"))
println("Startup!")
if err := config.SetupConfig(); err != nil {
return err
}
return jobs.StartCronJobs()
}

50
config/config.go

@ -41,56 +41,40 @@ func (k Key) setDefault(defaultValue interface{}) { @@ -41,56 +41,40 @@ func (k Key) setDefault(defaultValue interface{}) {

// This block contains all config keys
const (
DaysUntilStale Key = "daysUntilStale"
DaysUntilClose Key = "daysUntilClose"
LabelBlacklist Key = "onlyLabels"
LabelWhitelist Key = "exemptLabels"
StaleLabel Key = "staleLabel"
GiteaURL Key = "gitea_url"
BotAccessToken Key = "gitea_bot_access_token"

MarkComment Key = "markComment"
UnmarkComment Key = "unmarkComment"
CloseComment Key = "closeComment"

TypeBlacklist Key = "only"

BaseURL Key = "host"
SessionSecret Key = "session_secret"
DatabaseDriver Key = "database_driver"
DatabaseURI Key = "database_uri"

GiteaURL Key = "gitea.url"
GiteaClientID Key = "gitea.client_id"
GiteaClientSecret Key = "gitea.client_secret"
RepositoryScanInterval Key = "repository_scan_interval"
IssueScanInterval Key = "issue_scan_interval"

SessionSecret Key = "session_secret"
DatabasePath Key = "./database_path"
RepositoryConfigFileName Key = "repository_config_file_name"
)

// SetupConfig fills all default values, reads the config and/or writes the default config.
func SetupConfig() error {
DaysUntilStale.setDefault(60)
DaysUntilClose.setDefault(7)
LabelBlacklist.setDefault([]string{})
LabelWhitelist.setDefault([]string{})
StaleLabel.setDefault("wontfix")

MarkComment.setDefault(`This issue has been automatically marked as stale because it has not had
recent activity. It will be closed in %d days if no further activity occurs. Thank you
for your contributions.`)
UnmarkComment.setDefault("")
CloseComment.setDefault("This issue was closed automatically since it was marked as stale and had no recent activity.")
TypeBlacklist.setDefault([]string{})
BaseURL.setDefault("http://localhost")
GiteaURL.setDefault("https://gitea.com")
GiteaClientID.setDefault("")
GiteaClientSecret.setDefault("")
BotAccessToken.setDefault("YOUR_BOT_TOKEN")

secret, err := utils.NewSecret(32)
if err != nil {
return err
}
SessionSecret.setDefault(secret)
DatabasePath.setDefault("staletea.db")
DatabaseDriver.setDefault("sqlite3")
DatabaseURI.setDefault("staletea.db")

RepositoryScanInterval.setDefault("@every 30s")
IssueScanInterval.setDefault("@every 2m")

RepositoryConfigFileName.setDefault(".stale.yml")

viper.SetConfigName("config")
viper.SetConfigType("yml")
viper.AutomaticEnv()
viper.AddConfigPath(".")
if err := viper.SafeWriteConfigAs("config.yml"); err != nil {
if os.IsNotExist(err) {

2
go.mod

@ -12,6 +12,7 @@ require ( @@ -12,6 +12,7 @@ require (
github.com/lib/pq v1.1.0 // indirect
github.com/mattn/go-sqlite3 v1.10.0
github.com/pkg/errors v0.8.1 // indirect
github.com/robfig/cron v1.2.0
github.com/spf13/viper v1.4.0
github.com/urfave/cli v1.20.0
golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5 // indirect
@ -19,4 +20,5 @@ require ( @@ -19,4 +20,5 @@ require (
golang.org/x/oauth2 v0.0.0-20190523182746-aaccbc9213b0
golang.org/x/sys v0.0.0-20190602015325-4c4f7f33c9ed // indirect
golang.org/x/text v0.3.2 // indirect
gopkg.in/yaml.v2 v2.2.2
)

2
go.sum

@ -137,6 +137,8 @@ github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R @@ -137,6 +137,8 @@ github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R
github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
github.com/quasoft/memstore v0.0.0-20180925164028-84a050167438/go.mod h1:wTPjTepVu7uJBYgZ0SdWHQlIas582j6cn2jgk4DDdlg=
github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ=
github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=

21
jobs/issue_sync.go

@ -0,0 +1,21 @@ @@ -0,0 +1,21 @@
// staletea
// Copyright (C) 2020 Jonas Franz
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.

// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

package jobs

func syncIssues() error {
return nil
}

51
jobs/job.go

@ -0,0 +1,51 @@ @@ -0,0 +1,51 @@
// staletea
// Copyright (C) 2020 Jonas Franz
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.

// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

package jobs

import "github.com/robfig/cron"

import "gitea.com/jonasfranz/staletea/config"

type Job struct {
Interval config.Key
Action func() error
}

var registeredCronJobs []Job = []Job{
{Interval: config.RepositoryScanInterval, Action: syncRepositories},
{Interval: config.IssueScanInterval, Action: syncIssues},
}

func handleError(unsafeFunc func() error) func() {
return func() {
err := unsafeFunc()
if err != nil {
panic(err)
}
}
}

func StartCronJobs() error {
c := cron.New()
for _, job := range registeredCronJobs {
if err := c.AddFunc(job.Interval.Get().(string), handleError(job.Action)); err != nil {
return err
}
}
c.Run()
return nil
}

56
jobs/repository_sync.go

@ -0,0 +1,56 @@ @@ -0,0 +1,56 @@
// staletea
// Copyright (C) 2020 Jonas Franz
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.

// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

package jobs

import (
"fmt"
"gitea.com/jonasfranz/staletea/config"
"gitea.com/jonasfranz/staletea/loader"
"gitea.com/jonasfranz/staletea/models"
)

func syncRepositories() error {
savedRepos, err := models.FindAllRepositories()
if err != nil {
return err
}
savedRepoIDs := make(map[int64]*models.Repository)
for _, savedRepo := range savedRepos {
savedRepoIDs[savedRepo.ID] = savedRepo
}
l := loader.NewGiteaLoader(config.GiteaURL.Get().(string), config.BotAccessToken.Get().(string))
loadedRepos, err := l.LoadRepositories()
if err != nil {
return err
}
newRepos := make([]*models.Repository, 0)
for _, loadedRepo := range loadedRepos {
if _, ok := savedRepoIDs[loadedRepo.ID]; !ok {
repoConfig, err := l.DownloadConfig(loadedRepo)
if err != nil {
fmt.Printf("cannot download config for repo %s/%s: %v\n", loadedRepo.Owner, loadedRepo.Name, err)
continue
}
newRepos = append(newRepos, &models.Repository{
ID: loadedRepo.ID,
Config: repoConfig,
Activated: true,
})
}
}
return models.CreateRepositories(newRepos)
}

63
loader/gitea.go

@ -0,0 +1,63 @@ @@ -0,0 +1,63 @@
// staletea
// Copyright (C) 2020 Jonas Franz
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.

// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

package loader

import (
"bytes"
"code.gitea.io/sdk/gitea"
"gitea.com/jonasfranz/staletea/config"
"gopkg.in/yaml.v2"
)

func NewGiteaLoader(url, token string) Loader {
return &GiteaLoader{
client: gitea.NewClient(url, token),
}
}

type GiteaLoader struct {
client *gitea.Client
}

func (l *GiteaLoader) LoadRepositories() ([]*Repository, error) {
repos, err := l.client.ListMyRepos()
if err != nil {
return nil, err
}
convertedRepos := make([]*Repository, len(repos))
for index, repo := range repos {
convertedRepos[index] = &Repository{
ID: repo.ID,
Owner: repo.Owner.UserName,
Name: repo.Name,
ConfigBranch: repo.DefaultBranch,
}
}
return convertedRepos, nil
}

func (l *GiteaLoader) DownloadConfig(repo *Repository) (*RepositoryConfig, error) {
file, err := l.client.GetFile(repo.Owner, repo.Name, repo.ConfigBranch, config.RepositoryConfigFileName.Get().(string))
if err != nil {
return nil, err
}
repoConfig := new(RepositoryConfig)
if err := yaml.NewDecoder(bytes.NewReader(file)).Decode(repoConfig); err != nil {
return nil, err
}
return repoConfig, nil
}

22
loader/loader.go

@ -0,0 +1,22 @@ @@ -0,0 +1,22 @@
// staletea
// Copyright (C) 2020 Jonas Franz
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.

// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

package loader

type Loader interface {
LoadRepositories() ([]*Repository, error)
DownloadConfig(repo *Repository) (*RepositoryConfig, error)
}

27
models/user.go → loader/models.go

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
// staletea
// Copyright (C) 2019 Jonas Franz
// Copyright (C) 2020 Jonas Franz
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
@ -14,26 +14,15 @@ @@ -14,26 +14,15 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

package models
package loader

import (
"code.gitea.io/sdk/gitea"
"context"
"gitea.com/jonasfranz/staletea/auth"
"gitea.com/jonasfranz/staletea/config"
"golang.org/x/oauth2"
)

// User represents a signed in oauth2 gitea user saved in a session
type User struct {
type Repository struct {
ID int64
Username string
Token *oauth2.Token
Owner string
Name string
ConfigBranch string
}

// GiteaClient will return a gitea client with authentication of the user
func (user *User) GiteaClient() *gitea.Client {
client := gitea.NewClient(config.GiteaURL.Get().(string), "")
client.SetHTTPClient(auth.Config().Client(context.Background(), user.Token))
return client
type RepositoryConfig struct {
Enabled bool `yaml:"enabled"`
}

2
models/models.go

@ -30,7 +30,7 @@ var x *xorm.Engine @@ -30,7 +30,7 @@ var x *xorm.Engine
// SetupDatabase will init a sqlite3 database and sync all models
func SetupDatabase() {
var err error
x, err = xorm.NewEngine("sqlite3", config.DatabasePath.Get().(string))
x, err = xorm.NewEngine("sqlite3", config.DatabaseURI.Get().(string))
if err != nil {
panic(err)
}

30
models/repository.go

@ -16,24 +16,36 @@ @@ -16,24 +16,36 @@

package models

import "github.com/go-xorm/xorm"
import (
"gitea.com/jonasfranz/staletea/loader"
"github.com/go-xorm/xorm"
)

// Repository represents a Gitea repository indexed at the local database
type Repository struct {
ID int64 `xorm:"pk"`
UserID int64 `xorm:"index"`
Owner string `xorm:"unique(owner_name)"`
Name string `xorm:"unique(owner_name)"`
Config *loader.RepositoryConfig `xorm:"json"`

Activated bool
}

// FindRepositoriesByUserID returns all repos of an user
func FindRepositoriesByUserID(userID int64) ([]*Repository, error) {
return findRepositoriesByUserID(x, userID)
func FindAllRepositories() ([]*Repository, error) {
return findAllRepositories(x)
}

func findRepositoriesByUserID(e *xorm.Engine, userID int64) ([]*Repository, error) {
func findAllRepositories(e *xorm.Engine) ([]*Repository, error) {
repos := make([]*Repository, 0)
return repos, e.Where("user_id = ?", userID).Find(&repos)
if err := e.Find(&repos); err != nil {
return nil, err
}
return repos, nil
}

func CreateRepositories(repos []*Repository) error {
return createRepositories(x, repos)
}

func createRepositories(e *xorm.Engine, repos []*Repository) error {
_, err := e.Insert(&repos)
return err
}

83
web/auth.go

@ -1,83 +0,0 @@ @@ -1,83 +0,0 @@
// staletea
// Copyright (C) 2019 Jonas Franz
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.

// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

package web

import (
"code.gitea.io/sdk/gitea"
"context"
"gitea.com/jonasfranz/staletea/auth"
"gitea.com/jonasfranz/staletea/config"
"gitea.com/jonasfranz/staletea/models"
"gitea.com/jonasfranz/staletea/utils"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"net/http"
)

func handleLogin(ctx *gin.Context) {
session := sessions.Default(ctx)
state, err := utils.NewSecret(32)
if err != nil {
_ = ctx.AbortWithError(500, err)
return
}
session.Set("state", state)
if err := session.Save(); err != nil {
_ = ctx.AbortWithError(http.StatusInternalServerError, err)
return
}
redirectURL := auth.Config().AuthCodeURL(state)
ctx.Redirect(http.StatusTemporaryRedirect, redirectURL)
}

func handleCallback(ctx *gin.Context) {
session := sessions.Default(ctx)
state := ctx.Query("state")
savedState := session.Get("state")
if savedState == nil {
ctx.String(http.StatusUnauthorized, "Invalid state")
return
}
session.Delete("state")
if parsedState, ok := savedState.(string); !ok || parsedState != state {
ctx.String(http.StatusUnauthorized, "Invalid state")
return
}
token, err := auth.Config().Exchange(ctx, ctx.Query("code"))
if err != nil {
_ = ctx.AbortWithError(http.StatusUnauthorized, err)
return
}
client := gitea.NewClient(config.GiteaURL.Get().(string), "")
client.SetHTTPClient(auth.Config().Client(context.Background(), token))
user, err := client.GetMyUserInfo()
if err != nil {
_ = ctx.AbortWithError(http.StatusUnauthorized, err)
return
}
storedUser := &models.User{
ID: user.ID,
Username: user.UserName,
Token: token,
}
session.Set("user", storedUser)
if err := session.Save(); err != nil {
_ = ctx.AbortWithError(http.StatusInternalServerError, err)
return
}
ctx.Redirect(http.StatusTemporaryRedirect, config.BaseURL.Get().(string))
}

71
web/dashboard.go

@ -1,71 +0,0 @@ @@ -1,71 +0,0 @@
// staletea
// Copyright (C) 2019 Jonas Franz
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.

// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

package web

import (
"fmt"
"gitea.com/jonasfranz/staletea/config"
"gitea.com/jonasfranz/staletea/models"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"net/http"
)

func showDashboard(ctx *gin.Context) {
session := sessions.Default(ctx)
user, ok := session.Get("user").(*models.User)
if !ok || user == nil {
ctx.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/login", config.BaseURL.Get().(string)))
return
}
activatedRepos, err := models.FindRepositoriesByUserID(user.ID)
if err != nil {
_ = ctx.AbortWithError(500, err)
return
}
remoteRepos, err := user.GiteaClient().ListMyRepos()
if err != nil {
_ = ctx.AbortWithError(500, err)
return
}
combinedRepos := make(map[int64]*models.Repository, len(activatedRepos))

for _, repo := range activatedRepos {
combinedRepos[repo.ID] = repo
}

for _, repo := range remoteRepos {
combinedRepos[repo.ID] = &models.Repository{
ID: repo.ID,
UserID: user.ID,
Owner: repo.Owner.UserName,
Name: repo.Name,
Activated: false,
}
}

data := map[string]interface{}{
"repos": combinedRepos,
"user": user,
}

ctx.HTML(200, "dashboard.tmpl", data)
}

func handleActivate(ctx *gin.Context) {

}

44
web/router.go

@ -1,44 +0,0 @@ @@ -1,44 +0,0 @@
// staletea
// Copyright (C) 2019 Jonas Franz
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.

// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

package web

import (
"encoding/gob"
"gitea.com/jonasfranz/staletea/config"
"gitea.com/jonasfranz/staletea/models"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
)

// StartServer will start the webserver at the given addresses
func StartServer(addr ...string) error {
r := gin.Default()
gob.Register(new(models.User))
store := cookie.NewStore([]byte(config.SessionSecret.Get().(string)))
r.Use(sessions.Sessions("sessions", store))
r.LoadHTMLFiles("templates/dashboard.tmpl")
setupRoutes(r)
return r.Run(addr...)
}

func setupRoutes(r *gin.Engine) {
r.GET("/", showDashboard)
r.POST("/", handleActivate)
r.GET("/login", handleLogin)
r.GET("/callback", handleCallback)
}
Loading…
Cancel
Save