Add Media Endpoint support
parent
35770cecc9
commit
222afd3957
193
config.go
193
config.go
|
@ -2,10 +2,8 @@ package main
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/caarlos0/env/v6"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
|
@ -13,9 +11,11 @@ var (
|
|||
IgnoredWebmentionUrls []string
|
||||
SyndicationTargets []SyndicationTarget
|
||||
SelectedStorage Storage
|
||||
SelectedMediaStorage MediaStorage
|
||||
SelectedCdn Cdn
|
||||
SelectedSocials Socials
|
||||
SelectedNotificationServices NotificationServices
|
||||
MediaEndpointUrl string
|
||||
)
|
||||
|
||||
type SyndicationTarget struct {
|
||||
|
@ -23,80 +23,106 @@ type SyndicationTarget struct {
|
|||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func initConfig() {
|
||||
type config struct {
|
||||
BlogUrl string `env:"BLOG_URL,required"`
|
||||
BaseUrl string `env:"BASE_URL,required"`
|
||||
MediaUrl string `env:"MEDIA_URL"`
|
||||
GiteaEndpoint string `env:"GITEA_ENDPOINT"`
|
||||
GiteaToken string `env:"GITEA_TOKEN"`
|
||||
BunnyCdnKey string `env:"BUNNY_CDN_KEY"`
|
||||
BunnyCdnStorageKey string `env:"BUNNY_CDN_STORAGE_KEY"`
|
||||
BunnyCdnStorageName string `env:"BUNNY_CDN_STORAGE_NAME"`
|
||||
MicroblogUrl string `env:"MICROBLOG_URL"`
|
||||
MicroblogToken string `env:"MICROBLOG_TOKEN"`
|
||||
TelegramUserId int64 `env:"TELEGRAM_USER_ID"`
|
||||
TelegramBotToken string `env:"TELEGRAM_BOT_TOKEN"`
|
||||
IgnoredWebmentionUrls []string `env:"WEBMENTION_IGNORED" envSeparator:","`
|
||||
SyndicationTargets []string `env:"SYNDICATION" envSeparator:","`
|
||||
}
|
||||
|
||||
func initConfig() (err error) {
|
||||
cfg := config{}
|
||||
if err := env.Parse(&cfg); err != nil {
|
||||
return errors.New("failed to parse config, probably not all required env vars set")
|
||||
}
|
||||
// Blog URL (required)
|
||||
blogUrl, err := blogUrl()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
BlogUrl = blogUrl
|
||||
BlogUrl = cfg.BlogUrl
|
||||
// Media endpoint
|
||||
MediaEndpointUrl = cfg.BaseUrl + "/media"
|
||||
// Ignored Webmention URLs (optional)
|
||||
ignoredWebmentionUrls, err := ignoredWebmentionUrls()
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
IgnoredWebmentionUrls = ignoredWebmentionUrls
|
||||
IgnoredWebmentionUrls = cfg.IgnoredWebmentionUrls
|
||||
// Syndication Targets (optional)
|
||||
syndicationTargets, err := syndicationTargets()
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
targets := make([]SyndicationTarget, 0)
|
||||
for _, url := range cfg.SyndicationTargets {
|
||||
targets = append(targets, SyndicationTarget{
|
||||
Uid: url,
|
||||
Name: url,
|
||||
})
|
||||
}
|
||||
SyndicationTargets = syndicationTargets
|
||||
SyndicationTargets = targets
|
||||
// Find selected storage
|
||||
SelectedStorage = func() Storage {
|
||||
// Gitea
|
||||
giteaEndpoint, err1 := giteaEndpoint()
|
||||
giteaToken, err2 := giteaToken()
|
||||
if err1 == nil && err2 == nil {
|
||||
if len(cfg.GiteaEndpoint) > 0 && len(cfg.GiteaToken) >= 0 {
|
||||
return &Gitea{
|
||||
endpoint: giteaEndpoint,
|
||||
token: giteaToken,
|
||||
endpoint: cfg.GiteaEndpoint,
|
||||
token: cfg.GiteaToken,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
if SelectedStorage == nil {
|
||||
log.Fatal("No storage configured")
|
||||
return errors.New("no storage configured")
|
||||
}
|
||||
// Find selected media storage
|
||||
SelectedMediaStorage = func() MediaStorage {
|
||||
// BunnyCDN
|
||||
if len(cfg.BunnyCdnStorageKey) > 0 && len(cfg.BunnyCdnStorageName) > 0 && len(cfg.MediaUrl) > 0 {
|
||||
return &BunnyCdnStorage{
|
||||
key: cfg.BunnyCdnStorageKey,
|
||||
storageZoneName: cfg.BunnyCdnStorageName,
|
||||
baseLocation: cfg.MediaUrl,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
if SelectedMediaStorage == nil {
|
||||
return errors.New("no media storage configured")
|
||||
}
|
||||
// Find selected CDN (optional)
|
||||
SelectedCdn = func() Cdn {
|
||||
// BunnyCDN (optional)
|
||||
bunnyCdnKey, err := bunnyCdnKey()
|
||||
if err == nil {
|
||||
return &BunnyCdn{key: bunnyCdnKey}
|
||||
if len(cfg.BunnyCdnKey) > 0 {
|
||||
return &BunnyCdn{key: cfg.BunnyCdnKey}
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
if SelectedCdn == nil {
|
||||
log.Println("No CDN configured")
|
||||
log.Println("no CDN configured")
|
||||
}
|
||||
// Find configured social networks (optional)
|
||||
SelectedSocials = func() Socials {
|
||||
var socials []Social = nil
|
||||
// Microblog.pub
|
||||
microblogUrl, err1 := microblogUrl()
|
||||
microblogToken, err2 := microblogToken()
|
||||
if err1 == nil && err2 == nil {
|
||||
if len(cfg.MicroblogUrl) > 0 && len(cfg.MicroblogToken) > 0 {
|
||||
socials = append(socials, &MicroblogPub{
|
||||
url: microblogUrl,
|
||||
token: microblogToken,
|
||||
url: cfg.MicroblogUrl,
|
||||
token: cfg.MicroblogToken,
|
||||
})
|
||||
}
|
||||
return socials
|
||||
}()
|
||||
if SelectedSocials == nil {
|
||||
log.Println("No social networks configured")
|
||||
log.Println("no social networks configured")
|
||||
}
|
||||
// Find configured notification services (optional)
|
||||
SelectedNotificationServices = func() NotificationServices {
|
||||
var notificationServices []NotificationService = nil
|
||||
// Telegram
|
||||
telegramUserId, err1 := telegramUserId()
|
||||
telegramBotToken, err2 := telegramBotToken()
|
||||
if err1 == nil && err2 == nil {
|
||||
if cfg.TelegramUserId > 0 && len(cfg.TelegramBotToken) > 0 {
|
||||
notificationServices = append(notificationServices, &Telegram{
|
||||
userId: telegramUserId,
|
||||
botToken: telegramBotToken,
|
||||
userId: cfg.TelegramUserId,
|
||||
botToken: cfg.TelegramBotToken,
|
||||
})
|
||||
}
|
||||
return notificationServices
|
||||
|
@ -104,92 +130,5 @@ func initConfig() {
|
|||
if SelectedNotificationServices == nil {
|
||||
log.Println("No notification services configured")
|
||||
}
|
||||
}
|
||||
|
||||
func giteaEndpoint() (string, error) {
|
||||
giteaEndpoint := os.Getenv("GITEA_ENDPOINT")
|
||||
if len(giteaEndpoint) == 0 || giteaEndpoint == "" {
|
||||
return "", errors.New("GITEA_ENDPOINT not specified")
|
||||
}
|
||||
return giteaEndpoint, nil
|
||||
}
|
||||
|
||||
func giteaToken() (string, error) {
|
||||
giteaToken := os.Getenv("GITEA_TOKEN")
|
||||
if len(giteaToken) == 0 || giteaToken == "" {
|
||||
return "", errors.New("GITEA_TOKEN not specified")
|
||||
}
|
||||
return giteaToken, nil
|
||||
}
|
||||
|
||||
func blogUrl() (string, error) {
|
||||
blogURL := os.Getenv("BLOG_URL")
|
||||
if len(blogURL) == 0 || blogURL == "" {
|
||||
return "", errors.New("BLOG_URL not specified")
|
||||
}
|
||||
return blogURL, nil
|
||||
}
|
||||
|
||||
func bunnyCdnKey() (string, error) {
|
||||
bunnyCDNKey := os.Getenv("BUNNY_CDN_KEY")
|
||||
if len(bunnyCDNKey) == 0 || bunnyCDNKey == "" {
|
||||
return "", errors.New("BUNNY_CDN_KEY not specified, BunnyCDN features are deactivated")
|
||||
}
|
||||
return bunnyCDNKey, nil
|
||||
}
|
||||
|
||||
func microblogUrl() (string, error) {
|
||||
microblogUrl := os.Getenv("MICROBLOG_URL")
|
||||
if len(microblogUrl) == 0 || microblogUrl == "" {
|
||||
return "", errors.New("MICROBLOG_URL not specified, microblog.pub features are deactivated")
|
||||
}
|
||||
return microblogUrl, nil
|
||||
}
|
||||
|
||||
func microblogToken() (string, error) {
|
||||
microblogToken := os.Getenv("MICROBLOG_TOKEN")
|
||||
if len(microblogToken) == 0 || microblogToken == "" {
|
||||
return "", errors.New("MICROBLOG_TOKEN not specified, microblog.pub features are deactivated")
|
||||
}
|
||||
return microblogToken, nil
|
||||
}
|
||||
|
||||
func telegramUserId() (int64, error) {
|
||||
telegramUserIdString := os.Getenv("TELEGRAM_USER_ID")
|
||||
telegramUserId, err := strconv.ParseInt(telegramUserIdString, 10, 64)
|
||||
if err != nil || len(telegramUserIdString) == 0 || telegramUserIdString == "" {
|
||||
return 0, errors.New("TELEGRAM_USER_ID not specified, Telegram features are deactivated")
|
||||
}
|
||||
return telegramUserId, nil
|
||||
}
|
||||
|
||||
func telegramBotToken() (string, error) {
|
||||
telegramBotToken := os.Getenv("TELEGRAM_BOT_TOKEN")
|
||||
if len(telegramBotToken) == 0 || telegramBotToken == "" {
|
||||
return "", errors.New("TELEGRAM_BOT_TOKEN not specified, Telegram features are deactivated")
|
||||
}
|
||||
return telegramBotToken, nil
|
||||
}
|
||||
|
||||
func ignoredWebmentionUrls() ([]string, error) {
|
||||
webmentionIgnored := os.Getenv("WEBMENTION_IGNORED")
|
||||
if len(webmentionIgnored) == 0 {
|
||||
return make([]string, 0), errors.New("WEBMENTION_IGNORED not set, no URLs are ignored on Webmention sending")
|
||||
}
|
||||
return strings.Split(webmentionIgnored, ","), nil
|
||||
}
|
||||
|
||||
func syndicationTargets() ([]SyndicationTarget, error) {
|
||||
syndication := os.Getenv("SYNDICATION")
|
||||
targets := make([]SyndicationTarget, 0)
|
||||
if len(syndication) == 0 {
|
||||
return targets, errors.New("SYNDICATION not set, no targets are returned when querying for syndication targets")
|
||||
}
|
||||
for _, url := range strings.Split(syndication, ",") {
|
||||
targets = append(targets, SyndicationTarget{
|
||||
Uid: url,
|
||||
Name: url,
|
||||
})
|
||||
}
|
||||
return targets, nil
|
||||
return nil
|
||||
}
|
||||
|
|
1
go.mod
1
go.mod
|
@ -3,6 +3,7 @@ module codeberg.org/jlelse/hugo-micropub
|
|||
go 1.13
|
||||
|
||||
require (
|
||||
github.com/caarlos0/env/v6 v6.1.0
|
||||
github.com/go-telegram-bot-api/telegram-bot-api v4.6.5-0.20190904012038-b33efeebc785+incompatible
|
||||
github.com/technoweenie/multipartstreamer v1.0.1 // indirect
|
||||
gopkg.in/yaml.v2 v2.2.7
|
||||
|
|
8
go.sum
8
go.sum
|
@ -1,9 +1,16 @@
|
|||
github.com/andybalholm/cascadia v1.0.0 h1:hOCXnnZ5A+3eVDX8pvgl4kofXv2ELss0bKcqRySc45o=
|
||||
github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
|
||||
github.com/caarlos0/env/v6 v6.1.0 h1:4FbM+HmZA/Q5wdSrH2kj0KQXm7xnhuO8y3TuOTnOvqc=
|
||||
github.com/caarlos0/env/v6 v6.1.0/go.mod h1:iUA6X3VCAOwDhoqvgKlTGjjwJzQseIJaFYApUqQkt+8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-telegram-bot-api/telegram-bot-api v4.6.5-0.20190904012038-b33efeebc785+incompatible h1:OT02onvXX618RBcjxeUA4H7d1PSm5Apg4IET72VgVlE=
|
||||
github.com/go-telegram-bot-api/telegram-bot-api v4.6.5-0.20190904012038-b33efeebc785+incompatible/go.mod h1:qf9acutJ8cwBUhm1bqgz6Bei9/C/c93FPDljKWwsOgM=
|
||||
github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM=
|
||||
github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog=
|
||||
github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0/go.mod h1:IXCdmsXIht47RaVFLEdVnh1t+pgYtTAhQGj73kz+2DM=
|
||||
|
@ -12,6 +19,7 @@ golang.org/x/net v0.0.0-20180911220305-26e67e76b6c3 h1:czFLhve3vsQetD6JOJ8NZZvGQ
|
|||
golang.org/x/net v0.0.0-20180911220305-26e67e76b6c3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
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.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo=
|
||||
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
willnorris.com/go/webmention v0.0.0-20191104072158-c7fb13569b62 h1:jqC8A1S2/9WjXSOK/Nl2rYwVgxU7DCnZ/zpOTL1BErI=
|
||||
|
|
6
main.go
6
main.go
|
@ -8,12 +8,16 @@ import (
|
|||
)
|
||||
|
||||
func main() {
|
||||
initConfig()
|
||||
err := initConfig()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.Println("Starting micropub server...")
|
||||
log.Println("Current time: " + time.Now().Format(time.RFC3339))
|
||||
log.Println("Blog URL: " + BlogUrl)
|
||||
log.Println("Ignored URLs for Webmention: " + strings.Join(IgnoredWebmentionUrls, ", "))
|
||||
http.HandleFunc("/micropub", HandleMicroPub)
|
||||
http.HandleFunc("/media", HandleMedia)
|
||||
http.HandleFunc("/webmention", HandleWebmention)
|
||||
log.Fatal(http.ListenAndServe(":5555", nil))
|
||||
}
|
||||
|
|
|
@ -0,0 +1,58 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func HandleMedia(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
_, _ = w.Write([]byte("The HTTP method is not allowed, make a POST request"))
|
||||
return
|
||||
}
|
||||
if contentType := r.Header.Get("content-type"); !strings.Contains(contentType, "multipart/form-data") {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("Wrong Content-Type"))
|
||||
return
|
||||
}
|
||||
err := r.ParseMultipartForm(0)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("Failed to parse Multipart"))
|
||||
return
|
||||
}
|
||||
authCode := r.Header.Get("authorization")
|
||||
if formAuth := r.FormValue("authorization"); len(authCode) == 0 && len(formAuth) > 0 {
|
||||
authCode = formAuth
|
||||
}
|
||||
if CheckAuthorization(authCode) {
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("Failed to get file"))
|
||||
return
|
||||
}
|
||||
fileName := generateRandomString(time.Now(), 15)
|
||||
mimeType := header.Header.Get("content-type")
|
||||
originalName := header.Filename
|
||||
if strings.Contains(mimeType, "jpeg") || strings.Contains(originalName, ".jpeg") || strings.Contains(originalName, ".jpg") {
|
||||
fileName += ".jpg"
|
||||
} else if strings.Contains(mimeType, "png") || strings.Contains(originalName, ".png") {
|
||||
fileName += ".png"
|
||||
}
|
||||
location, err := SelectedMediaStorage.Upload(fileName, file)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("Failed to upload file"))
|
||||
return
|
||||
}
|
||||
w.Header().Add("Location", location)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte("Forbidden, there was a problem with the provided access token"))
|
||||
return
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type MediaStorage interface {
|
||||
Upload(fileName string, file multipart.File) (location string, err error)
|
||||
}
|
||||
|
||||
// BunnyCDN
|
||||
type BunnyCdnStorage struct {
|
||||
// Access Key
|
||||
key string
|
||||
// Storage zone name
|
||||
storageZoneName string
|
||||
// Base location
|
||||
baseLocation string
|
||||
}
|
||||
|
||||
var bunnyCdnStorageUrl = "https://storage.bunnycdn.com"
|
||||
|
||||
func (b BunnyCdnStorage) Upload(fileName string, file multipart.File) (location string, err error) {
|
||||
client := &http.Client{}
|
||||
req, _ := http.NewRequest("PUT", bunnyCdnStorageUrl+"/"+url.PathEscape(b.storageZoneName)+"/"+url.PathEscape("/"+fileName), file)
|
||||
req.Header.Add("AccessKey", b.key)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil || resp.StatusCode != 201 {
|
||||
return "", errors.New("failed to upload file to BunnyCDN")
|
||||
}
|
||||
return b.baseLocation + fileName, nil
|
||||
}
|
11
micropub.go
11
micropub.go
|
@ -8,7 +8,8 @@ import (
|
|||
)
|
||||
|
||||
type MicropubConfig struct {
|
||||
SyndicateTo []SyndicationTarget `json:"syndicate-to,omitempty"`
|
||||
SyndicateTo []SyndicationTarget `json:"syndicate-to,omitempty"`
|
||||
MediaEndpoint string `json:"media-endpoint,omitempty"`
|
||||
}
|
||||
|
||||
func HandleMicroPub(w http.ResponseWriter, r *http.Request) {
|
||||
|
@ -18,7 +19,8 @@ func HandleMicroPub(w http.ResponseWriter, r *http.Request) {
|
|||
w.Header().Add("Content-type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
jsonBytes, err := json.Marshal(&MicropubConfig{
|
||||
SyndicateTo: SyndicationTargets,
|
||||
SyndicateTo: SyndicationTargets,
|
||||
MediaEndpoint: MediaEndpointUrl,
|
||||
})
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
@ -71,7 +73,10 @@ func HandleMicroPub(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
return
|
||||
}
|
||||
if CheckAuthorization(entry, r.Header.Get("authorization")) {
|
||||
if authHeader := r.Header.Get("authorization"); len(entry.token) == 0 && len(authHeader) > 0 {
|
||||
entry.token = authHeader
|
||||
}
|
||||
if CheckAuthorization(entry.token) {
|
||||
location, err := WriteEntry(entry)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
|
|
@ -75,13 +75,8 @@ func checkAccess(token string) (bool, error) {
|
|||
return true, nil
|
||||
}
|
||||
|
||||
func CheckAuthorization(entry *Entry, token string) bool {
|
||||
if len(token) < 1 { // there is no token provided
|
||||
return false
|
||||
} else {
|
||||
entry.token = token
|
||||
}
|
||||
if ok, err := checkAccess(entry.token); ok {
|
||||
func CheckAuthorization(token string) bool {
|
||||
if ok, err := checkAccess(token); ok {
|
||||
return true
|
||||
} else if err != nil {
|
||||
return false
|
||||
|
|
Loading…
Reference in New Issue