mirror of
https://github.com/Flomp/wanderer.git
synced 2026-01-10 15:10:21 -06:00
1367 lines
35 KiB
Go
1367 lines
35 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/meilisearch/meilisearch-go"
|
|
"github.com/pocketbase/dbx"
|
|
"github.com/pocketbase/pocketbase"
|
|
"github.com/pocketbase/pocketbase/apis"
|
|
"github.com/pocketbase/pocketbase/core"
|
|
"github.com/pocketbase/pocketbase/plugins/migratecmd"
|
|
"github.com/pocketbase/pocketbase/tools/filesystem"
|
|
"github.com/pocketbase/pocketbase/tools/security"
|
|
"github.com/spf13/cast"
|
|
|
|
"pocketbase/commands"
|
|
"pocketbase/federation"
|
|
"pocketbase/integrations/komoot"
|
|
"pocketbase/integrations/strava"
|
|
|
|
_ "pocketbase/migrations"
|
|
"pocketbase/util"
|
|
|
|
pub "github.com/go-ap/activitypub"
|
|
"github.com/microcosm-cc/bluemonday"
|
|
)
|
|
|
|
const (
|
|
defaultPocketBaseEncryptionKey = "fde406459dc1f6ca6f348e1f44a9a2af"
|
|
defaultMeiliMasterKey = "vODkljPcfFANYNepCHyDyGjzAMPcdHnrb6X5KyXQPWo"
|
|
)
|
|
|
|
// verifySettings checks if the required environment variables are set.
|
|
// If they are not set, it logs a warning.
|
|
func verifySettings(app core.App) {
|
|
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
|
|
|
|
if len(encryptionKey) != 32 {
|
|
// terminate if the encryption key is not set or is not exactly 32 bytes long,
|
|
// as this is a requirement for PocketBase to function properly.
|
|
log.Fatal("POCKETBASE_ENCRYPTION_KEY must be exactly 32 bytes long- See https://wanderer.to/run/installation/#prerequisites for more information")
|
|
}
|
|
|
|
if encryptionKey == defaultPocketBaseEncryptionKey {
|
|
app.Logger().Warn("POCKETBASE_ENCRYPTION_KEY is still set to the default value. Please change it to a secure value")
|
|
}
|
|
|
|
meiliMasterKey := os.Getenv("MEILI_MASTER_KEY")
|
|
|
|
if len(meiliMasterKey) < 32 {
|
|
app.Logger().Warn("MEILI_MASTER_KEY not set or is shorter than 32 bytes")
|
|
}
|
|
|
|
if meiliMasterKey == defaultMeiliMasterKey {
|
|
app.Logger().Warn("MEILI_MASTER_KEY is still set to the default value. Please change it to a secure value")
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
|
|
app := pocketbase.New()
|
|
client := initializeMeiliSearch()
|
|
|
|
verifySettings(app)
|
|
|
|
registerMigrations(app)
|
|
setupEventHandlers(app, client)
|
|
|
|
setupCommands(app)
|
|
|
|
if err := app.Start(); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func initializeMeiliSearch() meilisearch.ServiceManager {
|
|
return meilisearch.New(
|
|
os.Getenv("MEILI_URL"),
|
|
meilisearch.WithAPIKey(os.Getenv("MEILI_MASTER_KEY")),
|
|
)
|
|
}
|
|
|
|
func registerMigrations(app *pocketbase.PocketBase) {
|
|
migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{
|
|
Dir: "migrations",
|
|
Automigrate: true,
|
|
})
|
|
}
|
|
|
|
func setupEventHandlers(app *pocketbase.PocketBase, client meilisearch.ServiceManager) {
|
|
app.OnRecordAfterCreateSuccess("users").BindFunc(createUserHandler(client))
|
|
|
|
app.OnRecordAfterCreateSuccess("trails").BindFunc(createTrailHandler(client))
|
|
app.OnRecordAfterUpdateSuccess("trails").BindFunc(updateTrailHandler(client))
|
|
app.OnRecordAfterDeleteSuccess("trails").BindFunc(deleteTrailHandler(client))
|
|
|
|
app.OnRecordCreateRequest("summit_logs").BindFunc(createSummitLogHandler(client))
|
|
app.OnRecordUpdateRequest("summit_logs").BindFunc(updateSummitLogHandler())
|
|
app.OnRecordDeleteRequest("summit_logs").BindFunc(deleteSummitLogHandler(client))
|
|
|
|
app.OnRecordCreateRequest("comments").BindFunc(createCommentHandler())
|
|
app.OnRecordUpdateRequest("comments").BindFunc(updateCommentHandler())
|
|
app.OnRecordDeleteRequest("comments").BindFunc(deleteCommentHandler(client))
|
|
|
|
app.OnRecordCreateRequest("trail_share").BindFunc(createTrailShareHandler(client))
|
|
app.OnRecordDeleteRequest("trail_share").BindFunc(deleteTrailShareHandler(client))
|
|
|
|
app.OnRecordAfterCreateSuccess("trail_like").BindFunc(createTrailLikeHandler(client))
|
|
app.OnRecordAfterDeleteSuccess("trail_like").BindFunc(deleteTrailLikeHandler(client))
|
|
|
|
app.OnRecordAfterCreateSuccess("lists").BindFunc(createListHandler(client))
|
|
app.OnRecordAfterUpdateSuccess("lists").BindFunc(updateListHandler(client))
|
|
app.OnRecordAfterDeleteSuccess("lists").BindFunc(deleteListHandler(client))
|
|
|
|
app.OnRecordCreateRequest("list_share").BindFunc(createListShareHandler(client))
|
|
app.OnRecordDeleteRequest("list_share").BindFunc(deleteListShareHandler(client))
|
|
|
|
app.OnRecordCreateRequest("follows").BindFunc(createFollowHandler())
|
|
app.OnRecordDeleteRequest("follows").BindFunc(deleteFollowHandler())
|
|
|
|
app.OnRecordsListRequest("integrations").BindFunc(listIntegrationHandler())
|
|
app.OnRecordCreate("integrations").BindFunc(createIntegrationHandler())
|
|
app.OnRecordAfterCreateSuccess("integrations").BindFunc(createUpdateIntegrationSuccessHandler())
|
|
app.OnRecordUpdate("integrations").BindFunc(updateIntegrationHandler())
|
|
app.OnRecordAfterUpdateSuccess("integrations").BindFunc(createUpdateIntegrationSuccessHandler())
|
|
|
|
app.OnRecordsListRequest("feed", "profile_feed").BindFunc(listFeedHandler())
|
|
|
|
app.OnRecordCreateRequest().BindFunc(sanitizeHTML())
|
|
app.OnRecordUpdateRequest().BindFunc(sanitizeHTML())
|
|
|
|
app.OnRecordRequestEmailChangeRequest("users").BindFunc(changeUserEmailHandler())
|
|
app.OnServe().BindFunc(onBeforeServeHandler(client))
|
|
|
|
app.OnBootstrap().BindFunc(onBootstrapHandler())
|
|
}
|
|
|
|
func setupCommands(app *pocketbase.PocketBase) {
|
|
app.RootCmd.AddCommand(commands.Dedup(app))
|
|
}
|
|
|
|
func sanitizeHTML() func(e *core.RecordRequestEvent) error {
|
|
return func(e *core.RecordRequestEvent) error {
|
|
fieldsToSanitize := map[string][]string{
|
|
"lists": {"description"},
|
|
"settings": {"bio"},
|
|
"summit_logs": {"text"},
|
|
"trails": {"description"},
|
|
"comments": {"text"},
|
|
"waypoints": {"description"},
|
|
}
|
|
collection := e.Collection.Name
|
|
fields, ok := fieldsToSanitize[collection]
|
|
if !ok {
|
|
return e.Next()
|
|
}
|
|
|
|
p := bluemonday.NewPolicy()
|
|
p.AllowStandardAttributes()
|
|
p.AllowStandardURLs()
|
|
p.AllowLists()
|
|
p.AllowElements("br", "div", "hr", "p", "span", "wbr")
|
|
p.AllowElements("b", "strong", "em", "u", "blockquote", "a")
|
|
p.AllowAttrs("href").OnElements("a")
|
|
p.AllowAttrs("target").OnElements("a")
|
|
p.AllowAttrs("class").OnElements("a")
|
|
|
|
for _, field := range fields {
|
|
if val, ok := e.Record.Get(field).(string); ok {
|
|
sanitizedValue := p.Sanitize(val)
|
|
e.Record.Set(field, sanitizedValue)
|
|
}
|
|
}
|
|
|
|
return e.Next()
|
|
}
|
|
}
|
|
|
|
func createUserHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
|
|
return func(e *core.RecordEvent) error {
|
|
userId := e.Record.Id
|
|
|
|
err := createDefaultUserSettings(e.App, e.Record.Id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
actor, err := util.ActorFromUser(e.App, e.Record)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
searchRules := map[string]interface{}{
|
|
"lists": map[string]string{
|
|
"filter": "public = true OR author = " + actor.Id + " OR shares = " + userId,
|
|
},
|
|
"trails": map[string]string{
|
|
"filter": "public = true OR author = " + actor.Id + " OR shares = " + userId,
|
|
},
|
|
}
|
|
|
|
token, err := util.GenerateMeilisearchToken(searchRules, client)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
e.Record.Set("token", token)
|
|
if err := e.App.Save(e.Record); err != nil {
|
|
return err
|
|
}
|
|
|
|
return e.Next()
|
|
}
|
|
}
|
|
|
|
func createDefaultUserSettings(app core.App, userId string) error {
|
|
collection, err := app.FindCollectionByNameOrId("settings")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
settings := core.NewRecord(collection)
|
|
settings.Set("language", "en")
|
|
settings.Set("unit", "metric")
|
|
settings.Set("mapFocus", "trails")
|
|
settings.Set("user", userId)
|
|
return app.Save(settings)
|
|
}
|
|
|
|
func createTrailHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
|
|
return func(e *core.RecordEvent) error {
|
|
record := e.Record
|
|
author, err := e.App.FindRecordById("activitypub_actors", record.GetString(("author")))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := util.IndexTrails(e.App, []*core.Record{record}, client); err != nil {
|
|
return err
|
|
}
|
|
if !author.GetBool("isLocal") {
|
|
// this happens if someone fetches a remote trail
|
|
// we create a stub trail record for later reference
|
|
// no need to create an activity for that
|
|
return e.Next()
|
|
}
|
|
|
|
err = e.Next()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = federation.CreateTrailActivity(e.App, author, e.Record, pub.CreateType)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = util.InsertIntoFeed(e.App, author.Id, author.Id, record.Id, util.TrailFeed)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func updateTrailHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
|
|
return func(e *core.RecordEvent) error {
|
|
record := e.Record
|
|
author, err := e.App.FindRecordById("activitypub_actors", record.GetString(("author")))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = util.UpdateTrail(e.App, record, author, client)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !author.GetBool("isLocal") {
|
|
// this happens if someone fetches a remote trail
|
|
// we create a stub trail record for later reference
|
|
// no need to create an activity for that
|
|
return e.Next()
|
|
}
|
|
|
|
err = e.Next()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = federation.CreateTrailActivity(e.App, author, e.Record, pub.UpdateType)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func deleteTrailHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
|
|
return func(e *core.RecordEvent) error {
|
|
record := e.Record
|
|
task, err := client.Index("trails").DeleteDocument(record.Id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
interval := 500 * time.Millisecond
|
|
_, err = client.WaitForTask(task.TaskUID, interval)
|
|
if err != nil {
|
|
log.Fatalf("Error waiting for task completion: %v", err)
|
|
}
|
|
|
|
err = federation.CreateTrailDeleteActivity(e.App, e.Record)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = util.DeleteFromFeed(e.App, record.Id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return e.Next()
|
|
}
|
|
}
|
|
|
|
func createSummitLogHandler(client meilisearch.ServiceManager) func(e *core.RecordRequestEvent) error {
|
|
return func(e *core.RecordRequestEvent) error {
|
|
|
|
err := e.Next()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
trail, err := e.App.FindRecordById("trails", e.Record.GetString("trail"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := util.IndexTrails(e.App, []*core.Record{trail}, client); err != nil {
|
|
return err
|
|
}
|
|
|
|
err = federation.CreateSummitLogActivity(e.App, userActor, e.Record, pub.CreateType)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func updateSummitLogHandler() func(e *core.RecordRequestEvent) error {
|
|
return func(e *core.RecordRequestEvent) error {
|
|
|
|
err := e.Next()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = federation.CreateSummitLogActivity(e.App, userActor, e.Record, pub.UpdateType)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func deleteSummitLogHandler(client meilisearch.ServiceManager) func(e *core.RecordRequestEvent) error {
|
|
return func(e *core.RecordRequestEvent) error {
|
|
err := e.Next()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
trail, err := e.App.FindRecordById("trails", e.Record.GetString("trail"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := util.IndexTrails(e.App, []*core.Record{trail}, client); err != nil {
|
|
return err
|
|
}
|
|
|
|
err = federation.CreateSummitLogDeleteActivity(e.App, e.Record)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func createCommentHandler() func(e *core.RecordRequestEvent) error {
|
|
return func(e *core.RecordRequestEvent) error {
|
|
|
|
e.Next()
|
|
|
|
userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = federation.CreateCommentActivity(e.App, userActor, e.Record, pub.CreateType)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func updateCommentHandler() func(e *core.RecordRequestEvent) error {
|
|
return func(e *core.RecordRequestEvent) error {
|
|
userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = federation.CreateCommentActivity(e.App, userActor, e.Record, pub.UpdateType)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return e.Next()
|
|
|
|
}
|
|
}
|
|
|
|
func deleteCommentHandler(client meilisearch.ServiceManager) func(e *core.RecordRequestEvent) error {
|
|
return func(e *core.RecordRequestEvent) error {
|
|
|
|
err := federation.CreateCommentDeleteActivity(e.App, client, e.Record)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return e.Next()
|
|
}
|
|
}
|
|
|
|
func createTrailShareHandler(client meilisearch.ServiceManager) func(e *core.RecordRequestEvent) error {
|
|
return func(e *core.RecordRequestEvent) error {
|
|
err := e.Next()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
record := e.Record
|
|
|
|
trailId := record.GetString("trail")
|
|
shares, err := e.App.FindAllRecords("trail_share",
|
|
dbx.NewExp("trail = {:trailId}", dbx.Params{"trailId": trailId}),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
actorIds := make([]string, len(shares))
|
|
for i, r := range shares {
|
|
actorIds[i] = r.GetString("actor")
|
|
}
|
|
err = util.UpdateTrailShares(trailId, actorIds, client)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = federation.CreateAnnounceActivity(e.App, record, federation.TrailAnnounceType)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func deleteTrailShareHandler(client meilisearch.ServiceManager) func(e *core.RecordRequestEvent) error {
|
|
return func(e *core.RecordRequestEvent) error {
|
|
record := e.Record
|
|
|
|
trailId := record.GetString("trail")
|
|
err := util.UpdateTrailShares(trailId, []string{}, client)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return e.Next()
|
|
}
|
|
}
|
|
|
|
func createTrailLikeHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
|
|
return func(e *core.RecordEvent) error {
|
|
err := e.Next()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
record := e.Record
|
|
|
|
trailId := record.GetString("trail")
|
|
actorId := record.GetString("actor")
|
|
actor, err := e.App.FindRecordById("activitypub_actors", actorId)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
trail, err := e.App.FindRecordById("trails", trailId)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
likes, err := e.App.FindAllRecords("trail_like",
|
|
dbx.NewExp("trail = {:trailId}", dbx.Params{"trailId": trailId}),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
trail.Set("like_count", len(likes))
|
|
err = e.App.UnsafeWithoutHooks().Save(trail)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
actorIds := make([]string, len(likes))
|
|
for i, r := range likes {
|
|
actorIds[i] = r.GetString("actor")
|
|
}
|
|
err = util.UpdateTrailLikes(trailId, actorIds, client)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !actor.GetBool("isLocal") {
|
|
// this happens if someone likes a remote trail
|
|
// we create a local copy
|
|
// no need to create an activity for that
|
|
return nil
|
|
}
|
|
|
|
err = federation.CreateLikeActivity(e.App, record)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func deleteTrailLikeHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
|
|
return func(e *core.RecordEvent) error {
|
|
|
|
record := e.Record
|
|
|
|
trailId := record.GetString("trail")
|
|
actorId := record.GetString("actor")
|
|
actor, err := e.App.FindRecordById("activitypub_actors", actorId)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// trail might deleted be already if this is called as part of a cascade
|
|
trail, err := e.App.FindRecordById("trails", trailId)
|
|
if err != nil && err == sql.ErrNoRows {
|
|
return nil
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
likes, err := e.App.CountRecords("trail_like", dbx.NewExp("trail={:trail}", dbx.Params{"trail": trailId}))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
trail.Set("like_count", likes)
|
|
err = e.App.UnsafeWithoutHooks().Save(trail)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = util.UpdateTrailLikes(trailId, []string{}, client)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !actor.GetBool("isLocal") {
|
|
// this happens if someone likes a remote trail
|
|
// we create a local copy
|
|
// no need to create an activity for that
|
|
return nil
|
|
}
|
|
|
|
err = federation.CreateUnlikeActivity(e.App, record)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return e.Next()
|
|
}
|
|
}
|
|
|
|
func createListHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
|
|
return func(e *core.RecordEvent) error {
|
|
record := e.Record
|
|
|
|
author, err := e.App.FindRecordById("activitypub_actors", record.GetString(("author")))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := util.IndexLists(e.App, []*core.Record{record}, client); err != nil {
|
|
return err
|
|
}
|
|
|
|
if !author.GetBool("isLocal") {
|
|
// this happens if someone fetches a remote list
|
|
// we create a stub list record for later reference
|
|
// no need to create an activity for that
|
|
return e.Next()
|
|
}
|
|
|
|
err = e.Next()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = federation.CreateListActivity(e.App, e.Record, pub.CreateType)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = util.InsertIntoFeed(e.App, author.Id, author.Id, record.Id, util.ListFeed)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func updateListHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
|
|
return func(e *core.RecordEvent) error {
|
|
record := e.Record
|
|
author, err := e.App.FindRecordById("activitypub_actors", record.GetString(("author")))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = util.UpdateList(e.App, record, author, client)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !author.GetBool("isLocal") {
|
|
// this happens if someone fetches a remote list
|
|
// we create a stub list record for later reference
|
|
// no need to create an activity for that
|
|
return e.Next()
|
|
}
|
|
|
|
err = e.Next()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = federation.CreateListActivity(e.App, e.Record, pub.CreateType)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func deleteListHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
|
|
return func(e *core.RecordEvent) error {
|
|
record := e.Record
|
|
_, err := client.Index("lists").DeleteDocument(record.Id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = federation.CreateListDeleteActivity(e.App, record)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = util.DeleteFromFeed(e.App, record.Id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return e.Next()
|
|
}
|
|
}
|
|
|
|
func createListShareHandler(client meilisearch.ServiceManager) func(e *core.RecordRequestEvent) error {
|
|
return func(e *core.RecordRequestEvent) error {
|
|
err := e.Next()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
record := e.Record
|
|
listId := record.GetString("list")
|
|
shares, err := e.App.FindAllRecords("list_share",
|
|
dbx.NewExp("list = {:listId}", dbx.Params{"listId": listId}),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
actorIds := make([]string, len(shares))
|
|
for i, r := range shares {
|
|
actorIds[i] = r.GetString("actor")
|
|
}
|
|
err = util.UpdateListShares(listId, actorIds, client)
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = federation.CreateAnnounceActivity(e.App, record, federation.ListAnnounceType)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func deleteListShareHandler(client meilisearch.ServiceManager) func(e *core.RecordRequestEvent) error {
|
|
return func(e *core.RecordRequestEvent) error {
|
|
record := e.Record
|
|
listId := record.GetString("list")
|
|
err := util.UpdateListShares(listId, []string{}, client)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return e.Next()
|
|
}
|
|
}
|
|
|
|
func createFollowHandler() func(e *core.RecordRequestEvent) error {
|
|
return func(e *core.RecordRequestEvent) error {
|
|
e.Next()
|
|
federation.CreateFollowActivity(e.App, e.Record)
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func deleteFollowHandler() func(e *core.RecordRequestEvent) error {
|
|
return func(e *core.RecordRequestEvent) error {
|
|
federation.CreateUnfollowActivity(e.App, e.Record)
|
|
|
|
return e.Next()
|
|
}
|
|
}
|
|
|
|
func listIntegrationHandler() func(e *core.RecordsListRequestEvent) error {
|
|
return func(e *core.RecordsListRequestEvent) error {
|
|
if e.HasSuperuserAuth() {
|
|
return e.Next()
|
|
}
|
|
for _, r := range e.Records {
|
|
|
|
err := censorIntegrationSecrets(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return e.Next()
|
|
}
|
|
}
|
|
|
|
func createIntegrationHandler() func(e *core.RecordEvent) error {
|
|
return func(e *core.RecordEvent) error {
|
|
err := encryptIntegrationSecrets(e.App, e.Record)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return e.Next()
|
|
}
|
|
}
|
|
|
|
func createUpdateIntegrationSuccessHandler() func(e *core.RecordEvent) error {
|
|
return func(e *core.RecordEvent) error {
|
|
err := censorIntegrationSecrets(e.Record)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return e.Next()
|
|
}
|
|
}
|
|
|
|
func updateIntegrationHandler() func(e *core.RecordEvent) error {
|
|
return func(e *core.RecordEvent) error {
|
|
err := encryptIntegrationSecrets(e.App, e.Record)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return e.Next()
|
|
}
|
|
}
|
|
func censorIntegrationSecrets(r *core.Record) error {
|
|
secrets := map[string][]string{
|
|
"strava": {"clientSecret", "refreshToken", "accessToken", "expiresAt"},
|
|
"komoot": {"password"},
|
|
}
|
|
for key, secretKeys := range secrets {
|
|
if integrationString := r.GetString(key); integrationString != "" {
|
|
var integration map[string]interface{}
|
|
if err := json.Unmarshal([]byte(integrationString), &integration); err != nil {
|
|
return err
|
|
}
|
|
if integration == nil {
|
|
continue
|
|
}
|
|
for _, secretKey := range secretKeys {
|
|
integration[secretKey] = ""
|
|
}
|
|
b, err := json.Marshal(integration)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
r.Set(key, string(b))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func encryptIntegrationSecrets(app core.App, r *core.Record) error {
|
|
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
|
|
if len(encryptionKey) == 0 {
|
|
return apis.NewBadRequestError("POCKETBASE_ENCRYPTION_KEY not set", nil)
|
|
}
|
|
|
|
secrets := map[string][]string{
|
|
"strava": {"clientSecret", "refreshToken", "accessToken", "expiresAt"},
|
|
"komoot": {"password"},
|
|
}
|
|
|
|
original, _ := app.FindRecordById("integrations", r.Id)
|
|
|
|
for key, secretKeys := range secrets {
|
|
if integrationString := r.GetString(key); integrationString != "" {
|
|
var integration map[string]interface{}
|
|
if err := json.Unmarshal([]byte(integrationString), &integration); err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, secretKey := range secretKeys {
|
|
// If the secret is already encrypted, we don't re-encrypt it.
|
|
// TODO: This is a bit of a hack, we should handle this in a more robust way (e.g.
|
|
// storing flag on the record or prefixing encrypted strings with enc: or smilar).
|
|
// Doing that would also potentially allow us to support key rotation in the future.
|
|
if secret, ok := integration[secretKey].(string); ok && len(secret) > 0 && !util.CanDecryptSecret(secret) {
|
|
encryptedSecret, err := security.Encrypt([]byte(secret), encryptionKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
integration[secretKey] = encryptedSecret
|
|
} else if original != nil {
|
|
|
|
originalString := original.GetString(key)
|
|
var originalIntegration map[string]interface{}
|
|
if err := json.Unmarshal([]byte(originalString), &originalIntegration); err != nil {
|
|
return err
|
|
}
|
|
if integration == nil {
|
|
continue
|
|
}
|
|
integration[secretKey] = originalIntegration[secretKey]
|
|
}
|
|
}
|
|
|
|
b, err := json.Marshal(integration)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
r.Set(key, string(b))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func changeUserEmailHandler() func(e *core.RecordRequestEmailChangeRequestEvent) error {
|
|
return func(e *core.RecordRequestEmailChangeRequestEvent) error {
|
|
|
|
e.Record.Set("email", e.NewEmail)
|
|
if err := e.App.Save(e.Record); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func listFeedHandler() func(e *core.RecordsListRequestEvent) error {
|
|
return func(e *core.RecordsListRequestEvent) error {
|
|
|
|
for _, r := range e.Records {
|
|
var item *core.Record
|
|
var err error
|
|
|
|
typ := r.GetString("type")
|
|
typ = strings.Trim(typ, "\"")
|
|
|
|
itemId := r.GetString("item")
|
|
itemId = strings.Trim(itemId, "\"")
|
|
|
|
switch typ {
|
|
case string(util.TrailFeed):
|
|
item, err = e.App.FindRecordById("trails", itemId)
|
|
case string(util.ListFeed):
|
|
item, err = e.App.FindRecordById("lists", itemId)
|
|
case string(util.SummitLogFeed):
|
|
item, err = e.App.FindRecordById("summit_logs", itemId)
|
|
}
|
|
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if item == nil {
|
|
continue
|
|
}
|
|
|
|
errs := e.App.ExpandRecord(item, []string{"author"}, nil)
|
|
if len(errs) > 0 {
|
|
return fmt.Errorf("failed to expand author: %v", errs)
|
|
}
|
|
|
|
if typ == string(util.TrailFeed) {
|
|
errs := e.App.ExpandRecord(item, []string{"category"}, nil)
|
|
if len(errs) > 0 {
|
|
return fmt.Errorf("failed to expand category: %v", errs)
|
|
}
|
|
}
|
|
|
|
r.MergeExpand(map[string]any{"item": item})
|
|
}
|
|
return e.Next()
|
|
}
|
|
}
|
|
|
|
func onBeforeServeHandler(client meilisearch.ServiceManager) func(se *core.ServeEvent) error {
|
|
return func(se *core.ServeEvent) error {
|
|
registerRoutes(se, client)
|
|
registerCronJobs(se.App)
|
|
bootstrapData(se.App, client)
|
|
|
|
return se.Next()
|
|
}
|
|
|
|
}
|
|
|
|
func onBootstrapHandler() func(se *core.BootstrapEvent) error {
|
|
return func(e *core.BootstrapEvent) error {
|
|
if err := e.Next(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if e.App.Settings().Meta.AppName == "Acme" {
|
|
e.App.Settings().Meta.AppName = "wanderer"
|
|
}
|
|
if v := os.Getenv("ORIGIN"); v != "" {
|
|
e.App.Settings().Meta.AppURL = v
|
|
}
|
|
if v := os.Getenv("POCKETBASE_SMTP_SENDER_ADRESS"); v != "" {
|
|
e.App.Settings().Meta.SenderAddress = v
|
|
}
|
|
if v := os.Getenv("POCKETBASE_SMTP_SENDER_NAME"); v != "" {
|
|
e.App.Settings().Meta.SenderName = v
|
|
}
|
|
if v := os.Getenv("POCKETBASE_SMTP_ENABLED"); v != "" {
|
|
e.App.Settings().SMTP.Enabled = cast.ToBool(v)
|
|
}
|
|
if v := os.Getenv("POCKETBASE_SMTP_HOST"); v != "" {
|
|
e.App.Settings().SMTP.Host = v
|
|
}
|
|
if v := os.Getenv("POCKETBASE_SMTP_PORT"); v != "" {
|
|
e.App.Settings().SMTP.Port = cast.ToInt(v)
|
|
}
|
|
if v := os.Getenv("POCKETBASE_SMTP_USERNAME"); v != "" {
|
|
e.App.Settings().SMTP.Username = v
|
|
}
|
|
if v := os.Getenv("POCKETBASE_SMTP_PASSWORD"); v != "" {
|
|
e.App.Settings().SMTP.Password = v
|
|
}
|
|
|
|
return e.App.Save(e.App.Settings())
|
|
}
|
|
}
|
|
|
|
func registerRoutes(se *core.ServeEvent, client meilisearch.ServiceManager) {
|
|
se.Router.GET("/health", func(e *core.RequestEvent) error {
|
|
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
|
})
|
|
|
|
se.Router.GET("/public/search/token", func(e *core.RequestEvent) error {
|
|
searchRules := map[string]interface{}{
|
|
"lists": map[string]string{
|
|
"filter": "public = true",
|
|
},
|
|
"trails": map[string]string{
|
|
"filter": "public = true",
|
|
},
|
|
}
|
|
token, err := util.GenerateMeilisearchToken(searchRules, client)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return e.JSON(http.StatusOK, map[string]string{"token": token})
|
|
})
|
|
|
|
se.Router.POST("/integration/strava/token", func(e *core.RequestEvent) error {
|
|
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
|
|
if len(encryptionKey) == 0 {
|
|
return apis.NewBadRequestError("POCKETBASE_ENCRYPTION_KEY not set", nil)
|
|
}
|
|
|
|
var data strava.TokenRequest
|
|
if err := e.BindBody(&data); err != nil {
|
|
return apis.NewBadRequestError("Failed to read request data", err)
|
|
}
|
|
|
|
userId := ""
|
|
if e.Auth != nil {
|
|
userId = e.Auth.Id
|
|
}
|
|
|
|
integrations, err := e.App.FindAllRecords("integrations", dbx.NewExp("user = {:id}", dbx.Params{"id": userId}))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(integrations) == 0 {
|
|
return apis.NewBadRequestError("user has no integration", nil)
|
|
}
|
|
integration := integrations[0]
|
|
stravaString := integration.GetString("strava")
|
|
if len(stravaString) == 0 {
|
|
return apis.NewBadRequestError("strava integration missing", nil)
|
|
}
|
|
var stravaIntegration strava.StravaIntegration
|
|
err = json.Unmarshal([]byte(stravaString), &stravaIntegration)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
decryptedSecret, err := security.Decrypt(stravaIntegration.ClientSecret, encryptionKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
request := strava.TokenRequest{
|
|
ClientID: stravaIntegration.ClientID,
|
|
ClientSecret: string(decryptedSecret),
|
|
Code: data.Code,
|
|
GrantType: "authorization_code",
|
|
}
|
|
r, err := strava.GetStravaToken(request)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if r.AccessToken != "" {
|
|
stravaIntegration.AccessToken = r.AccessToken
|
|
}
|
|
if r.RefreshToken != "" {
|
|
stravaIntegration.RefreshToken = r.RefreshToken
|
|
}
|
|
if r.AccessToken != "" {
|
|
stravaIntegration.ExpiresAt = r.ExpiresAt
|
|
}
|
|
|
|
stravaIntegration.Active = true
|
|
|
|
b, err := json.Marshal(stravaIntegration)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
integration.Set("strava", string(b))
|
|
err = e.App.Save(integration)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return e.JSON(http.StatusOK, nil)
|
|
})
|
|
|
|
se.Router.GET("/integration/komoot/login", func(e *core.RequestEvent) error {
|
|
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
|
|
if len(encryptionKey) == 0 {
|
|
return apis.NewBadRequestError("POCKETBASE_ENCRYPTION_KEY not set", nil)
|
|
}
|
|
|
|
userId := ""
|
|
if e.Auth != nil {
|
|
userId = e.Auth.Id
|
|
}
|
|
|
|
integrations, err := e.App.FindAllRecords("integrations", dbx.NewExp("user = {:id}", dbx.Params{"id": userId}))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(integrations) == 0 {
|
|
return apis.NewBadRequestError("user has no integration", nil)
|
|
}
|
|
integration := integrations[0]
|
|
komootString := integration.GetString("komoot")
|
|
if len(komootString) == 0 {
|
|
return apis.NewBadRequestError("komoot integration missing", nil)
|
|
}
|
|
var komootIntegration komoot.KomootIntegration
|
|
err = json.Unmarshal([]byte(komootString), &komootIntegration)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
decryptedPassword, err := security.Decrypt(komootIntegration.Password, encryptionKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
k := &komoot.KomootApi{}
|
|
|
|
err = k.Login(komootIntegration.Email, string(decryptedPassword))
|
|
if err != nil {
|
|
return apis.NewUnauthorizedError("invalid credentials", nil)
|
|
}
|
|
|
|
return e.JSON(http.StatusOK, nil)
|
|
})
|
|
se.Router.POST("/activitypub/activity/process", federation.ProcessActivity)
|
|
se.Router.GET("/activitypub/actor", func(e *core.RequestEvent) error {
|
|
resource := e.Request.URL.Query().Get("resource")
|
|
resource = strings.TrimPrefix(resource, "acct:")
|
|
|
|
iri := e.Request.URL.Query().Get("iri")
|
|
follows := e.Request.URL.Query().Get("follows") == "true"
|
|
|
|
var userActor *core.Record
|
|
var err error
|
|
if e.Auth != nil {
|
|
userActor, err = e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
var actor *core.Record
|
|
if resource != "" {
|
|
actor, err = federation.GetActorByHandle(e.App, userActor, resource, follows)
|
|
} else {
|
|
actor, err = federation.GetActorByIRI(e.App, userActor, iri, follows)
|
|
}
|
|
if err != nil && actor == nil {
|
|
if strings.HasPrefix(err.Error(), "webfinger") {
|
|
return e.NotFoundError("Not found", err)
|
|
}
|
|
return err
|
|
} else if err != nil && actor != nil {
|
|
if err.Error() == "profile is private" {
|
|
// this is our own profile
|
|
if e.Auth != nil && actor.GetString("user") == e.Auth.Id {
|
|
return e.JSON(http.StatusOK, map[string]any{"actor": actor, "error": nil})
|
|
} else {
|
|
return e.JSON(http.StatusNotFound, map[string]any{"error": "profile is private"})
|
|
}
|
|
}
|
|
// we could not fetch the remote actor so we return our local cached copy
|
|
return e.JSON(http.StatusOK, map[string]any{"actor": actor, "error": err.Error()})
|
|
}
|
|
|
|
return e.JSON(http.StatusOK, map[string]any{"actor": actor, "error": nil})
|
|
})
|
|
se.Router.GET("/activitypub/actor/{id}/{follow}", func(e *core.RequestEvent) error {
|
|
id := e.Request.PathValue("id")
|
|
followType := e.Request.PathValue("follow")
|
|
page := e.Request.URL.Query().Get("page")
|
|
intPage := 0
|
|
|
|
if page != "" {
|
|
var err error
|
|
intPage, err = strconv.Atoi(page)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
actor, err := e.App.FindRecordById("activitypub_actors", id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var userActor *core.Record
|
|
if e.Auth != nil {
|
|
userActor, err = e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
url := actor.GetString(followType)
|
|
|
|
if url == "" {
|
|
return e.BadRequestError("unknown type: "+followType, nil)
|
|
}
|
|
collection, err := federation.FetchCollection(userActor, fmt.Sprintf("%s?page=%d", url, intPage))
|
|
if err != nil {
|
|
if err.Error() == "profile is private" {
|
|
return e.JSON(http.StatusNotFound, map[string]any{"error": "profile is private"})
|
|
}
|
|
return err
|
|
}
|
|
return e.JSON(http.StatusOK, collection)
|
|
})
|
|
se.Router.GET("/activitypub/trail/{id}", func(e *core.RequestEvent) error {
|
|
id := e.Request.PathValue("id")
|
|
|
|
trail, err := e.App.FindRecordById("trails", id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
trailObject, err := util.ObjectFromTrail(e.App, trail, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return e.JSON(http.StatusOK, trailObject)
|
|
})
|
|
se.Router.GET("/activitypub/comment/{id}", func(e *core.RequestEvent) error {
|
|
id := e.Request.PathValue("id")
|
|
|
|
comment, err := e.App.FindRecordById("comments", id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
commentObject, err := util.ObjectFromComment(e.App, comment, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return e.JSON(http.StatusOK, commentObject)
|
|
})
|
|
}
|
|
|
|
func registerCronJobs(app core.App) {
|
|
schedule := os.Getenv("POCKETBASE_CRON_SYNC_SCHEDULE")
|
|
if len(schedule) == 0 {
|
|
schedule = "0 2 * * *"
|
|
}
|
|
|
|
app.Cron().MustAdd("integrations", schedule, func() {
|
|
err := strava.SyncStrava(app)
|
|
if err != nil {
|
|
warning := fmt.Sprintf("Error syncing with strava: %v", err)
|
|
fmt.Println(warning)
|
|
app.Logger().Error(warning)
|
|
}
|
|
err = komoot.SyncKomoot(app)
|
|
if err != nil {
|
|
warning := fmt.Sprintf("Error syncing with komoot: %v", err)
|
|
fmt.Println(warning)
|
|
app.Logger().Error(warning)
|
|
}
|
|
})
|
|
}
|
|
|
|
func bootstrapData(app core.App, client meilisearch.ServiceManager) error {
|
|
bootstrapCategories(app)
|
|
go bootstrapMeilisearchDocuments(app, client)
|
|
return nil
|
|
}
|
|
|
|
func bootstrapCategories(app core.App) error {
|
|
query := app.RecordQuery("categories")
|
|
records := []*core.Record{}
|
|
|
|
if err := query.All(&records); err != nil {
|
|
return err
|
|
}
|
|
if len(records) == 0 {
|
|
collection, _ := app.FindCollectionByNameOrId("categories")
|
|
|
|
categories := []string{"Hiking", "Walking", "Climbing", "Skiing", "Canoeing", "Biking"}
|
|
for _, element := range categories {
|
|
record := core.NewRecord(collection)
|
|
record.Set("name", element)
|
|
f, _ := filesystem.NewFileFromPath("migrations/initial_data/" + strings.ToLower(element) + ".jpg")
|
|
record.Set("img", f)
|
|
err := app.Save(record)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func bootstrapMeilisearchDocuments(app core.App, client meilisearch.ServiceManager) error {
|
|
// --- Trails ---
|
|
const pageSize int64 = 100
|
|
var page int64 = 0
|
|
|
|
// Clear index before re-indexing
|
|
if _, err := client.Index("trails").DeleteAllDocuments(); err != nil {
|
|
return err
|
|
}
|
|
|
|
for {
|
|
trails := []*core.Record{}
|
|
err := app.RecordQuery("trails").
|
|
Limit(pageSize).
|
|
Offset(page * pageSize).
|
|
All(&trails)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(trails) == 0 {
|
|
break
|
|
}
|
|
|
|
if err := util.IndexTrails(app, trails, client); err != nil {
|
|
app.Logger().Warn(fmt.Sprintf("Unable to index trails page %d: %v", page, err))
|
|
continue
|
|
}
|
|
|
|
page++
|
|
}
|
|
|
|
// --- Lists ---
|
|
if _, err := client.Index("lists").DeleteAllDocuments(); err != nil {
|
|
return err
|
|
}
|
|
|
|
page = 0
|
|
for {
|
|
lists := []*core.Record{}
|
|
err := app.RecordQuery("lists").
|
|
Limit(pageSize).
|
|
Offset(page * pageSize).
|
|
All(&lists)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(lists) == 0 {
|
|
break
|
|
}
|
|
|
|
if err := util.IndexLists(app, lists, client); err != nil {
|
|
app.Logger().Warn(fmt.Sprintf("Unable to index list page %d: %v", page, err))
|
|
continue
|
|
}
|
|
|
|
page++
|
|
}
|
|
|
|
return nil
|
|
}
|