Use dynamic tokens & bootstrap meili config on startup (#839)
* initial commit * persist meili token in cookie * handle login/logout cookie invalidation * fix review suggestions --------- Co-authored-by: Christian Beutel <>
This commit is contained in:
88
db/main.go
88
db/main.go
@@ -1009,20 +1009,37 @@ func registerRoutes(se *core.ServeEvent, client meilisearch.ServiceManager) {
|
|||||||
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||||
})
|
})
|
||||||
|
|
||||||
se.Router.GET("/public/search/token", func(e *core.RequestEvent) error {
|
se.Router.GET("/search/token", func(e *core.RequestEvent) error {
|
||||||
searchRules := map[string]interface{}{
|
searchRules := map[string]interface{}{
|
||||||
"lists": map[string]string{
|
"lists": map[string]string{"filter": "public = true"},
|
||||||
"filter": "public = true",
|
"trails": map[string]string{"filter": "public = true"},
|
||||||
},
|
|
||||||
"trails": map[string]string{
|
|
||||||
"filter": "public = true",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
token, err := util.GenerateMeilisearchToken(searchRules, client)
|
|
||||||
|
if e.Auth != nil {
|
||||||
|
userId := e.Auth.Id
|
||||||
|
userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return e.JSON(http.StatusOK, map[string]string{"token": token})
|
|
||||||
|
searchRules = map[string]any{
|
||||||
|
"lists": map[string]string{
|
||||||
|
"filter": "public = true OR author = " + userActor.Id + " OR shares = " + userId,
|
||||||
|
},
|
||||||
|
"trails": map[string]string{
|
||||||
|
"filter": "public = true OR author = " + userActor.Id + " OR shares = " + userId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := util.GenerateMeilisearchToken(searchRules, client)
|
||||||
|
if err != nil {
|
||||||
|
return e.InternalServerError("Failed to generate search token", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.JSON(http.StatusOK, map[string]string{
|
||||||
|
"token": token,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
se.Router.POST("/integration/strava/token", func(e *core.RequestEvent) error {
|
se.Router.POST("/integration/strava/token", func(e *core.RequestEvent) error {
|
||||||
@@ -1277,6 +1294,7 @@ func registerCronJobs(app core.App) {
|
|||||||
|
|
||||||
func bootstrapData(app core.App, client meilisearch.ServiceManager) error {
|
func bootstrapData(app core.App, client meilisearch.ServiceManager) error {
|
||||||
bootstrapCategories(app)
|
bootstrapCategories(app)
|
||||||
|
bootstrapMeilisearchConfig(client)
|
||||||
go bootstrapMeilisearchDocuments(app, client)
|
go bootstrapMeilisearchDocuments(app, client)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1366,3 +1384,55 @@ func bootstrapMeilisearchDocuments(app core.App, client meilisearch.ServiceManag
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func bootstrapMeilisearchConfig(client meilisearch.ServiceManager) {
|
||||||
|
configs := map[string]meilisearch.Settings{
|
||||||
|
"trails": {
|
||||||
|
SearchableAttributes: []string{"author_name", "name", "description", "location", "tags"},
|
||||||
|
FilterableAttributes: []string{
|
||||||
|
"_geo", "author", "category", "completed", "date", "difficulty",
|
||||||
|
"distance", "elevation_gain", "elevation_loss", "likes", "public",
|
||||||
|
"shares", "tags",
|
||||||
|
},
|
||||||
|
SortableAttributes: []string{
|
||||||
|
"author", "created", "date", "difficulty", "distance",
|
||||||
|
"duration", "elevation_gain", "elevation_loss", "like_count", "name",
|
||||||
|
},
|
||||||
|
RankingRules: []string{"words", "typo", "proximity", "attribute", "sort", "exactness"},
|
||||||
|
},
|
||||||
|
"lists": {
|
||||||
|
SearchableAttributes: []string{"*"},
|
||||||
|
FilterableAttributes: []string{"author", "public", "shares"},
|
||||||
|
SortableAttributes: []string{"created", "name"},
|
||||||
|
RankingRules: []string{"words", "typo", "proximity", "attribute", "sort", "exactness"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for indexName, settings := range configs {
|
||||||
|
_, err := client.GetIndex(indexName)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Index [%s] not found, creating it...", indexName)
|
||||||
|
task, err := client.CreateIndex(&meilisearch.IndexConfig{
|
||||||
|
Uid: indexName,
|
||||||
|
PrimaryKey: "id",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to create index [%s]: %v", indexName, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.WaitForTask(task.TaskUID, 0)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error waiting for index creation [%s]: %v", indexName, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.Index(indexName).UpdateSettings(&settings)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to sync settings for index [%s]: %v", indexName, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("Settings synced for index [%s]", indexName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
45
db/migrations/1772036961_updated_users.go
Normal file
45
db/migrations/1772036961_updated_users.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
m "github.com/pocketbase/pocketbase/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
m.Register(func(app core.App) error {
|
||||||
|
collection, err := app.FindCollectionByNameOrId("_pb_users_auth_")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.Fields.RemoveById("dlzhxcn2")
|
||||||
|
|
||||||
|
return app.Save(collection)
|
||||||
|
}, func(app core.App) error {
|
||||||
|
collection, err := app.FindCollectionByNameOrId("_pb_users_auth_")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// add field
|
||||||
|
if err := collection.Fields.AddMarshaledJSONAt(7, []byte(`{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "dlzhxcn2",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "token",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
}`)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return app.Save(collection)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -6,10 +6,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path"
|
"path"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/meilisearch/meilisearch-go"
|
"github.com/meilisearch/meilisearch-go"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
@@ -456,27 +456,37 @@ func UpdateListShares(listId string, shares []string, client meilisearch.Service
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GenerateMeilisearchToken(rules map[string]interface{}, client meilisearch.ServiceManager) (resp string, err error) {
|
func GenerateMeilisearchToken(rules map[string]interface{}, client meilisearch.ServiceManager) (string, error) {
|
||||||
apiKeyUid := ""
|
var apiKeyUid string
|
||||||
apiKey := ""
|
var apiKey string
|
||||||
|
|
||||||
|
keys, err := client.GetKeys(&meilisearch.KeysQuery{Limit: 20})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("meilisearch connection error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if keys, err := client.GetKeys(nil); err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
} else {
|
|
||||||
for _, k := range keys.Results {
|
for _, k := range keys.Results {
|
||||||
if k.Name == "Default Search API Key" {
|
for _, action := range k.Actions {
|
||||||
|
if action == "search" || k.Name == "Default Search API Key" {
|
||||||
apiKeyUid = k.UID
|
apiKeyUid = k.UID
|
||||||
apiKey = k.Key
|
apiKey = k.Key
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if apiKey != "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(apiKey) == 0 || len(apiKeyUid) == 0 {
|
if apiKey == "" || apiKeyUid == "" {
|
||||||
return "", errors.New("unable to locate meilisearch API key")
|
return "", errors.New("unable to locate a valid search API key")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expiresAt := time.Now().Add(24 * time.Hour)
|
||||||
|
|
||||||
options := &meilisearch.TenantTokenOptions{
|
options := &meilisearch.TenantTokenOptions{
|
||||||
APIKey: apiKey,
|
APIKey: apiKey,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
return client.GenerateTenantToken(apiKeyUid, rules, options)
|
return client.GenerateTenantToken(apiKeyUid, rules, options)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { MeiliSearch } from 'meilisearch'
|
|||||||
import { locale } from 'svelte-i18n'
|
import { locale } from 'svelte-i18n'
|
||||||
import type { Actor } from '$lib/models/activitypub/actor'
|
import type { Actor } from '$lib/models/activitypub/actor'
|
||||||
import { normalizeLocale } from '$lib/i18n/locales'
|
import { normalizeLocale } from '$lib/i18n/locales'
|
||||||
|
import { handleError } from '$lib/util/api_util'
|
||||||
|
|
||||||
|
|
||||||
function csrf(allowedPaths: string[]): Handle {
|
function csrf(allowedPaths: string[]): Handle {
|
||||||
@@ -49,15 +50,48 @@ function isFormContentType(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let publicMeilisearchKey: string | undefined = undefined;
|
|
||||||
|
|
||||||
const auth: Handle = async ({ event, resolve }) => {
|
const auth: Handle = async ({ event, resolve }) => {
|
||||||
const pb = new PocketBase(envPub.PUBLIC_POCKETBASE_URL)
|
const pb = new PocketBase(envPub.PUBLIC_POCKETBASE_URL)
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
|
||||||
// load the store data from the request cookie string
|
// load the store data from the request cookie string
|
||||||
pb.authStore.loadFromCookie(event.request.headers.get('cookie') || '')
|
pb.authStore.loadFromCookie(event.request.headers.get('cookie') || '')
|
||||||
|
|
||||||
const url = new URL(event.request.url);
|
const secure = event.url.protocol === "https:"
|
||||||
|
let meiliCookie = event.cookies.get('meilisearch_token');
|
||||||
|
let meilisearchToken: string | undefined = undefined;
|
||||||
|
const currentUserId = pb.authStore.record?.id || 'public';
|
||||||
|
|
||||||
|
if (meiliCookie) {
|
||||||
|
const [token, ownerId] = meiliCookie.split('|');
|
||||||
|
|
||||||
|
if (ownerId === currentUserId) {
|
||||||
|
meilisearchToken = token;
|
||||||
|
} else {
|
||||||
|
// Identity mismatch (e.g. just logged in/out)
|
||||||
|
event.cookies.delete('meilisearch_token', { path: '/' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!meilisearchToken) {
|
||||||
|
try {
|
||||||
|
const tokenResponse = await pb.send("/search/token", { method: "GET", fetch: event.fetch });
|
||||||
|
meilisearchToken = tokenResponse.token
|
||||||
|
event.cookies.set('meilisearch_token', `${meilisearchToken}|${currentUserId}`, {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: false,
|
||||||
|
maxAge: 60 * 60 * 24,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: secure
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (url.pathname.startsWith("/api")) {
|
||||||
|
return handleError(e)
|
||||||
|
}
|
||||||
|
throw error(500, "Failed to invalidate meilisearch token: " + e)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
// validate the user existence and if the path is acceesible
|
// validate the user existence and if the path is acceesible
|
||||||
if (!pb.authStore.record && isRouteProtected(url)) {
|
if (!pb.authStore.record && isRouteProtected(url)) {
|
||||||
@@ -79,28 +113,21 @@ const auth: Handle = async ({ event, resolve }) => {
|
|||||||
} catch (_) {
|
} catch (_) {
|
||||||
// clear the auth store on failed refresh
|
// clear the auth store on failed refresh
|
||||||
pb.authStore.clear()
|
pb.authStore.clear()
|
||||||
|
event.cookies.delete('meilisearch_token', { path: '/' });
|
||||||
}
|
}
|
||||||
|
|
||||||
let meiliApiKey: string = "";
|
|
||||||
let settings: Settings | undefined;
|
let settings: Settings | undefined;
|
||||||
let actor: Actor | undefined;
|
let actor: Actor | undefined;
|
||||||
|
|
||||||
if (pb.authStore.record) {
|
if (pb.authStore.record) {
|
||||||
meiliApiKey = pb.authStore.record.token
|
|
||||||
settings = await pb.collection('settings').getFirstListItem<Settings>(`user="${pb.authStore.record.id}"`, { requestKey: null })
|
settings = await pb.collection('settings').getFirstListItem<Settings>(`user="${pb.authStore.record.id}"`, { requestKey: null })
|
||||||
actor = await pb.collection("activitypub_actors").getFirstListItem(`isLocal=1&&user='${pb.authStore.record.id}'`)
|
actor = await pb.collection("activitypub_actors").getFirstListItem(`isLocal=1&&user='${pb.authStore.record.id}'`)
|
||||||
} else {
|
|
||||||
if (!publicMeilisearchKey) {
|
|
||||||
const response = await pb.send("/public/search/token", { method: "GET", fetch: event.fetch });
|
|
||||||
publicMeilisearchKey = response.token;
|
|
||||||
}
|
|
||||||
|
|
||||||
meiliApiKey = publicMeilisearchKey!;
|
|
||||||
}
|
}
|
||||||
const meiliHost = env.MEILI_URL;
|
const meiliHost = env.MEILI_URL;
|
||||||
if (!meiliHost) {
|
if (!meiliHost) {
|
||||||
throw error(500, "Missing MEILI_URL");
|
throw error(500, "Missing MEILI_URL");
|
||||||
}
|
}
|
||||||
const ms = new MeiliSearch({ host: meiliHost, apiKey: meiliApiKey });
|
const ms = new MeiliSearch({ host: meiliHost, apiKey: meilisearchToken });
|
||||||
|
|
||||||
event.locals.ms = ms
|
event.locals.ms = ms
|
||||||
event.locals.pb = pb
|
event.locals.pb = pb
|
||||||
@@ -124,12 +151,11 @@ const auth: Handle = async ({ event, resolve }) => {
|
|||||||
const response = await resolve(event)
|
const response = await resolve(event)
|
||||||
|
|
||||||
// send back the default 'pb_auth' cookie to the client with the latest store state
|
// send back the default 'pb_auth' cookie to the client with the latest store state
|
||||||
const secure = event.url.protocol === "https:"
|
const pbCookie = pb.authStore.exportToCookie({ httpOnly: false, secure: secure, sameSite: "Lax" });
|
||||||
|
if (pbCookie) {
|
||||||
|
response.headers.append('set-cookie', pbCookie);
|
||||||
|
}
|
||||||
|
|
||||||
response.headers.set(
|
|
||||||
'set-cookie',
|
|
||||||
pb.authStore.exportToCookie({ httpOnly: false, secure: secure, sameSite: "Lax" })
|
|
||||||
)
|
|
||||||
|
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user