diff --git a/db/main.go b/db/main.go index 406f4811..33fe73a0 100644 --- a/db/main.go +++ b/db/main.go @@ -136,6 +136,8 @@ func setupEventHandlers(app *pocketbase.PocketBase, client meilisearch.ServiceMa app.OnRecordsListRequest("feed", "profile_feed").BindFunc(listFeedHandler()) + app.OnRecordCreate("api_tokens").BindFunc(createAPITokenHandler()) + app.OnRecordCreateRequest().BindFunc(sanitizeHTML()) app.OnRecordUpdateRequest().BindFunc(sanitizeHTML()) @@ -955,6 +957,22 @@ func listFeedHandler() func(e *core.RecordsListRequestEvent) error { } } +func createAPITokenHandler() func(e *core.RecordEvent) error { + return func(e *core.RecordEvent) error { + rawToken := "wanderer_key_" + security.RandomString(32) + + hashedKey := security.SHA256(rawToken) + + e.Record.Set("token", hashedKey) + + // Temporarily store rawToken so we can display it once to the user + e.Record.WithCustomData(true) + e.Record.Set("rawToken", rawToken) + + return e.Next() + } +} + func onBeforeServeHandler(client meilisearch.ServiceManager) func(se *core.ServeEvent) error { return func(se *core.ServeEvent) error { registerRoutes(se, client) @@ -1009,6 +1027,46 @@ func registerRoutes(se *core.ServeEvent, client meilisearch.ServiceManager) { return e.JSON(http.StatusOK, map[string]string{"status": "ok"}) }) + se.Router.POST("/auth/token", func(e *core.RequestEvent) error { + var data struct { + APIToken string `json:"api_token"` + } + if err := e.BindBody(&data); err != nil { + return apis.NewBadRequestError("Failed to read request data", err) + } + + hashedAPIToken := security.SHA256(data.APIToken) + + tokenRecord, err := e.App.FindFirstRecordByFilter( + "api_tokens", + "token = {:hash}", + map[string]any{"hash": hashedAPIToken}, + ) + + if err != nil { + return apis.NewNotFoundError("Invalid or revoked API token", nil) + } + if !tokenRecord.GetDateTime("expiration").IsZero() && + tokenRecord.GetDateTime("expiration").Time().Before(time.Now()) { + return apis.NewBadRequestError("Key has expired", nil) + } + + tokenRecord.Set("last_used", time.Now()) + if err := e.App.Save(tokenRecord); err != nil { + return err + } + + userRecord, _ := e.App.FindRecordById("users", tokenRecord.GetString("user")) + token, err := userRecord.NewAuthToken() + if err != nil { + return err + } + return e.JSON(http.StatusOK, map[string]any{ + "token": token, + "record": userRecord, + }) + }) + se.Router.GET("/search/token", func(e *core.RequestEvent) error { searchRules := map[string]interface{}{ "lists": map[string]string{"filter": "public = true"}, diff --git a/db/migrations/1772293922_created_api_tokens.go b/db/migrations/1772293922_created_api_tokens.go new file mode 100644 index 00000000..3085d8fe --- /dev/null +++ b/db/migrations/1772293922_created_api_tokens.go @@ -0,0 +1,138 @@ +package migrations + +import ( + "encoding/json" + + "github.com/pocketbase/pocketbase/core" + m "github.com/pocketbase/pocketbase/migrations" +) + +func init() { + m.Register(func(app core.App) error { + jsonData := `{ + "createRule": "user = @request.auth.id", + "deleteRule": "user = @request.auth.id", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": true, + "id": "text1597481275", + "max": 64, + "min": 64, + "name": "token", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "date617435213", + "max": "", + "min": "", + "name": "expiration", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "date4016875332", + "max": "", + "min": "", + "name": "last_used", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "hidden": false, + "id": "relation2375276105", + "maxSelect": 1, + "minSelect": 0, + "name": "user", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_3525142174", + "indexes": [], + "listRule": "user = @request.auth.id", + "name": "api_tokens", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": "user = @request.auth.id" + }` + + collection := &core.Collection{} + if err := json.Unmarshal([]byte(jsonData), &collection); err != nil { + return err + } + + return app.Save(collection) + }, func(app core.App) error { + collection, err := app.FindCollectionByNameOrId("pbc_3525142174") + if err != nil { + return err + } + + return app.Delete(collection) + }) +} diff --git a/web/src/hooks.server.ts b/web/src/hooks.server.ts index 9c451885..0b8d4c15 100644 --- a/web/src/hooks.server.ts +++ b/web/src/hooks.server.ts @@ -54,8 +54,30 @@ const auth: Handle = async ({ event, resolve }) => { const pb = new PocketBase(envPub.PUBLIC_POCKETBASE_URL) const url = new URL(event.request.url); - // load the store data from the request cookie string - pb.authStore.loadFromCookie(event.request.headers.get('cookie') || '') + // Handle API token based auth for API requests + if (event.request.headers.has("Authorization") && url.pathname.startsWith("/api")) { + const authHeader = event.request.headers.get("Authorization") as string; + const apiToken = authHeader.replace("Bearer ", ""); + if (apiToken.startsWith("wanderer_key")) { + try { + const authData = await pb.send("/auth/token", { + method: "POST", + body: JSON.stringify({ + api_token: apiToken + }), + fetch: event.fetch, + }) + pb.authStore.save(authData.token, authData.record) + } catch (e) { + throw error(500, "Failed to verify API token " + e) + } + + } + } else { + // load the store data from the request cookie string + pb.authStore.loadFromCookie(event.request.headers.get('cookie') || '') + } + const secure = event.url.protocol === "https:" let meiliCookie = event.cookies.get('meilisearch_token'); diff --git a/web/src/lib/components/base/datepicker.svelte b/web/src/lib/components/base/datepicker.svelte index 2b07fec7..6e847b63 100644 --- a/web/src/lib/components/base/datepicker.svelte +++ b/web/src/lib/components/base/datepicker.svelte @@ -1,12 +1,14 @@ @@ -32,6 +36,8 @@ class:border-red-400={(error?.length ?? 0) > 0} class:bg-input-background-error={(error?.length ?? 0) > 0} type="date" + {min} + {max} bind:value {onchange} /> diff --git a/web/src/lib/components/settings/api_token_modal.svelte b/web/src/lib/components/settings/api_token_modal.svelte new file mode 100644 index 00000000..77b9aa78 --- /dev/null +++ b/web/src/lib/components/settings/api_token_modal.svelte @@ -0,0 +1,88 @@ + + + + {#snippet content()} +
+ + + {#if expires} + + {/if} +
+ {/snippet} + {#snippet footer()} +
+ + +
+ {/snippet}
diff --git a/web/src/lib/components/settings/api_token_success_modal.svelte b/web/src/lib/components/settings/api_token_success_modal.svelte new file mode 100644 index 00000000..be48419a --- /dev/null +++ b/web/src/lib/components/settings/api_token_success_modal.svelte @@ -0,0 +1,64 @@ + + + + {#snippet content()} +

+ Please copy your new API token now. For your security, we cannot + show it to you again. If you lose this token, you will need to + delete it and generate a new one. +

+
+
+ +
+ +
+ {/snippet} + {#snippet footer()} + + {/snippet}
diff --git a/web/src/lib/i18n/locales/de.json b/web/src/lib/i18n/locales/de.json index 147e82fd..6b338206 100644 --- a/web/src/lib/i18n/locales/de.json +++ b/web/src/lib/i18n/locales/de.json @@ -24,6 +24,8 @@ "amenity": "", "ammenity": "Einrichtung", "api-documentation": "API Dokumentation", + "api-tokens": "", + "api-tokens-hint": "", "apply-user-settings": "", "attraction": "Sehenswürdigkeit", "author": "Autor", @@ -139,10 +141,12 @@ "entry": "Eintrag", "error-copying-trail": "Fehler beim Kopieren der Route", "error-creating-user": "Fehler beim Erstellen des Nutzers", + "error-deleting-token": "", "error-disabling-strava-integration": "Fehler beim Deaktivieren der Stravaintegration", "error-during-login": "Fehler beim Login", "error-during-password-reset": "Email zum Zurücksetzen des Passworts konnte nicht versandt werden", "error-exporting-trail": "Fehler beim Exportieren der Route", + "error-generating-token": "", "error-liking-trail": "Error liking trail", "error-logging-in-to-komoot": "Fehler bei der Anmeldung bei komoot", "error-posting-comment": "Fehler beim Posten des Kommentars", @@ -155,6 +159,8 @@ "error-updating-strava-integration": "Fehler bei Aktualisierung der komoot-Integration", "est-duration": "Gesch. Dauer", "everyone-with-the-link": "Jeder mit dem Link", + "expiration": "", + "expires": "", "explore": "Erkunden", "explore-some-trails": "Erkunde einige Routen", "export": "Exportieren", @@ -183,6 +189,7 @@ "from-url": "Aus einer URL", "garage": "Auto Reparatur", "gas-station": "Tankstelle", + "generate-new-token": "", "german": "Deutsch", "get-position-from-exif": "Koordinaten aus EXIF Daten", "get-started": "Los geht’s", @@ -222,6 +229,7 @@ "keep-original": "", "keep-private": "Ohne Veröffentlichung fortfahren", "language": "Sprache", + "last-used": "", "latitude": "Breitengrad", "layer": "{n, plural, =1 {Ebene} other {Ebenen}}", "license": "Lizenz", @@ -266,13 +274,16 @@ "n-years-ago": "vor {n} Jahren", "name": "Name", "near": "Nahe", + "never": "", "new-list": "Neue Liste", "new-password": "Neues Passwort", "new-password-error": "Fehler beim Speichern des Passworts", "new-password-success": "Neues Passwort wurde gesetzt", "new-password-text": "Wähle ein neues Passwort", + "new-token-generated": "", "new-trail": "Neue Route", "no-account": "Du hast noch kein Konto?", + "no-api-tokens": "", "no-comments-so-far": "Bisher keine Kommentare", "no-data": "Keine Daten", "no-description-for-now": "Noch keine Beschreibung", diff --git a/web/src/lib/i18n/locales/en.json b/web/src/lib/i18n/locales/en.json index 62de79fc..945a88a8 100644 --- a/web/src/lib/i18n/locales/en.json +++ b/web/src/lib/i18n/locales/en.json @@ -24,6 +24,8 @@ "amenity": "Amenity", "ammenity": "", "api-documentation": "API Documentation", + "api-tokens": "API Tokens", + "api-tokens-hint": "API Tokens can be used to grant 3rd party applications access to your wanderer account.", "apply-user-settings": "Apply user settings", "attraction": "Attraction", "author": "Author", @@ -139,10 +141,12 @@ "entry": "Entry", "error-copying-trail": "Error copying trail", "error-creating-user": "Error creating user", + "error-deleting-token": "Error deleting token", "error-disabling-strava-integration": "Error disabling strava integration", "error-during-login": "Error during login", "error-during-password-reset": "Unable to send password reset email", "error-exporting-trail": "Error exporting trail", + "error-generating-token": "Error generating token", "error-liking-trail": "Error liking trail", "error-logging-in-to-komoot": "Error logging in to komoot", "error-posting-comment": "Error posting comment", @@ -155,6 +159,8 @@ "error-updating-strava-integration": "Error updating komoot integration", "est-duration": "Est. duration", "everyone-with-the-link": "Everyone with the link", + "expiration": "Expiration", + "expires": "Expires", "explore": "Explore", "explore-some-trails": "Explore some trails", "export": "Export", @@ -183,6 +189,7 @@ "from-url": "From URL", "garage": "Garage", "gas-station": "Gas station", + "generate-new-token": "Generate new token", "german": "German", "get-position-from-exif": "Get coordinates from EXIF data", "get-started": "Get started", @@ -222,6 +229,7 @@ "keep-original": "Keep original", "keep-private": "Keep private", "language": "Language", + "last-used": "Last used", "latitude": "Latitude", "layer": "{n, plural, =1 {Layer} other {Layers}}", "license": "License", @@ -266,13 +274,16 @@ "n-years-ago": "{n} years ago", "name": "Name", "near": "Near", + "never": "Never", "new-list": "New List", "new-password": "New password", "new-password-error": "Error setting new password", "new-password-success": "The new password has been set", "new-password-text": "Set a new password", + "new-token-generated": "New API Token generated", "new-trail": "New Trail", "no-account": "Don't have an account?", + "no-api-tokens": "You have no API Tokens", "no-comments-so-far": "No comments so far", "no-data": "No data", "no-description-for-now": "No description for now", diff --git a/web/src/lib/i18n/locales/es.json b/web/src/lib/i18n/locales/es.json index c4a63268..5fa19c53 100644 --- a/web/src/lib/i18n/locales/es.json +++ b/web/src/lib/i18n/locales/es.json @@ -24,6 +24,8 @@ "amenity": "", "ammenity": "Servicios", "api-documentation": "Documentación API", + "api-tokens": "", + "api-tokens-hint": "", "apply-user-settings": "", "attraction": "Atracción", "author": "Autor", @@ -37,6 +39,7 @@ "basic-info": "Información básica", "basque": "Vasco", "before": "Antes", + "behavior": "", "bicycle-parking": "Aparcamiento de bicicletas", "bicycle-rental": "Alquiler de bicicletas", "bicycle-shop": "Tienda de bicicletas", @@ -138,10 +141,12 @@ "entry": "Entrada", "error-copying-trail": "", "error-creating-user": "Error creando el usuario", + "error-deleting-token": "", "error-disabling-strava-integration": "Error al desactivar la integración de strava", "error-during-login": "Error durante el acceso", "error-during-password-reset": "Imposible enviar la contraseña de restablecimiento al correo electrónico", "error-exporting-trail": "Error exportando la ruta", + "error-generating-token": "", "error-liking-trail": "Error al dar \"me gusta\" a la ruta", "error-logging-in-to-komoot": "Error al iniciar sesión en komoot", "error-posting-comment": "Error publicando el comentario", @@ -154,6 +159,8 @@ "error-updating-strava-integration": "Error al actualizar la integración con komoot", "est-duration": "Duración estimada", "everyone-with-the-link": "Cualquier persona con el enlace", + "expiration": "", + "expires": "", "explore": "Explora", "explore-some-trails": "Explora alguna ruta", "export": "Exportar", @@ -182,6 +189,7 @@ "from-url": "Desde URL", "garage": "Garaje", "gas-station": "Gasolinera", + "generate-new-token": "", "german": "Alemán", "get-position-from-exif": "Obtener las coordenadas de los datos EXIF", "get-started": "Iniciar", @@ -221,6 +229,7 @@ "keep-original": "", "keep-private": "Keep private", "language": "Idioma", + "last-used": "", "latitude": "Latitud", "layer": "{n, plural, one {}=1 {Lista} other {Listas}}", "license": "Licencia", @@ -265,13 +274,16 @@ "n-years-ago": "hace {n} años", "name": "Nombre", "near": "Cerca", + "never": "", "new-list": "Nueva lista", "new-password": "Nueva contraseña", "new-password-error": "Error estableciendo la nueva contraseña", "new-password-success": "La nueva contraseña ha sido configurada", "new-password-text": "Configura una nueva contraseña", + "new-token-generated": "", "new-trail": "Nueva Ruta", "no-account": "¿No tienes una cuenta?", + "no-api-tokens": "", "no-comments-so-far": "Ningún comentario todavía", "no-data": "No datos", "no-description-for-now": "Ninguna descripción de momento", diff --git a/web/src/lib/i18n/locales/eu.json b/web/src/lib/i18n/locales/eu.json index ab02876e..aa2a9256 100644 --- a/web/src/lib/i18n/locales/eu.json +++ b/web/src/lib/i18n/locales/eu.json @@ -24,6 +24,8 @@ "amenity": "Altimetria", "ammenity": "", "api-documentation": "API dokumentazioa", + "api-tokens": "", + "api-tokens-hint": "", "apply-user-settings": "", "attraction": "Erakarmena", "author": "Egilea", @@ -37,6 +39,7 @@ "basic-info": "Oinarrizko informazioa", "basque": "Euskara", "before": "Aurretik", + "behavior": "", "bicycle-parking": "Bizikleta-parkina", "bicycle-rental": "Bizikleta-alokairua", "bicycle-shop": "Bizikleta-denda", @@ -138,10 +141,12 @@ "entry": "Sarrera", "error-copying-trail": "", "error-creating-user": "Errorea erabiltzailea sortzen", + "error-deleting-token": "", "error-disabling-strava-integration": "Errorea stravarekin integrazioa desaktibatzean", "error-during-login": "Errorea sartzean", "error-during-password-reset": "Ezin izan da pasahitza berrezartzeko mezua bidali", "error-exporting-trail": "Errorea ibilbidea esportatzean", + "error-generating-token": "", "error-liking-trail": "Errorea ibilbidea atsegitean", "error-logging-in-to-komoot": "Errorea komoot-en login egitean", "error-posting-comment": "Errorea iruzkina egitean", @@ -154,6 +159,8 @@ "error-updating-strava-integration": "Errorea komoot integrazioa eguneratzean", "est-duration": "Ustezko iraupena", "everyone-with-the-link": "Esteka duen edonor", + "expiration": "", + "expires": "", "explore": "Arakatu", "explore-some-trails": "Arakatu ibilbide batzuk", "export": "Esportatu", @@ -182,6 +189,7 @@ "from-url": "URL batetik", "garage": "Garajea", "gas-station": "Gasolindegia", + "generate-new-token": "", "german": "Alemaniera", "get-position-from-exif": "Lortu koordenatuak EXIF datuetatik", "get-started": "Hasi", @@ -221,6 +229,7 @@ "keep-original": "", "keep-private": "Keep private", "language": "Hizkuntza", + "last-used": "", "latitude": "Latitudea", "layer": "{n, plural, one {}=1 {geruza} other {geruza}}", "license": "Lizentzia", @@ -265,13 +274,16 @@ "n-years-ago": "Orain dela {n} urte", "name": "Izena", "near": "Gertu", + "never": "", "new-list": "Zerrenda berria", "new-password": "Pasahitz berria", "new-password-error": "Errorea pasahitza berria ezartzean", "new-password-success": "Pasahitz berria ezarri da", "new-password-text": "Pasahitz berria sortu", + "new-token-generated": "", "new-trail": "Ibilaldi berria", "no-account": "Ez duzu konturik?", + "no-api-tokens": "", "no-comments-so-far": "Ez dago iruzkinik", "no-data": "Ez dago daturik", "no-description-for-now": "Ez dago deskribapenik", diff --git a/web/src/lib/i18n/locales/fr.json b/web/src/lib/i18n/locales/fr.json index e6079789..4652c8ac 100644 --- a/web/src/lib/i18n/locales/fr.json +++ b/web/src/lib/i18n/locales/fr.json @@ -24,6 +24,8 @@ "amenity": "", "ammenity": "Aménagement", "api-documentation": "Documentation API", + "api-tokens": "", + "api-tokens-hint": "", "apply-user-settings": "", "attraction": "Attraction", "author": "Auteur", @@ -37,6 +39,7 @@ "basic-info": "Informations de base", "basque": "Basque", "before": "Avant le", + "behavior": "", "bicycle-parking": "Parking vélo", "bicycle-rental": "Location de vélos", "bicycle-shop": "Magasin de vélos", @@ -138,10 +141,12 @@ "entry": "Entrée", "error-copying-trail": "", "error-creating-user": "Erreur durant la création de l'utilisateur", + "error-deleting-token": "", "error-disabling-strava-integration": "Erreur lors de la désactivation de l'intégration Strava", "error-during-login": "Erreur durant la connexion", "error-during-password-reset": "Impossible d'envoyer l'e-mail de réinitialisation du mot de passe", "error-exporting-trail": "Erreur lors de l'export de l'itinéraire", + "error-generating-token": "", "error-liking-trail": "Erreur de like de l'itinéraire", "error-logging-in-to-komoot": "Erreur de connexion à Komoot", "error-posting-comment": "Erreur lors de la publication du commentaire", @@ -154,6 +159,8 @@ "error-updating-strava-integration": "Erreur lors de la mise à jour de l'intégration Komoot", "est-duration": "Temps estimé", "everyone-with-the-link": "Tout le monde avec ce lien", + "expiration": "", + "expires": "", "explore": "Explorer", "explore-some-trails": "Explorer les itinéraires", "export": "Exporter", @@ -182,6 +189,7 @@ "from-url": "Depuis une URL", "garage": "Garage", "gas-station": "Station-service", + "generate-new-token": "", "german": "Allemand", "get-position-from-exif": "Obtenir les coordonnées à partir des données EXIF", "get-started": "C'est parti", @@ -221,6 +229,7 @@ "keep-original": "", "keep-private": "Keep private", "language": "Langue", + "last-used": "", "latitude": "Latitude", "layer": "{n, plural, =1 {Calque} other {Calques}}", "license": "Licence", @@ -265,13 +274,16 @@ "n-years-ago": "il y a {n} ans", "name": "Nom de l'itinéraire", "near": "À proximité de", + "never": "", "new-list": "Nouvelle liste", "new-password": "Nouveau mot de passe", "new-password-error": "Erreur lors de l'enregistrement du nouveau mot de passe", "new-password-success": "Le nouveau mot de passe a été enregistré", "new-password-text": "Définir un nouveau mot de passe", + "new-token-generated": "", "new-trail": "Nouvel itinéraire", "no-account": "Pas encore de compte ?", + "no-api-tokens": "", "no-comments-so-far": "Aucun commentaire pour l'instant", "no-data": "Pas de données", "no-description-for-now": "Pas de description pour le moment", diff --git a/web/src/lib/i18n/locales/hu.json b/web/src/lib/i18n/locales/hu.json index 8c9495e8..bab1f8a8 100644 --- a/web/src/lib/i18n/locales/hu.json +++ b/web/src/lib/i18n/locales/hu.json @@ -24,6 +24,8 @@ "amenity": "Amenity", "ammenity": "", "api-documentation": "API Dokumentáció", + "api-tokens": "", + "api-tokens-hint": "", "apply-user-settings": "", "attraction": "Attraction", "author": "Author", @@ -37,6 +39,7 @@ "basic-info": "Alap információk", "basque": "Basque", "before": "Before", + "behavior": "", "bicycle-parking": "Bicycle Parking", "bicycle-rental": "Bicycle Rental", "bicycle-shop": "Bicycle Shop", @@ -138,10 +141,12 @@ "entry": "Bejegyzés", "error-copying-trail": "", "error-creating-user": "Hiba felhasználó hozzáadása közben", + "error-deleting-token": "", "error-disabling-strava-integration": "Error disabling strava integration", "error-during-login": "Hiba bejelentkezés közben", "error-during-password-reset": "Unable to send password reset email", "error-exporting-trail": "Error exporting trail", + "error-generating-token": "", "error-liking-trail": "Error liking trail", "error-logging-in-to-komoot": "Error logging in to komoot", "error-posting-comment": "Error posting comment", @@ -154,6 +159,8 @@ "error-updating-strava-integration": "Error updating komoot integration", "est-duration": "Becsült időtartam", "everyone-with-the-link": "Everyone with the link", + "expiration": "", + "expires": "", "explore": "Felfedezés", "explore-some-trails": "Fedezzen fel néhány ösvényt", "export": "Export", @@ -182,6 +189,7 @@ "from-url": "From URL", "garage": "Garage", "gas-station": "Gas station", + "generate-new-token": "", "german": "Német", "get-position-from-exif": "Get coordinates from EXIF data", "get-started": "Get started", @@ -221,6 +229,7 @@ "keep-original": "", "keep-private": "Keep private", "language": "Nyelf", + "last-used": "", "latitude": "Szélesség", "layer": "{n, plural, =1 {Layer} other {Layers}}", "license": "License", @@ -265,13 +274,16 @@ "n-years-ago": "{n} years ago", "name": "Név", "near": "Közelben", + "never": "", "new-list": "Új lista", "new-password": "New password", "new-password-error": "Error setting new password", "new-password-success": "The new password has been set", "new-password-text": "Set a new password", + "new-token-generated": "", "new-trail": "Új útvonal", "no-account": "Nincs még fiókja?", + "no-api-tokens": "", "no-comments-so-far": "No comments so far", "no-data": "No data", "no-description-for-now": "No description for now", diff --git a/web/src/lib/i18n/locales/it.json b/web/src/lib/i18n/locales/it.json index ea6e19c7..0ee0c0df 100644 --- a/web/src/lib/i18n/locales/it.json +++ b/web/src/lib/i18n/locales/it.json @@ -24,6 +24,8 @@ "amenity": "Amenity", "ammenity": "", "api-documentation": "Documentazione API", + "api-tokens": "", + "api-tokens-hint": "", "apply-user-settings": "", "attraction": "Attraction", "author": "Autore", @@ -37,6 +39,7 @@ "basic-info": "Informazioni di base", "basque": "Basque", "before": "Prima", + "behavior": "", "bicycle-parking": "Bicycle Parking", "bicycle-rental": "Bicycle Rental", "bicycle-shop": "Bicycle Shop", @@ -138,10 +141,12 @@ "entry": "Voce", "error-copying-trail": "", "error-creating-user": "Errore nella creazione dell'utente", + "error-deleting-token": "", "error-disabling-strava-integration": "Error disabling strava integration", "error-during-login": "Errore durante il login", "error-during-password-reset": "Impossibile inviare email per ripristinare la password", "error-exporting-trail": "Errore durante l'esportazione del percorso", + "error-generating-token": "", "error-liking-trail": "Error liking trail", "error-logging-in-to-komoot": "Error logging in to komoot", "error-posting-comment": "Errore pubblicando il commento", @@ -154,6 +159,8 @@ "error-updating-strava-integration": "Error updating komoot integration", "est-duration": "Durata stimata", "everyone-with-the-link": "Everyone with the link", + "expiration": "", + "expires": "", "explore": "Esplora", "explore-some-trails": "Esplora alcuni percorsi", "export": "Esporta", @@ -182,6 +189,7 @@ "from-url": "From URL", "garage": "Garage", "gas-station": "Gas station", + "generate-new-token": "", "german": "Tedesco", "get-position-from-exif": "Ottieni posizione da dati EXIF", "get-started": "Get started", @@ -221,6 +229,7 @@ "keep-original": "", "keep-private": "Keep private", "language": "Lingua", + "last-used": "", "latitude": "Latitudine", "layer": "{n, plural, =1 {Layer} other {Layers}}", "license": "Licenza", @@ -265,13 +274,16 @@ "n-years-ago": "{n} anni fa", "name": "Nome", "near": "Vicino", + "never": "", "new-list": "Nuova lista", "new-password": "Nuova password", "new-password-error": "Errore configurando la nuova password", "new-password-success": "La nuova password è stata configurata", "new-password-text": "Definire una nuova password", + "new-token-generated": "", "new-trail": "Nuovo percorso", "no-account": "Non hai ancora un account?", + "no-api-tokens": "", "no-comments-so-far": "Nessun commento per il momento", "no-data": "Nessun dato", "no-description-for-now": "Nessuna descrizione per il momento", diff --git a/web/src/lib/i18n/locales/nl.json b/web/src/lib/i18n/locales/nl.json index 4e882cd5..68950c75 100644 --- a/web/src/lib/i18n/locales/nl.json +++ b/web/src/lib/i18n/locales/nl.json @@ -24,6 +24,8 @@ "amenity": "Amenity", "ammenity": "Voorziening", "api-documentation": "API-documentatie", + "api-tokens": "", + "api-tokens-hint": "", "apply-user-settings": "", "attraction": "Attractie", "author": "Auteur", @@ -37,6 +39,7 @@ "basic-info": "Algemene informatie", "basque": "Baskisch", "before": "Voor", + "behavior": "", "bicycle-parking": "Fietsenstalling", "bicycle-rental": "Fietsverhuur", "bicycle-shop": "Fietsenwinkel", @@ -138,10 +141,12 @@ "entry": "Item", "error-copying-trail": "", "error-creating-user": "Fout bij aanmaken gebruiker", + "error-deleting-token": "", "error-disabling-strava-integration": "Fout bij het uitschakelen van Strava-integratie", "error-during-login": "Het inloggen is mislukt", "error-during-password-reset": "Kan geen e-mail voor wachtwoordherstel verzenden", "error-exporting-trail": "Fout bij exporteren van parcours", + "error-generating-token": "", "error-liking-trail": "Fout bij het \"leuk vinden\" van route", "error-logging-in-to-komoot": "Fout tijdens inloggen in Komoot", "error-posting-comment": "Fout bij het plaatsen van een reactie", @@ -154,6 +159,8 @@ "error-updating-strava-integration": "Fout bij bijwerken van Komoot integratie", "est-duration": "Geschatte duur", "everyone-with-the-link": "Iedereen met de link", + "expiration": "", + "expires": "", "explore": "Verkennen", "explore-some-trails": "Verken enkele routes", "export": "Exporteer", @@ -182,6 +189,7 @@ "from-url": "Van URL", "garage": "Garage", "gas-station": "Benzinestation", + "generate-new-token": "", "german": "Duits", "get-position-from-exif": "Coördinaten ophalen uit EXIF-gegevens", "get-started": "Aan de slag", @@ -221,6 +229,7 @@ "keep-original": "", "keep-private": "Keep private", "language": "Taal", + "last-used": "", "latitude": "Breedtegraad", "layer": "{n, plural, =1 {Layer} other {Layers}}", "license": "Licentie", @@ -265,13 +274,16 @@ "n-years-ago": "{n} jaren geleden", "name": "Naam", "near": "Nabij", + "never": "", "new-list": "Nieuwe lijst", "new-password": "Nieuw wachtwoord", "new-password-error": "Fout bij het instellen van een nieuw wachtwoord", "new-password-success": "Het nieuwe wachtwoord is ingesteld.", "new-password-text": "Stel nieuw wachtwoord in", + "new-token-generated": "", "new-trail": "Nieuwe Route", "no-account": "Heb je nog geen account?", + "no-api-tokens": "", "no-comments-so-far": "Tot nu toe geen opmerkingen", "no-data": "Geen data", "no-description-for-now": "Voorlopig geen beschrijving", diff --git a/web/src/lib/i18n/locales/pl.json b/web/src/lib/i18n/locales/pl.json index 35db0b38..99218ff7 100644 --- a/web/src/lib/i18n/locales/pl.json +++ b/web/src/lib/i18n/locales/pl.json @@ -24,6 +24,8 @@ "amenity": "Amenity", "ammenity": "", "api-documentation": "Dokumentacja API", + "api-tokens": "", + "api-tokens-hint": "", "apply-user-settings": "", "attraction": "Attraction", "author": "Autor", @@ -37,6 +39,7 @@ "basic-info": "Podstawowe informacje", "basque": "Basque", "before": "Przed", + "behavior": "", "bicycle-parking": "Bicycle Parking", "bicycle-rental": "Bicycle Rental", "bicycle-shop": "Bicycle Shop", @@ -138,10 +141,12 @@ "entry": "Pozycja", "error-copying-trail": "", "error-creating-user": "Błąd tworzenia użytkownika", + "error-deleting-token": "", "error-disabling-strava-integration": "Błąd przy wyłączaniu integracji strava", "error-during-login": "Błąd podczas logowania", "error-during-password-reset": "Nie udało się wysłać e-maila z resetowaniem hasła", "error-exporting-trail": "Błąd podczas eksportowania szlaku", + "error-generating-token": "", "error-liking-trail": "Error liking trail", "error-logging-in-to-komoot": "Błąd zapisu do komoot", "error-posting-comment": "Błąd wysyłania komentarza", @@ -154,6 +159,8 @@ "error-updating-strava-integration": "Błąd aktualizacji integracji kamoot", "est-duration": "Szacowany czas", "everyone-with-the-link": "Everyone with the link", + "expiration": "", + "expires": "", "explore": "Eksploruj", "explore-some-trails": "Eksploruj różne szlaki", "export": "Eksportuj", @@ -182,6 +189,7 @@ "from-url": "Z URL", "garage": "Garage", "gas-station": "Gas station", + "generate-new-token": "", "german": "Niemiecki", "get-position-from-exif": "Odczytaj współrzędne z danych EXIF", "get-started": "Get started", @@ -221,6 +229,7 @@ "keep-original": "", "keep-private": "Keep private", "language": "Język", + "last-used": "", "latitude": "Szerokość", "layer": "{n, plural, =1 {Layer} other {Layers}}", "license": "Licencja", @@ -265,13 +274,16 @@ "n-years-ago": "{n} lat temu", "name": "Nazwa", "near": "Blisko", + "never": "", "new-list": "Nowa Lista", "new-password": "Nowe hasło", "new-password-error": "Błąd ustawiania nowego hasła", "new-password-success": "Nowe hasło zostało ustawione", "new-password-text": "Ustaw nowe hasło", + "new-token-generated": "", "new-trail": "Nowy szlak", "no-account": "Nie masz konta?", + "no-api-tokens": "", "no-comments-so-far": "Nie ma jeszcze komentarzy", "no-data": "Brak danych", "no-description-for-now": "Nie ma jeszcze opisu", diff --git a/web/src/lib/i18n/locales/pt.json b/web/src/lib/i18n/locales/pt.json index 2973a61b..d4966e99 100644 --- a/web/src/lib/i18n/locales/pt.json +++ b/web/src/lib/i18n/locales/pt.json @@ -24,6 +24,8 @@ "amenity": "Amenity", "ammenity": "", "api-documentation": "Documentação da API", + "api-tokens": "", + "api-tokens-hint": "", "apply-user-settings": "", "attraction": "Attraction", "author": "Author", @@ -37,6 +39,7 @@ "basic-info": "Informações básicas", "basque": "Basque", "before": "Antes", + "behavior": "", "bicycle-parking": "Bicycle Parking", "bicycle-rental": "Bicycle Rental", "bicycle-shop": "Bicycle Shop", @@ -138,10 +141,12 @@ "entry": "Entrada", "error-copying-trail": "", "error-creating-user": "Erro ao criar utilizador", + "error-deleting-token": "", "error-disabling-strava-integration": "Error disabling strava integration", "error-during-login": "Erro durante o ‘login’", "error-during-password-reset": "Unable to send password reset email", "error-exporting-trail": "Erro na exportação do percurso", + "error-generating-token": "", "error-liking-trail": "Error liking trail", "error-logging-in-to-komoot": "Error logging in to komoot", "error-posting-comment": "Error posting comment", @@ -154,6 +159,8 @@ "error-updating-strava-integration": "Error updating komoot integration", "est-duration": "Duração prevista", "everyone-with-the-link": "Everyone with the link", + "expiration": "", + "expires": "", "explore": "Explorar", "explore-some-trails": "Explore algumas trilhas", "export": "Exportar", @@ -182,6 +189,7 @@ "from-url": "From URL", "garage": "Garage", "gas-station": "Gas station", + "generate-new-token": "", "german": "Alemão", "get-position-from-exif": "Obter coordenadas dos dados EXIF", "get-started": "Get started", @@ -221,6 +229,7 @@ "keep-original": "", "keep-private": "Keep private", "language": "Língua", + "last-used": "", "latitude": "Latitude", "layer": "{n, plural, =1 {Layer} other {Layers}}", "license": "Licença", @@ -265,13 +274,16 @@ "n-years-ago": "{n} anos atrás", "name": "Nome", "near": "Próximo", + "never": "", "new-list": "Nova lista", "new-password": "Nova senha", "new-password-error": "Error setting new password", "new-password-success": "The new password has been set", "new-password-text": "Set a new password", + "new-token-generated": "", "new-trail": "Nova trilha", "no-account": "Não tem uma conta?", + "no-api-tokens": "", "no-comments-so-far": "No comments so far", "no-data": "Sem dados", "no-description-for-now": "No description for now", diff --git a/web/src/lib/i18n/locales/ru.json b/web/src/lib/i18n/locales/ru.json index 6b4c7a6f..284f07e3 100644 --- a/web/src/lib/i18n/locales/ru.json +++ b/web/src/lib/i18n/locales/ru.json @@ -24,6 +24,8 @@ "amenity": "Amenity", "ammenity": "", "api-documentation": "Документация API", + "api-tokens": "", + "api-tokens-hint": "", "apply-user-settings": "", "attraction": "Attraction", "author": "Автор", @@ -37,6 +39,7 @@ "basic-info": "Основная информация", "basque": "Basque", "before": "До", + "behavior": "", "bicycle-parking": "Велосипедная парковка", "bicycle-rental": "Прокат велосипедов", "bicycle-shop": "Веломагазин", @@ -138,10 +141,12 @@ "entry": "Запись", "error-copying-trail": "", "error-creating-user": "Ошибка создания пользователя", + "error-deleting-token": "", "error-disabling-strava-integration": "Ошибка отключения Strava", "error-during-login": "Ошибка входа", "error-during-password-reset": "Не удалось отправить email сброса пароля", "error-exporting-trail": "Ошибка экспорта трека", + "error-generating-token": "", "error-liking-trail": "Error liking trail", "error-logging-in-to-komoot": "Ошибка входа в Komoot", "error-posting-comment": "Ошибка отправки комментария", @@ -154,6 +159,8 @@ "error-updating-strava-integration": "Ошибка обновления Strava", "est-duration": "Продолжительность", "everyone-with-the-link": "Everyone with the link", + "expiration": "", + "expires": "", "explore": "Изучить", "explore-some-trails": "Изучите треки", "export": "Экспорт", @@ -182,6 +189,7 @@ "from-url": "По ссылке", "garage": "Гараж", "gas-station": "Gas station", + "generate-new-token": "", "german": "Немецкий", "get-position-from-exif": "Координаты из EXIF", "get-started": "Get started", @@ -221,6 +229,7 @@ "keep-original": "", "keep-private": "Keep private", "language": "Язык", + "last-used": "", "latitude": "Широта", "layer": "{n, plural, =1 {Layer} other {Layers}}", "license": "Лицензия", @@ -265,13 +274,16 @@ "n-years-ago": "{n} лет назад", "name": "Название", "near": "Рядом", + "never": "", "new-list": "Новый список", "new-password": "Новый пароль", "new-password-error": "Ошибка обновления пароля", "new-password-success": "Пароль успешно изменен", "new-password-text": "Установить новый пароль", + "new-token-generated": "", "new-trail": "Новый трек", "no-account": "Нет аккаунта?", + "no-api-tokens": "", "no-comments-so-far": "Пока нет комментариев", "no-data": "Нет данных", "no-description-for-now": "Пока нет описания", diff --git a/web/src/lib/i18n/locales/zh.json b/web/src/lib/i18n/locales/zh.json index c6d0314b..46f30865 100644 --- a/web/src/lib/i18n/locales/zh.json +++ b/web/src/lib/i18n/locales/zh.json @@ -24,6 +24,8 @@ "amenity": "友好性", "ammenity": "", "api-documentation": "API 文档", + "api-tokens": "", + "api-tokens-hint": "", "apply-user-settings": "", "attraction": "景点", "author": "作者", @@ -37,6 +39,7 @@ "basic-info": "基本信息", "basque": "Basque", "before": "之前", + "behavior": "", "bicycle-parking": "自行车停车场", "bicycle-rental": "自行车租车", "bicycle-shop": "自行车店", @@ -138,10 +141,12 @@ "entry": "日程", "error-copying-trail": "", "error-creating-user": "创建用户错误", + "error-deleting-token": "", "error-disabling-strava-integration": "禁用strava集成时出错", "error-during-login": "登录错误", "error-during-password-reset": "无法发送密码重置邮件", "error-exporting-trail": "导出路线失败", + "error-generating-token": "", "error-liking-trail": "赞轨迹时出错", "error-logging-in-to-komoot": "登录到 komoot 时出错", "error-posting-comment": "发布评论时出错", @@ -154,6 +159,8 @@ "error-updating-strava-integration": "更新 komoot 集成出错", "est-duration": "预计时长", "everyone-with-the-link": "Everyone with the link", + "expiration": "", + "expires": "", "explore": "探索", "explore-some-trails": "探索行程", "export": "导出", @@ -182,6 +189,7 @@ "from-url": "从 URL", "garage": "车库", "gas-station": "加油站", + "generate-new-token": "", "german": "德语", "get-position-from-exif": "从EXIF数据获取坐标", "get-started": "Get started", @@ -221,6 +229,7 @@ "keep-original": "", "keep-private": "Keep private", "language": "语言", + "last-used": "", "latitude": "纬度", "layer": "{n, plural, =1 {层} other {层}}", "license": "开源协议", @@ -265,13 +274,16 @@ "n-years-ago": "{n} 年前", "name": "名称", "near": "附近", + "never": "", "new-list": "新列表", "new-password": "新密码", "new-password-error": "设置新密码时出错", "new-password-success": "新密码已设置", "new-password-text": "设置新密码", + "new-token-generated": "", "new-trail": "创建新路线", "no-account": "还未注册?", + "no-api-tokens": "", "no-comments-so-far": "到目前为止没有评论", "no-data": "无数据", "no-description-for-now": "暂无描述", diff --git a/web/src/lib/models/api/api_token_schema.ts b/web/src/lib/models/api/api_token_schema.ts new file mode 100644 index 00000000..758d2a06 --- /dev/null +++ b/web/src/lib/models/api/api_token_schema.ts @@ -0,0 +1,11 @@ +import { z, ZodType } from "zod"; +import type { APIToken } from "../api_token"; + +const APITokenCreateSchema = z.object({ + name: z.string().min(1, "required"), + expiration: z.string().refine((val) => !isNaN(Date.parse(val)), "invalid-date").refine((val) => Date.parse(val) > new Date().getTime(), "date-in-past").optional(), + user: z.string().length(15), + +}) satisfies ZodType + +export { APITokenCreateSchema } \ No newline at end of file diff --git a/web/src/lib/models/api_token.ts b/web/src/lib/models/api_token.ts new file mode 100644 index 00000000..a2190a9d --- /dev/null +++ b/web/src/lib/models/api_token.ts @@ -0,0 +1,13 @@ + +export class APIToken { + id?: string; + name: string; + expiration?: string; + last_used?: string; + user?: string; + + constructor(name: string, expiration?: string) { + this.name = name; + this.expiration = expiration; + } +} \ No newline at end of file diff --git a/web/src/lib/stores/api_token_store.ts b/web/src/lib/stores/api_token_store.ts new file mode 100644 index 00000000..e7fa0f5d --- /dev/null +++ b/web/src/lib/stores/api_token_store.ts @@ -0,0 +1,59 @@ +import { get } from "svelte/store"; +import { currentUser } from "./user_store"; +import type { APIToken } from "$lib/models/api_token"; +import { APIError } from "$lib/util/api_util"; +import type { ListResult } from "pocketbase"; + +export async function api_tokens_index(f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch) { + + const r = await f(`/api/v1/api-token`, { + method: 'GET', + }) + + if (!r.ok) { + const response = await r.json(); + throw new APIError(r.status, response.message, response.detail) + } + + const fetchedTokens: ListResult = await r.json(); + + return fetchedTokens; +} + +export async function api_tokens_create(apiToken: APIToken) { + const user = get(currentUser) + if (!user) { + throw Error("Unauthenticated") + } + + apiToken.user = user.id + + let r = await fetch('/api/v1/api-token', { + method: 'PUT', + body: JSON.stringify(apiToken), + }) + + if (!r.ok) { + const response = await r.json(); + throw new APIError(r.status, response.message, response.detail) + } + + const model: APIToken & { rawToken: string } = await r.json(); + + return model; +} + +export async function api_tokens_delete(token: APIToken) { + const r = await fetch('/api/v1/api-token/' + token.id, { + method: 'DELETE', + }) + + if (!r.ok) { + const response = await r.json(); + throw new APIError(r.status, response.message, response.detail) + } + + + return await r.json(); + +} \ No newline at end of file diff --git a/web/src/lib/util/api_util.ts b/web/src/lib/util/api_util.ts index dcb365f0..bf1128d8 100644 --- a/web/src/lib/util/api_util.ts +++ b/web/src/lib/util/api_util.ts @@ -41,6 +41,7 @@ export enum Collection { trails_bounding_box = "trails_bounding_box", trails_filter = "trails_filter", users_anonymous = "users_anonymous", + api_tokens = "api_tokens" } diff --git a/web/src/routes/api/v1/api-token/+server.ts b/web/src/routes/api/v1/api-token/+server.ts new file mode 100644 index 00000000..f037503b --- /dev/null +++ b/web/src/routes/api/v1/api-token/+server.ts @@ -0,0 +1,25 @@ +import type { APIToken } from '$lib/models/api_token'; +import { create } from '$lib/util/api_util'; +import { Collection, handleError, list } from '$lib/util/api_util'; +import { json, type RequestEvent } from '@sveltejs/kit'; +import { APITokenCreateSchema } from "$lib/models/api/api_token_schema"; + +export async function GET(event: RequestEvent) { + try { + const r = await list(event, Collection.api_tokens); + + return json(r) + } catch (e: any) { + return handleError(e); + } +} + + +export async function PUT(event: RequestEvent) { + try { + const r = await create(event, APITokenCreateSchema, Collection.api_tokens) + return json(r); + } catch (e) { + return handleError(e) + } +} diff --git a/web/src/routes/api/v1/api-token/[id]/+server.ts b/web/src/routes/api/v1/api-token/[id]/+server.ts new file mode 100644 index 00000000..a8ac5ad5 --- /dev/null +++ b/web/src/routes/api/v1/api-token/[id]/+server.ts @@ -0,0 +1,11 @@ +import { Collection, handleError, remove } from "$lib/util/api_util"; +import { json, type RequestEvent } from "@sveltejs/kit"; + +export async function DELETE(event: RequestEvent) { + try { + const r = await remove(event, Collection.api_tokens) + return json(r); + } catch (e: any) { + return handleError(e) + } +} diff --git a/web/src/routes/settings/account/+page.svelte b/web/src/routes/settings/account/+page.svelte index 287cfa2b..851d5234 100644 --- a/web/src/routes/settings/account/+page.svelte +++ b/web/src/routes/settings/account/+page.svelte @@ -1,9 +1,17 @@ @@ -91,6 +137,79 @@ +
+
+

{$_("api-tokens")}

+ +
+

{$_("api-tokens-hint")}

+ {#if data.apiTokens.totalItems == 0} +

+ {$_("no-api-tokens")} +

+ {:else} +
+ + + + + + + + + + + {#each data.apiTokens.items as token} + + + + + + + {/each} + +
{$_("name")}{$_("expiration")}{$_("last-used")}
{token.name}{token.expiration + ? new Date( + token.expiration, + ).toLocaleDateString(undefined, { + month: "long", + day: "2-digit", + year: "numeric", + timeZone: "UTC", + }) + : $_("never")}{token.last_used + ? new Date( + token.last_used, + ).toLocaleTimeString(undefined, { + month: "2-digit", + day: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }) + : "-"}
+
+ {/if} +

{$_("danger-zone")} @@ -108,13 +227,22 @@ onsave={updateEmail} bind:this={emailModal} > - + + {/if} + + diff --git a/web/src/routes/settings/account/+page.ts b/web/src/routes/settings/account/+page.ts new file mode 100644 index 00000000..e778897b --- /dev/null +++ b/web/src/routes/settings/account/+page.ts @@ -0,0 +1,7 @@ +import { api_tokens_index } from "$lib/stores/api_token_store"; +import { type Load } from "@sveltejs/kit"; + +export const load: Load = async ({ params, fetch }) => { + const apiTokens = await api_tokens_index(fetch) + return { apiTokens: apiTokens } +}; \ No newline at end of file