From cde9f8873e87d2d2211108239026884171dbcb92 Mon Sep 17 00:00:00 2001
From: Thomas Citharel <tcit@tcit.fr>
Date: Fri, 26 Mar 2021 19:01:55 +0100
Subject: [PATCH] Expose personal tokened feeds

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
---
 js/src/components/Settings/SettingsMenu.vue   |   2 +-
 js/src/graphql/user.ts                        |  14 ++
 js/src/i18n/en_US.json                        |  10 +-
 js/src/i18n/fr_FR.json                        |  10 +-
 .../views/Account/children/EditIdentity.vue   | 139 ++++++++++++++++
 js/src/views/Settings/Notifications.vue       | 151 +++++++++++++++++-
 lib/graphql/resolvers/feed_token.ex           |  14 +-
 lib/graphql/schema/actors/person.ex           |  10 +-
 lib/graphql/schema/events/feed_token.ex       |   2 +-
 lib/graphql/schema/user.ex                    |  10 +-
 lib/service/export/common.ex                  |   5 +-
 lib/web/controllers/feed_controller.ex        |   4 +-
 test/graphql/resolvers/feed_token_test.exs    |   8 +-
 test/service/export/icalendar_test.exs        |   2 +-
 test/web/controllers/feed_controller_test.exs |   8 +-
 15 files changed, 363 insertions(+), 26 deletions(-)

diff --git a/js/src/components/Settings/SettingsMenu.vue b/js/src/components/Settings/SettingsMenu.vue
index e6aa18ba3..bbf96cdcb 100644
--- a/js/src/components/Settings/SettingsMenu.vue
+++ b/js/src/components/Settings/SettingsMenu.vue
@@ -14,7 +14,7 @@
           :to="{ name: RouteName.PREFERENCES }"
         />
         <SettingMenuItem
-          :title="this.$t('Email notifications')"
+          :title="this.$t('Notifications')"
           :to="{ name: RouteName.NOTIFICATIONS }"
         />
       </SettingMenuSection>
diff --git a/js/src/graphql/user.ts b/js/src/graphql/user.ts
index b794ef1a8..1eba90fbb 100644
--- a/js/src/graphql/user.ts
+++ b/js/src/graphql/user.ts
@@ -241,3 +241,17 @@ export const UPDATE_USER_LOCALE = gql`
     }
   }
 `;
+
+export const FEED_TOKENS_LOGGED_USER = gql`
+  query {
+    loggedUser {
+      id
+      feedTokens {
+        token
+        actor {
+          id
+        }
+      }
+    }
+  }
+`;
diff --git a/js/src/i18n/en_US.json b/js/src/i18n/en_US.json
index 0953e0c0b..a5410a33e 100644
--- a/js/src/i18n/en_US.json
+++ b/js/src/i18n/en_US.json
@@ -969,5 +969,13 @@
   "You replied to a comment on the event {event}.": "You replied to a comment on the event {event}.",
   "{profile} replied to a comment on the event {event}.": "{profile} replied to a comment on the event {event}.",
   "New post": "New post",
-  "Comment text can't be empty": "Comment text can't be empty"
+  "Comment text can't be empty": "Comment text can't be empty",
+  "Notifications": "Notifications",
+  "Profile feeds": "Profile feeds",
+  "These feeds contain event data for the events for which this specific profile is a participant or creator. You should keep these private. You can find feeds for all of your profiles into your notification settings.": "These feeds contain event data for the events for which this specific profile is a participant or creator. You should keep these private. You can find feeds for all of your profiles into your notification settings.",
+  "Regenerate new links": "Regenerate new links",
+  "Create new links": "Create new links",
+  "You'll need to change the URLs where there were previously entered.": "You'll need to change the URLs where there were previously entered.",
+  "Personal feeds": "Personal feeds",
+  "These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page.": "These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page."
 }
diff --git a/js/src/i18n/fr_FR.json b/js/src/i18n/fr_FR.json
index 992fc10ea..cdbfda87c 100644
--- a/js/src/i18n/fr_FR.json
+++ b/js/src/i18n/fr_FR.json
@@ -1063,5 +1063,13 @@
   "You replied to a comment on the event {event}.": "Vous avez répondu à un commentaire sur l'événement {event}.",
   "{profile} replied to a comment on the event {event}.": "{profile} a répondu à un commentaire sur l'événement {event}.",
   "New post": "Nouveau billet",
-  "Comment text can't be empty": "Le texte du commentaire ne peut être vide"
+  "Comment text can't be empty": "Le texte du commentaire ne peut être vide",
+  "Notifications": "Notifications",
+  "Profile feeds": "Flux du profil",
+  "These feeds contain event data for the events for which this specific profile is a participant or creator. You should keep these private. You can find feeds for all of your profiles into your notification settings.": "Ces flux contiennent des informations sur les événements pour lesquels ce profil spécifique est un⋅e participant⋅e ou un⋅e créateur⋅ice. Vous devriez les garder privés. Vous pouvez trouver des flux pour l'ensemble de vos profils dans vos paramètres de notification.",
+  "Regenerate new links": "Regénérer de nouveaux liens",
+  "Create new links": "Créer de nouveaux liens",
+  "You'll need to change the URLs where there were previously entered.": "Vous devrez changer les URLs là où vous les avez entrées précédemment.",
+  "Personal feeds": "Flux personnels",
+  "These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page.": "Ces flux contiennent des informations sur les événements pour lesquels n'importe lequel de vos profils est un⋅e participant⋅e ou un⋅e créateur⋅ice. Vous devriez les garder privés. Vous pouvez trouver des flux spécifiques à chaque profil sur la page d'édition des profils."
 }
diff --git a/js/src/views/Account/children/EditIdentity.vue b/js/src/views/Account/children/EditIdentity.vue
index bb6c44dc5..5bf8670a4 100644
--- a/js/src/views/Account/children/EditIdentity.vue
+++ b/js/src/views/Account/children/EditIdentity.vue
@@ -98,6 +98,77 @@
           $t("Delete this identity")
         }}</span>
       </div>
+
+      <section v-if="isUpdate">
+        <div class="setting-title">
+          <h2>{{ $t("Profile feeds") }}</h2>
+        </div>
+        <p>
+          {{
+            $t(
+              "These feeds contain event data for the events for which this specific profile is a participant or creator. You should keep these private. You can find feeds for all of your profiles into your notification settings."
+            )
+          }}
+        </p>
+        <div v-if="identity.feedTokens && identity.feedTokens.length > 0">
+          <div
+            class="buttons"
+            v-for="feedToken in identity.feedTokens"
+            :key="feedToken.token"
+          >
+            <b-tooltip
+              :label="$t('URL copied to clipboard')"
+              :active="showCopiedTooltip.atom"
+              always
+              type="is-success"
+              position="is-left"
+            >
+              <b-button
+                tag="a"
+                icon-left="rss"
+                @click="
+                  (e) => copyURL(e, tokenToURL(feedToken.token, 'atom'), 'atom')
+                "
+                :href="tokenToURL(feedToken.token, 'atom')"
+                target="_blank"
+                >{{ $t("RSS/Atom Feed") }}</b-button
+              >
+            </b-tooltip>
+            <b-tooltip
+              :label="$t('URL copied to clipboard')"
+              :active="showCopiedTooltip.ics"
+              always
+              type="is-success"
+              position="is-left"
+            >
+              <b-button
+                tag="a"
+                @click="
+                  (e) => copyURL(e, tokenToURL(feedToken.token, 'ics'), 'ics')
+                "
+                icon-left="calendar-sync"
+                :href="tokenToURL(feedToken.token, 'ics')"
+                target="_blank"
+                >{{ $t("ICS/WebCal Feed") }}</b-button
+              >
+            </b-tooltip>
+            <b-button
+              icon-left="refresh"
+              type="is-text"
+              @click="openRegenerateFeedTokensConfirmation"
+              >{{ $t("Regenerate new links") }}</b-button
+            >
+          </div>
+        </div>
+        <div v-else>
+          <b-button
+            icon-left="refresh"
+            type="is-text"
+            @click="generateFeedTokens"
+            >{{ $t("Create new links") }}</b-button
+          >
+        </div>
+      </section>
     </div>
   </div>
 </template>
@@ -131,6 +202,10 @@ h1 {
 .username-field + .field {
   margin-bottom: 0;
 }
+
+::v-deep .buttons > *:not(:last-child) .button {
+  margin-right: 0.5rem;
+}
 </style>
 
 <script lang="ts">
@@ -151,6 +226,11 @@ import RouteName from "../../../router/name";
 import { buildFileVariable } from "../../../utils/image";
 import { changeIdentity } from "../../../utils/auth";
 import identityEditionMixin from "../../../mixins/identityEdition";
+import {
+  CREATE_FEED_TOKEN_ACTOR,
+  DELETE_FEED_TOKEN,
+} from "@/graphql/feed_tokens";
+import { IFeedToken } from "@/types/feedtoken.model";
 
 @Component({
   components: {
@@ -191,6 +271,8 @@ export default class EditIdentity extends mixins(identityEditionMixin) {
 
   RouteName = RouteName;
 
+  showCopiedTooltip = { ics: false, atom: false };
+
   get message(): string | null {
     if (this.isUpdate) return null;
     return this.$t(
@@ -353,6 +435,63 @@ export default class EditIdentity extends mixins(identityEditionMixin) {
     return MOBILIZON_INSTANCE_HOST;
   }
 
+  tokenToURL(token: string, format: string): string {
+    return `${window.location.origin}/events/going/${token}/${format}`;
+  }
+
+  copyURL(e: Event, url: string, format: "ics" | "atom"): void {
+    if (navigator.clipboard) {
+      e.preventDefault();
+      navigator.clipboard.writeText(url);
+      this.showCopiedTooltip[format] = true;
+      setTimeout(() => {
+        this.showCopiedTooltip[format] = false;
+      }, 2000);
+    }
+  }
+
+  async generateFeedTokens(): Promise<void> {
+    const newToken = await this.createNewFeedToken();
+    this.identity.feedTokens.push(newToken);
+  }
+
+  async regenerateFeedTokens(): Promise<void> {
+    if (this.identity?.feedTokens.length < 1) return;
+    await this.deleteFeedToken(this.identity.feedTokens[0].token);
+    const newToken = await this.createNewFeedToken();
+    this.identity.feedTokens.pop();
+    this.identity.feedTokens.push(newToken);
+  }
+
+  private async deleteFeedToken(token: string): Promise<void> {
+    await this.$apollo.mutate({
+      mutation: DELETE_FEED_TOKEN,
+      variables: { token },
+    });
+  }
+
+  private async createNewFeedToken(): Promise<IFeedToken> {
+    const { data } = await this.$apollo.mutate({
+      mutation: CREATE_FEED_TOKEN_ACTOR,
+      variables: { actor_id: this.identity?.id },
+    });
+
+    return data.createFeedToken;
+  }
+
+  openRegenerateFeedTokensConfirmation(): void {
+    this.$buefy.dialog.confirm({
+      type: "is-warning",
+      title: this.$t("Regenerate new links") as string,
+      message: this.$t(
+        "You'll need to change the URLs where there were previously entered."
+      ) as string,
+      confirmText: this.$t("Regenerate new links") as string,
+      cancelText: this.$t("Cancel") as string,
+      onConfirm: () => this.regenerateFeedTokens(),
+    });
+  }
+
   openDeleteIdentityConfirmation(): void {
     this.$buefy.dialog.prompt({
       type: "is-danger",
diff --git a/js/src/views/Settings/Notifications.vue b/js/src/views/Settings/Notifications.vue
index c204471a9..c6e697b1d 100644
--- a/js/src/views/Settings/Notifications.vue
+++ b/js/src/views/Settings/Notifications.vue
@@ -9,7 +9,7 @@
         </li>
         <li class="is-active">
           <router-link :to="{ name: RouteName.NOTIFICATIONS }">{{
-            $t("Email notifications")
+            $t("Notifications")
           }}</router-link>
         </li>
       </ul>
@@ -118,23 +118,108 @@
         </b-select>
       </div>
     </section>
+    <section>
+      <div class="setting-title">
+        <h2>{{ $t("Personal feeds") }}</h2>
+      </div>
+      <p>
+        {{
+          $t(
+            "These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page."
+          )
+        }}
+      </p>
+      <div v-if="feedTokens && feedTokens.length > 0">
+        <div
+          class="buttons"
+          v-for="feedToken in feedTokens"
+          :key="feedToken.token"
+        >
+          <b-tooltip
+            :label="$t('URL copied to clipboard')"
+            :active="showCopiedTooltip.atom"
+            always
+            type="is-success"
+            position="is-left"
+          >
+            <b-button
+              tag="a"
+              icon-left="rss"
+              @click="
+                (e) => copyURL(e, tokenToURL(feedToken.token, 'atom'), 'atom')
+              "
+              :href="tokenToURL(feedToken.token, 'atom')"
+              target="_blank"
+              >{{ $t("RSS/Atom Feed") }}</b-button
+            >
+          </b-tooltip>
+          <b-tooltip
+            :label="$t('URL copied to clipboard')"
+            :active="showCopiedTooltip.ics"
+            always
+            type="is-success"
+            position="is-left"
+          >
+            <b-button
+              tag="a"
+              @click="
+                (e) => copyURL(e, tokenToURL(feedToken.token, 'ics'), 'ics')
+              "
+              icon-left="calendar-sync"
+              :href="tokenToURL(feedToken.token, 'ics')"
+              target="_blank"
+              >{{ $t("ICS/WebCal Feed") }}</b-button
+            >
+          </b-tooltip>
+          <b-button
+            icon-left="refresh"
+            type="is-text"
+            @click="openRegenerateFeedTokensConfirmation"
+            >{{ $t("Regenerate new links") }}</b-button
+          >
+        </div>
+      </div>
+      <div v-else>
+        <b-button
+          icon-left="refresh"
+          type="is-text"
+          @click="generateFeedTokens"
+          >{{ $t("Create new links") }}</b-button
+        >
+      </div>
+    </section>
   </div>
 </template>
 <script lang="ts">
 import { Component, Vue, Watch } from "vue-property-decorator";
 import { INotificationPendingEnum } from "@/types/enums";
-import { USER_SETTINGS, SET_USER_SETTINGS } from "../../graphql/user";
+import {
+  USER_SETTINGS,
+  SET_USER_SETTINGS,
+  FEED_TOKENS_LOGGED_USER,
+} from "../../graphql/user";
 import { IUser } from "../../types/current-user.model";
 import RouteName from "../../router/name";
+import { IFeedToken } from "@/types/feedtoken.model";
+import { CREATE_FEED_TOKEN, DELETE_FEED_TOKEN } from "@/graphql/feed_tokens";
 
 @Component({
   apollo: {
     loggedUser: USER_SETTINGS,
+    feedTokens: {
+      query: FEED_TOKENS_LOGGED_USER,
+      update: (data) =>
+        data.loggedUser.feedTokens.filter(
+          (token: IFeedToken) => token.actor === null
+        ),
+    },
   },
 })
 export default class Notifications extends Vue {
   loggedUser!: IUser;
 
+  feedTokens: IFeedToken[] = [];
+
   notificationOnDay: boolean | undefined = true;
 
   notificationEachWeek: boolean | undefined = false;
@@ -148,6 +233,8 @@ export default class Notifications extends Vue {
 
   RouteName = RouteName;
 
+  showCopiedTooltip = { ics: false, atom: false };
+
   mounted(): void {
     this.notificationPendingParticipationValues = {
       [INotificationPendingEnum.NONE]: this.$t("Do not receive any mail"),
@@ -176,6 +263,62 @@ export default class Notifications extends Vue {
       refetchQueries: [{ query: USER_SETTINGS }],
     });
   }
+
+  tokenToURL(token: string, format: string): string {
+    return `${window.location.origin}/events/going/${token}/${format}`;
+  }
+
+  copyURL(e: Event, url: string, format: "ics" | "atom"): void {
+    if (navigator.clipboard) {
+      e.preventDefault();
+      navigator.clipboard.writeText(url);
+      this.showCopiedTooltip[format] = true;
+      setTimeout(() => {
+        this.showCopiedTooltip[format] = false;
+      }, 2000);
+    }
+  }
+
+  openRegenerateFeedTokensConfirmation(): void {
+    this.$buefy.dialog.confirm({
+      type: "is-warning",
+      title: this.$t("Regenerate new links") as string,
+      message: this.$t(
+        "You'll need to change the URLs where there were previously entered."
+      ) as string,
+      confirmText: this.$t("Regenerate new links") as string,
+      cancelText: this.$t("Cancel") as string,
+      onConfirm: () => this.regenerateFeedTokens(),
+    });
+  }
+
+  async regenerateFeedTokens(): Promise<void> {
+    if (this.feedTokens.length < 1) return;
+    await this.deleteFeedToken(this.feedTokens[0].token);
+    const newToken = await this.createNewFeedToken();
+    this.feedTokens.pop();
+    this.feedTokens.push(newToken);
+  }
+
+  async generateFeedTokens(): Promise<void> {
+    const newToken = await this.createNewFeedToken();
+    this.feedTokens.push(newToken);
+  }
+
+  private async deleteFeedToken(token: string): Promise<void> {
+    await this.$apollo.mutate({
+      mutation: DELETE_FEED_TOKEN,
+      variables: { token },
+    });
+  }
+
+  private async createNewFeedToken(): Promise<IFeedToken> {
+    const { data } = await this.$apollo.mutate({
+      mutation: CREATE_FEED_TOKEN,
+    });
+
+    return data.createFeedToken;
+  }
 }
 </script>
 
@@ -193,4 +336,8 @@ export default class Notifications extends Vue {
     margin-left: 5px;
   }
 }
+
+::v-deep .buttons > *:not(:last-child) .button {
+  margin-right: 0.5rem;
+}
 </style>
diff --git a/lib/graphql/resolvers/feed_token.ex b/lib/graphql/resolvers/feed_token.ex
index ed545ed3a..196da066c 100644
--- a/lib/graphql/resolvers/feed_token.ex
+++ b/lib/graphql/resolvers/feed_token.ex
@@ -22,7 +22,7 @@ defmodule Mobilizon.GraphQL.Resolvers.FeedToken do
       ) do
     with {:is_owned, %Actor{}} <- User.owns_actor(user, actor_id),
          {:ok, feed_token} <- Events.create_feed_token(%{user_id: id, actor_id: actor_id}) do
-      {:ok, feed_token}
+      {:ok, to_short_uuid(feed_token)}
     else
       {:is_owned, nil} ->
         {:error, dgettext("errors", "Profile is not owned by authenticated user")}
@@ -32,7 +32,7 @@ defmodule Mobilizon.GraphQL.Resolvers.FeedToken do
   @spec create_feed_token(any, map, map) :: {:ok, FeedToken.t()}
   def create_feed_token(_parent, %{}, %{context: %{current_user: %User{id: id}}}) do
     with {:ok, feed_token} <- Events.create_feed_token(%{user_id: id}) do
-      {:ok, feed_token}
+      {:ok, to_short_uuid(feed_token)}
     end
   end
 
@@ -50,7 +50,8 @@ defmodule Mobilizon.GraphQL.Resolvers.FeedToken do
         %{token: token},
         %{context: %{current_user: %User{id: id} = _user}}
       ) do
-    with {:ok, token} <- Ecto.UUID.cast(token),
+    with {:ok, token} <- ShortUUID.decode(token),
+         {:ok, token} <- Ecto.UUID.cast(token),
          {:no_token, %FeedToken{actor: actor, user: %User{} = user} = feed_token} <-
            {:no_token, Events.get_feed_token(token)},
          {:token_from_user, true} <- {:token_from_user, id == user.id},
@@ -65,6 +66,9 @@ defmodule Mobilizon.GraphQL.Resolvers.FeedToken do
       :error ->
         {:error, dgettext("errors", "Token is not a valid UUID")}
 
+      {:error, "Invalid input"} ->
+        {:error, dgettext("errors", "Token is not a valid UUID")}
+
       {:no_token, _} ->
         {:error, dgettext("errors", "Token does not exist")}
 
@@ -77,4 +81,8 @@ defmodule Mobilizon.GraphQL.Resolvers.FeedToken do
   def delete_feed_token(_parent, _args, %{}) do
     {:error, dgettext("errors", "You are not allowed to delete a feed token if not connected")}
   end
+
+  defp to_short_uuid(%FeedToken{token: token} = feed_token) do
+    %FeedToken{feed_token | token: ShortUUID.encode!(token)}
+  end
 end
diff --git a/lib/graphql/schema/actors/person.ex b/lib/graphql/schema/actors/person.ex
index fd8aedf03..42743316f 100644
--- a/lib/graphql/schema/actors/person.ex
+++ b/lib/graphql/schema/actors/person.ex
@@ -4,7 +4,7 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
   """
   use Absinthe.Schema.Notation
 
-  import Absinthe.Resolution.Helpers, only: [dataloader: 1]
+  import Absinthe.Resolution.Helpers, only: [dataloader: 2]
 
   alias Mobilizon.Events
   alias Mobilizon.GraphQL.Resolvers.{Media, Person}
@@ -53,7 +53,13 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
     )
 
     field(:feed_tokens, list_of(:feed_token),
-      resolve: dataloader(Events),
+      resolve:
+        dataloader(
+          Events,
+          callback: fn feed_tokens, _parent, _args ->
+            {:ok, Enum.map(feed_tokens, &Map.put(&1, :token, ShortUUID.encode!(&1.token)))}
+          end
+        ),
       description: "A list of the feed tokens for this person"
     )
 
diff --git a/lib/graphql/schema/events/feed_token.ex b/lib/graphql/schema/events/feed_token.ex
index 44ea96289..c50880574 100644
--- a/lib/graphql/schema/events/feed_token.ex
+++ b/lib/graphql/schema/events/feed_token.ex
@@ -31,7 +31,7 @@ defmodule Mobilizon.GraphQL.Schema.Events.FeedTokenType do
       description: "The actor that participates to the event"
     )
 
-    field(:token, :string, description: "The role of this actor at this event")
+    field(:token, :string, description: "A ShortUUID private token")
   end
 
   @desc "Represents a deleted feed_token"
diff --git a/lib/graphql/schema/user.ex b/lib/graphql/schema/user.ex
index af62f798b..e8236c897 100644
--- a/lib/graphql/schema/user.ex
+++ b/lib/graphql/schema/user.ex
@@ -4,7 +4,7 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
   """
   use Absinthe.Schema.Notation
 
-  import Absinthe.Resolution.Helpers, only: [dataloader: 1]
+  import Absinthe.Resolution.Helpers, only: [dataloader: 2]
 
   alias Mobilizon.Events
   alias Mobilizon.GraphQL.Resolvers.{Media, User}
@@ -43,7 +43,13 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
     )
 
     field(:feed_tokens, list_of(:feed_token),
-      resolve: dataloader(Events),
+      resolve:
+        dataloader(
+          Events,
+          callback: fn feed_tokens, _parent, _args ->
+            {:ok, Enum.map(feed_tokens, &Map.put(&1, :token, ShortUUID.encode!(&1.token)))}
+          end
+        ),
       description: "A list of the feed tokens for this user"
     )
 
diff --git a/lib/service/export/common.ex b/lib/service/export/common.ex
index 26efe9b38..2b9a35692 100644
--- a/lib/service/export/common.ex
+++ b/lib/service/export/common.ex
@@ -25,8 +25,9 @@ defmodule Mobilizon.Service.Export.Common do
   # Only events, not posts
   @spec fetch_events_from_token(String.t()) :: String.t()
   def fetch_events_from_token(token) do
-    with {:ok, _uuid} <- Ecto.UUID.cast(token),
-         %FeedToken{actor: actor, user: %User{} = user} <- Events.get_feed_token(token) do
+    with {:ok, uuid} <- ShortUUID.decode(token),
+         {:ok, _uuid} <- Ecto.UUID.cast(uuid),
+         %FeedToken{actor: actor, user: %User{} = user} <- Events.get_feed_token(uuid) do
       case actor do
         %Actor{} = actor ->
           %{
diff --git a/lib/web/controllers/feed_controller.ex b/lib/web/controllers/feed_controller.ex
index 87b8181df..4de8e61b4 100644
--- a/lib/web/controllers/feed_controller.ex
+++ b/lib/web/controllers/feed_controller.ex
@@ -26,7 +26,7 @@ defmodule Mobilizon.Web.FeedController do
   end
 
   def event(conn, %{"uuid" => uuid, "format" => "ics"}) do
-    return_data(conn, "ics", "event_" <> uuid, "event.ics")
+    return_data(conn, "ics", "event_" <> uuid, "event")
   end
 
   def event(_conn, _) do
@@ -34,7 +34,7 @@ defmodule Mobilizon.Web.FeedController do
   end
 
   def going(conn, %{"token" => token, "format" => format}) when format in @formats do
-    return_data(conn, format, "token_" <> token, "events.#{format}")
+    return_data(conn, format, "token_" <> token, "events")
   end
 
   def going(_conn, _) do
diff --git a/test/graphql/resolvers/feed_token_test.exs b/test/graphql/resolvers/feed_token_test.exs
index 931c8c342..bc2d353ce 100644
--- a/test/graphql/resolvers/feed_token_test.exs
+++ b/test/graphql/resolvers/feed_token_test.exs
@@ -186,7 +186,7 @@ defmodule Mobilizon.GraphQL.Resolvers.FeedTokenTest do
                %{
                  "feedTokens" => [
                    %{
-                     "token" => feed_token.token
+                     "token" => ShortUUID.encode!(feed_token.token)
                    }
                  ]
                }
@@ -194,7 +194,7 @@ defmodule Mobilizon.GraphQL.Resolvers.FeedTokenTest do
       mutation = """
           mutation {
             deleteFeedToken(
-              token: "#{feed_token.token}",
+              token: "#{ShortUUID.encode!(feed_token.token)}",
             ) {
                 actor {
                   id
@@ -270,7 +270,7 @@ defmodule Mobilizon.GraphQL.Resolvers.FeedTokenTest do
       mutation = """
           mutation {
             deleteFeedToken(
-              token: "#{feed_token.token}",
+              token: "#{ShortUUID.encode!(feed_token.token)}",
             ) {
                 actor {
                   id
@@ -320,7 +320,7 @@ defmodule Mobilizon.GraphQL.Resolvers.FeedTokenTest do
       mutation = """
           mutation {
             deleteFeedToken(
-              token: "#{uuid}"
+              token: "#{ShortUUID.encode!(uuid)}"
             ) {
                 actor {
                   id
diff --git a/test/service/export/icalendar_test.exs b/test/service/export/icalendar_test.exs
index c3aceed1c..b50351e12 100644
--- a/test/service/export/icalendar_test.exs
+++ b/test/service/export/icalendar_test.exs
@@ -58,7 +58,7 @@ defmodule Mobilizon.Service.ICalendarTest do
       event = insert(:event)
       insert(:participant, event: event, actor: actor, role: :participant)
 
-      {:commit, ics} = ICalendarService.create_cache("token_#{token}")
+      {:commit, ics} = ICalendarService.create_cache("token_#{ShortUUID.encode!(token)}")
       assert ics =~ event.title
     end
   end
diff --git a/test/web/controllers/feed_controller_test.exs b/test/web/controllers/feed_controller_test.exs
index 1b56dabc3..c4335dfbc 100644
--- a/test/web/controllers/feed_controller_test.exs
+++ b/test/web/controllers/feed_controller_test.exs
@@ -225,7 +225,7 @@ defmodule Mobilizon.Web.FeedControllerTest do
         conn
         |> get(
           Endpoint
-          |> Routes.feed_url(:going, feed_token.token, "atom")
+          |> Routes.feed_url(:going, ShortUUID.encode!(feed_token.token), "atom")
           |> URI.decode()
         )
 
@@ -260,7 +260,7 @@ defmodule Mobilizon.Web.FeedControllerTest do
         |> put_req_header("accept", "application/atom+xml")
         |> get(
           Endpoint
-          |> Routes.feed_url(:going, feed_token.token, "atom")
+          |> Routes.feed_url(:going, ShortUUID.encode!(feed_token.token), "atom")
           |> URI.decode()
         )
 
@@ -307,7 +307,7 @@ defmodule Mobilizon.Web.FeedControllerTest do
         |> put_req_header("accept", "text/calendar")
         |> get(
           Endpoint
-          |> Routes.feed_url(:going, feed_token.token, "ics")
+          |> Routes.feed_url(:going, ShortUUID.encode!(feed_token.token), "ics")
           |> URI.decode()
         )
 
@@ -338,7 +338,7 @@ defmodule Mobilizon.Web.FeedControllerTest do
         |> put_req_header("accept", "text/calendar")
         |> get(
           Endpoint
-          |> Routes.feed_url(:going, feed_token.token, "ics")
+          |> Routes.feed_url(:going, ShortUUID.encode!(feed_token.token), "ics")
           |> URI.decode()
         )