astro-atmosphere-comments 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,78 @@
1
+ {
2
+ "signIn.heading": "Accedi per commentare, mettere mi piace o ripubblicare",
3
+ "signIn.anyAccount": "Va bene qualsiasi {{atmosphereAccount}}, anche quello di {{bluesky}}: un solo account per un'intera rete di app aperte.",
4
+ "signIn.atmosphereAccount": "account Atmosphere",
5
+ "signIn.button": "Accedi con Atmosphere",
6
+ "signIn.matchingAccounts": "Account corrispondenti",
7
+ "signIn.privacy": "Darai l'approvazione sul sito del tuo account (quello di Bluesky, per la maggior parte delle persone); la tua password non viene mai condivisa qui. Non hai ancora un account? {{createAccount}}.",
8
+ "signIn.createAccount": "Crea un account Atmosphere gratuito",
9
+ "elsewhere.text": "Preferisci non accedere qui? {{link}} La tua risposta compare qui allo stesso modo, così come i mi piace e le ripubblicazioni.",
10
+ "elsewhere.link": "Rispondi su Bluesky →",
11
+ "actions.label": "Rispondi a questo post",
12
+ "actions.like": "Mi piace",
13
+ "actions.liked": "Ti piace",
14
+ "actions.repost": "Ripubblica",
15
+ "actions.reposted": "Ripubblicato",
16
+ "actions.quote": "Cita",
17
+ "composer.commentAs": "{{verb}} come {{handle}}",
18
+ "composer.comment": "Commenta",
19
+ "composer.quote": "Cita",
20
+ "composer.mentions": "Account da menzionare",
21
+ "composer.previewCaption": "Ecco come apparirà il tuo post",
22
+ "composer.now": "ora",
23
+ "composer.previewEmpty": "Il tuo testo appare qui.",
24
+ "composer.quoting": "Stai citando questo post sul tuo account",
25
+ "composer.replyingTo": "In risposta a {{name}}",
26
+ "composer.cancel": "Annulla",
27
+ "composer.signOut": "Esci",
28
+ "composer.postComment": "Pubblica commento",
29
+ "composer.postQuote": "Pubblica citazione",
30
+ "composer.charactersLeft_one": "Ne resta {{count}}",
31
+ "composer.charactersLeft_many": "Ne restano {{count}}",
32
+ "composer.charactersLeft_other": "Ne restano {{count}}",
33
+ "composer.posting": "Pubblicazione…",
34
+ "posted.comment": "Il tuo commento è pubblicato.",
35
+ "posted.quote": "La tua citazione è su Bluesky.",
36
+ "posted.see": "Vedila su Bluesky →",
37
+ "reactions.likes_one": "{{count}} mi piace",
38
+ "reactions.likes_many": "{{count}} mi piace",
39
+ "reactions.likes_other": "{{count}} mi piace",
40
+ "reactions.reposts_one": "{{count}} ripubblicazione",
41
+ "reactions.reposts_many": "{{count}} ripubblicazioni",
42
+ "reactions.reposts_other": "{{count}} ripubblicazioni",
43
+ "comment.author": "Autore",
44
+ "comment.viewOnBluesky": "vedi su Bluesky",
45
+ "comment.someone": "questo commento",
46
+ "comment.reply": "Rispondi",
47
+ "comment.replyTo": "Rispondi a {{name}}",
48
+ "comment.likeLabel": "Metti mi piace al commento di {{name}}",
49
+ "comment.repostLabel": "Ripubblica il commento di {{name}}",
50
+ "comment.delete": "Elimina",
51
+ "comment.deleteLabel": "Elimina il tuo commento",
52
+ "comment.deleteConfirm": "Eliminare questo commento da Bluesky?",
53
+ "comment.deleting": "Eliminazione…",
54
+ "comment.deleted": "Il tuo commento è stato eliminato.",
55
+ "comment.deletePermission": "Per eliminare i commenti qui, questo sito ha bisogno di un'altra autorizzazione dal tuo account. Puoi anche eliminarlo in qualsiasi app di Bluesky.",
56
+ "comment.continue": "Continua per approvare",
57
+ "comment.cancel": "Annulla",
58
+ "signInFirst.reply": "Accedi per rispondere.",
59
+ "signInFirst.like": "Accedi per mettere mi piace ai commenti.",
60
+ "signInFirst.repost": "Accedi per ripubblicare i commenti.",
61
+ "status.liked": "Ti piace.",
62
+ "status.likeRemoved": "Mi piace rimosso.",
63
+ "status.reposted": "Ripubblicato.",
64
+ "status.repostRemoved": "Ripubblicazione rimossa.",
65
+ "status.signedOut": "Sei uscito.",
66
+ "messages.signedIn": "Hai effettuato l'accesso. Puoi commentare, mettere mi piace o ripubblicare qui sopra.",
67
+ "messages.signInFailed": "L'accesso non è stato completato. Riprova.",
68
+ "messages.accountNotFound": "Non abbiamo trovato quell'account. Controlla l'handle e riprova.",
69
+ "messages.invalidHandle": "Non sembra un handle. Di solito è qualcosa come alice.bsky.social.",
70
+ "messages.signedOut": "Sei stato disconnesso. Accedi di nuovo.",
71
+ "messages.needsPermission": "Per eliminare i commenti qui, questo sito ha bisogno di un'altra autorizzazione dal tuo account.",
72
+ "messages.notAllowed": "Qui non si può fare.",
73
+ "messages.newPermissions": "Ora questo sito può anche mettere mi piace e ripubblicare per te, quindi ha di nuovo bisogno della tua autorizzazione. Accedi di nuovo.",
74
+ "messages.tooLong": "I post possono avere fino a {{max}} caratteri.",
75
+ "messages.tooMany": "Stai andando un po' veloce. Aspetta un minuto e riprova.",
76
+ "messages.error": "Non ha funzionato. Riprova.",
77
+ "credit": "Realizzato con {{link}}"
78
+ }
@@ -0,0 +1,72 @@
1
+ {
2
+ "signIn.heading": "ログインしてコメント、いいね、リポストする",
3
+ "signIn.anyAccount": "{{bluesky}}を含め、どの{{atmosphereAccount}}でも使えます。ひとつのアカウントで、オープンなアプリのネットワーク全体を利用できます。",
4
+ "signIn.atmosphereAccount": "Atmosphereアカウント",
5
+ "signIn.button": "Atmosphereでログイン",
6
+ "signIn.matchingAccounts": "一致するアカウント",
7
+ "signIn.privacy": "承認はご自身のアカウントのサイト(ほとんどの方はBluesky)で行います。パスワードがこのサイトに渡ることはありません。アカウントをお持ちでない方は{{createAccount}}。",
8
+ "signIn.createAccount": "無料のAtmosphereアカウントを作成",
9
+ "elsewhere.text": "ここでログインしたくない場合は、{{link}} 返信は同じようにここに表示されます。いいねやリポストも同様です。",
10
+ "elsewhere.link": "Blueskyで返信 →",
11
+ "actions.label": "この投稿に反応する",
12
+ "actions.like": "いいね",
13
+ "actions.liked": "いいね済み",
14
+ "actions.repost": "リポスト",
15
+ "actions.reposted": "リポスト済み",
16
+ "actions.quote": "引用",
17
+ "composer.commentAs": "{{handle}}として{{verb}}",
18
+ "composer.comment": "コメント",
19
+ "composer.quote": "引用",
20
+ "composer.mentions": "メンションするアカウント",
21
+ "composer.previewCaption": "投稿の表示イメージ",
22
+ "composer.now": "たった今",
23
+ "composer.previewEmpty": "ここに文章が表示されます。",
24
+ "composer.quoting": "ご自身のアカウントでこの投稿を引用します",
25
+ "composer.replyingTo": "{{name}}への返信",
26
+ "composer.cancel": "キャンセル",
27
+ "composer.signOut": "ログアウト",
28
+ "composer.postComment": "コメントを投稿",
29
+ "composer.postQuote": "引用を投稿",
30
+ "composer.charactersLeft_other": "残り{{count}}文字",
31
+ "composer.posting": "投稿中…",
32
+ "posted.comment": "コメントを投稿しました。",
33
+ "posted.quote": "引用をBlueskyに投稿しました。",
34
+ "posted.see": "Blueskyで見る →",
35
+ "reactions.likes_other": "{{count}}件のいいね",
36
+ "reactions.reposts_other": "{{count}}件のリポスト",
37
+ "comment.author": "作成者",
38
+ "comment.viewOnBluesky": "Blueskyで見る",
39
+ "comment.someone": "このコメント",
40
+ "comment.reply": "返信",
41
+ "comment.replyTo": "{{name}}に返信",
42
+ "comment.likeLabel": "{{name}}のコメントにいいね",
43
+ "comment.repostLabel": "{{name}}のコメントをリポスト",
44
+ "comment.delete": "削除",
45
+ "comment.deleteLabel": "コメントを削除",
46
+ "comment.deleteConfirm": "このコメントをBlueskyから削除しますか?",
47
+ "comment.deleting": "削除中…",
48
+ "comment.deleted": "コメントを削除しました。",
49
+ "comment.deletePermission": "ここでコメントを削除するには、このサイトにアカウントからの許可がもうひとつ必要です。Blueskyのアプリから削除することもできます。",
50
+ "comment.continue": "許可に進む",
51
+ "comment.cancel": "キャンセル",
52
+ "signInFirst.reply": "返信するにはログインしてください。",
53
+ "signInFirst.like": "コメントにいいねするにはログインしてください。",
54
+ "signInFirst.repost": "コメントをリポストするにはログインしてください。",
55
+ "status.liked": "いいねしました。",
56
+ "status.likeRemoved": "いいねを取り消しました。",
57
+ "status.reposted": "リポストしました。",
58
+ "status.repostRemoved": "リポストを取り消しました。",
59
+ "status.signedOut": "ログアウトしました。",
60
+ "messages.signedIn": "ログインしました。上からコメント、いいね、リポストができます。",
61
+ "messages.signInFailed": "ログインが完了しませんでした。もう一度お試しください。",
62
+ "messages.accountNotFound": "そのアカウントが見つかりませんでした。ハンドルを確認して、もう一度お試しください。",
63
+ "messages.invalidHandle": "ハンドルではないようです。通常は alice.bsky.social のような形式です。",
64
+ "messages.signedOut": "ログアウトされました。もう一度ログインしてください。",
65
+ "messages.needsPermission": "ここでコメントを削除するには、このサイトにアカウントからの許可がもうひとつ必要です。",
66
+ "messages.notAllowed": "ここではその操作はできません。",
67
+ "messages.newPermissions": "このサイトはいいねとリポストもできるようになったため、改めて許可が必要です。もう一度ログインしてください。",
68
+ "messages.tooLong": "投稿は{{max}}文字までです。",
69
+ "messages.tooMany": "操作が早すぎます。1分待ってから、もう一度お試しください。",
70
+ "messages.error": "うまくいきませんでした。もう一度お試しください。",
71
+ "credit": "{{link}}で動作しています"
72
+ }
@@ -0,0 +1,75 @@
1
+ {
2
+ "signIn.heading": "Log in om te reageren, te liken of te reposten",
3
+ "signIn.anyAccount": "Elk {{atmosphereAccount}} werkt, ook {{bluesky}}: één account voor een heel netwerk van open apps.",
4
+ "signIn.atmosphereAccount": "Atmosphere-account",
5
+ "signIn.button": "Inloggen met Atmosphere",
6
+ "signIn.matchingAccounts": "Overeenkomende accounts",
7
+ "signIn.privacy": "Je keurt dit goed op de site van je eigen account (voor de meeste mensen die van Bluesky); je wachtwoord wordt hier nooit gedeeld. Nog geen account? {{createAccount}}.",
8
+ "signIn.createAccount": "Maak gratis een Atmosphere-account",
9
+ "elsewhere.text": "Liever hier niet inloggen? {{link}} Je reactie verschijnt hier net zo goed, net als likes en reposts.",
10
+ "elsewhere.link": "Reageer op Bluesky →",
11
+ "actions.label": "Reageer op dit bericht",
12
+ "actions.like": "Liken",
13
+ "actions.liked": "Geliket",
14
+ "actions.repost": "Reposten",
15
+ "actions.reposted": "Gerepost",
16
+ "actions.quote": "Citeren",
17
+ "composer.commentAs": "{{verb}} als {{handle}}",
18
+ "composer.comment": "Reageren",
19
+ "composer.quote": "Citeren",
20
+ "composer.mentions": "Accounts om te noemen",
21
+ "composer.previewCaption": "Zo ziet je bericht eruit",
22
+ "composer.now": "nu",
23
+ "composer.previewEmpty": "Je tekst verschijnt hier.",
24
+ "composer.quoting": "Je citeert dit bericht met je eigen account",
25
+ "composer.replyingTo": "Reactie op {{name}}",
26
+ "composer.cancel": "Annuleren",
27
+ "composer.signOut": "Uitloggen",
28
+ "composer.postComment": "Reactie plaatsen",
29
+ "composer.postQuote": "Citaat plaatsen",
30
+ "composer.charactersLeft_one": "Nog {{count}} teken",
31
+ "composer.charactersLeft_other": "Nog {{count}} tekens",
32
+ "composer.posting": "Plaatsen…",
33
+ "posted.comment": "Je reactie is geplaatst.",
34
+ "posted.quote": "Je citaat staat op Bluesky.",
35
+ "posted.see": "Bekijk het op Bluesky →",
36
+ "reactions.likes_one": "{{count}} like",
37
+ "reactions.likes_other": "{{count}} likes",
38
+ "reactions.reposts_one": "{{count}} repost",
39
+ "reactions.reposts_other": "{{count}} reposts",
40
+ "comment.author": "Auteur",
41
+ "comment.viewOnBluesky": "bekijk op Bluesky",
42
+ "comment.someone": "deze reactie",
43
+ "comment.reply": "Reageren",
44
+ "comment.replyTo": "Reageer op {{name}}",
45
+ "comment.likeLabel": "De reactie van {{name}} liken",
46
+ "comment.repostLabel": "De reactie van {{name}} reposten",
47
+ "comment.delete": "Verwijderen",
48
+ "comment.deleteLabel": "Je reactie verwijderen",
49
+ "comment.deleteConfirm": "Deze reactie van Bluesky verwijderen?",
50
+ "comment.deleting": "Verwijderen…",
51
+ "comment.deleted": "Je reactie is verwijderd.",
52
+ "comment.deletePermission": "Om hier reacties te verwijderen, heeft deze site nog één toestemming van je account nodig. Je kunt hem ook in elke Bluesky-app verwijderen.",
53
+ "comment.continue": "Doorgaan om toe te staan",
54
+ "comment.cancel": "Annuleren",
55
+ "signInFirst.reply": "Log in om te reageren.",
56
+ "signInFirst.like": "Log in om reacties te liken.",
57
+ "signInFirst.repost": "Log in om reacties te reposten.",
58
+ "status.liked": "Geliket.",
59
+ "status.likeRemoved": "Like verwijderd.",
60
+ "status.reposted": "Gerepost.",
61
+ "status.repostRemoved": "Repost verwijderd.",
62
+ "status.signedOut": "Je bent uitgelogd.",
63
+ "messages.signedIn": "Je bent ingelogd. Je kunt hierboven reageren, liken of reposten.",
64
+ "messages.signInFailed": "Het inloggen is niet afgerond. Probeer het opnieuw.",
65
+ "messages.accountNotFound": "We konden dat account niet vinden. Controleer de handle en probeer het opnieuw.",
66
+ "messages.invalidHandle": "Dat lijkt geen handle. Meestal is het zoiets als alice.bsky.social.",
67
+ "messages.signedOut": "Je bent uitgelogd. Log opnieuw in.",
68
+ "messages.needsPermission": "Om hier reacties te verwijderen, heeft deze site nog één toestemming van je account nodig.",
69
+ "messages.notAllowed": "Dat kan hier niet.",
70
+ "messages.newPermissions": "Deze site kan nu ook voor je liken en reposten en heeft daarom opnieuw je toestemming nodig. Log opnieuw in.",
71
+ "messages.tooLong": "Berichten mogen maximaal {{max}} tekens lang zijn.",
72
+ "messages.tooMany": "Je gaat wel snel. Wacht een minuut en probeer het opnieuw.",
73
+ "messages.error": "Dat is niet gelukt. Probeer het opnieuw.",
74
+ "credit": "Mogelijk gemaakt door {{link}}"
75
+ }
@@ -0,0 +1,78 @@
1
+ {
2
+ "signIn.heading": "Entre para comentar, curtir ou repostar",
3
+ "signIn.anyAccount": "Qualquer {{atmosphereAccount}} funciona, inclusive a do {{bluesky}}: uma só conta para toda uma rede de apps abertos.",
4
+ "signIn.atmosphereAccount": "conta Atmosphere",
5
+ "signIn.button": "Entrar com Atmosphere",
6
+ "signIn.matchingAccounts": "Contas correspondentes",
7
+ "signIn.privacy": "Você vai aprovar isso no site da sua própria conta (o do Bluesky, para a maioria das pessoas); sua senha nunca é compartilhada aqui. Ainda não tem conta? {{createAccount}}.",
8
+ "signIn.createAccount": "Crie uma conta Atmosphere grátis",
9
+ "elsewhere.text": "Prefere não entrar aqui? {{link}} Sua resposta aparece aqui do mesmo jeito, assim como curtidas e reposts.",
10
+ "elsewhere.link": "Responda no Bluesky →",
11
+ "actions.label": "Responder a esta publicação",
12
+ "actions.like": "Curtir",
13
+ "actions.liked": "Curtido",
14
+ "actions.repost": "Repostar",
15
+ "actions.reposted": "Repostado",
16
+ "actions.quote": "Citar",
17
+ "composer.commentAs": "{{verb}} como {{handle}}",
18
+ "composer.comment": "Comentar",
19
+ "composer.quote": "Citar",
20
+ "composer.mentions": "Contas para mencionar",
21
+ "composer.previewCaption": "Como sua publicação vai ficar",
22
+ "composer.now": "agora",
23
+ "composer.previewEmpty": "Seu texto aparece aqui.",
24
+ "composer.quoting": "Citando esta publicação na sua própria conta",
25
+ "composer.replyingTo": "Respondendo a {{name}}",
26
+ "composer.cancel": "Cancelar",
27
+ "composer.signOut": "Sair",
28
+ "composer.postComment": "Publicar comentário",
29
+ "composer.postQuote": "Publicar citação",
30
+ "composer.charactersLeft_one": "Resta {{count}}",
31
+ "composer.charactersLeft_many": "Restam {{count}}",
32
+ "composer.charactersLeft_other": "Restam {{count}}",
33
+ "composer.posting": "Publicando…",
34
+ "posted.comment": "Seu comentário foi publicado.",
35
+ "posted.quote": "Sua citação está no Bluesky.",
36
+ "posted.see": "Ver no Bluesky →",
37
+ "reactions.likes_one": "{{count}} curtida",
38
+ "reactions.likes_many": "{{count}} curtidas",
39
+ "reactions.likes_other": "{{count}} curtidas",
40
+ "reactions.reposts_one": "{{count}} repost",
41
+ "reactions.reposts_many": "{{count}} reposts",
42
+ "reactions.reposts_other": "{{count}} reposts",
43
+ "comment.author": "Autor",
44
+ "comment.viewOnBluesky": "ver no Bluesky",
45
+ "comment.someone": "este comentário",
46
+ "comment.reply": "Responder",
47
+ "comment.replyTo": "Responder a {{name}}",
48
+ "comment.likeLabel": "Curtir o comentário de {{name}}",
49
+ "comment.repostLabel": "Repostar o comentário de {{name}}",
50
+ "comment.delete": "Excluir",
51
+ "comment.deleteLabel": "Excluir seu comentário",
52
+ "comment.deleteConfirm": "Excluir este comentário do Bluesky?",
53
+ "comment.deleting": "Excluindo…",
54
+ "comment.deleted": "Seu comentário foi excluído.",
55
+ "comment.deletePermission": "Para excluir comentários aqui, este site precisa de mais uma permissão da sua conta. Você também pode excluí-lo em qualquer app do Bluesky.",
56
+ "comment.continue": "Continuar para aprovar",
57
+ "comment.cancel": "Cancelar",
58
+ "signInFirst.reply": "Entre para responder.",
59
+ "signInFirst.like": "Entre para curtir comentários.",
60
+ "signInFirst.repost": "Entre para repostar comentários.",
61
+ "status.liked": "Curtido.",
62
+ "status.likeRemoved": "Curtida removida.",
63
+ "status.reposted": "Repostado.",
64
+ "status.repostRemoved": "Repost removido.",
65
+ "status.signedOut": "Você saiu.",
66
+ "messages.signedIn": "Você entrou. Pode comentar, curtir ou repostar acima.",
67
+ "messages.signInFailed": "A entrada não foi concluída. Tente de novo.",
68
+ "messages.accountNotFound": "Não encontramos essa conta. Confira o identificador e tente de novo.",
69
+ "messages.invalidHandle": "Isso não parece um identificador. Normalmente é algo como alice.bsky.social.",
70
+ "messages.signedOut": "Você foi desconectado. Entre de novo.",
71
+ "messages.needsPermission": "Para excluir comentários aqui, este site precisa de mais uma permissão da sua conta.",
72
+ "messages.notAllowed": "Isso não pode ser feito aqui.",
73
+ "messages.newPermissions": "Este site agora também pode curtir e repostar por você, então precisa da sua permissão de novo. Entre de novo.",
74
+ "messages.tooLong": "As publicações podem ter até {{max}} caracteres.",
75
+ "messages.tooMany": "Você está indo rápido demais. Espere um minuto e tente de novo.",
76
+ "messages.error": "Não deu certo. Tente de novo.",
77
+ "credit": "Feito com {{link}}"
78
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-atmosphere-comments",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Atmosphere Comments for Astro: Bluesky comments, likes, reposts and quote posts for Astro sites; readers sign in with their Atmosphere (AT Protocol) account and respond without leaving the page.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,7 +31,7 @@
31
31
  },
32
32
  "types": "./index.d.ts",
33
33
  "bin": {
34
- "astro-atmosphere-comments": "./bin/cli.mjs"
34
+ "astro-atmosphere-comments": "bin/cli.mjs"
35
35
  },
36
36
  "files": [
37
37
  "index.mjs",
@@ -42,6 +42,7 @@
42
42
  "lib",
43
43
  "routes",
44
44
  "assets",
45
+ "locales",
45
46
  "README.md",
46
47
  "CHANGELOG.md",
47
48
  "LICENSE"
@@ -0,0 +1,41 @@
1
+ // Deletes one of the signed-in reader's own comments: a post of theirs that
2
+ // is a reply somewhere under one of the site's announcements. Nothing else,
3
+ // even though the permission behind it covers all of the reader's posts.
4
+ // Body: { post }. Readers who haven't allowed deleting yet get
5
+ // "needs-permission", and the page offers to ask their account host for it.
6
+ import type { APIRoute } from "astro";
7
+ import { json } from "../lib/http";
8
+ import { allows, deleteRecord, isPostUri, isSitePost, postsAsReader, rateLimit, reader } from "../lib/reader";
9
+ import { forgetThread } from "../lib/thread";
10
+
11
+ const tooMany = rateLimit(10, 60);
12
+
13
+ export const POST: APIRoute = async ({ request, url, cookies }) => {
14
+ const signedIn = await reader(request, url, cookies);
15
+ if (signedIn instanceof Response) return signedIn;
16
+
17
+ let body: { post?: unknown };
18
+ try {
19
+ body = await request.json();
20
+ } catch {
21
+ return json({ error: "bad-request" }, 400);
22
+ }
23
+ const { post } = body;
24
+ if (!isPostUri(post) || !post.startsWith(`at://${signedIn.did}/`)) return json({ error: "not-allowed" }, 400);
25
+ if (!allows(signedIn, "app.bsky.feed.post", "delete")) return json({ error: "needs-permission" }, 403);
26
+ if (tooMany(signedIn.did)) return json({ error: "too-many" }, 429);
27
+
28
+ try {
29
+ const [view] = await postsAsReader(signedIn.session, [post]);
30
+ if (!view) return json({ error: "not-found" }, 404);
31
+ // Only a reply under one of the site's announcements, never a post of its own.
32
+ const root = view.record?.reply?.root?.uri;
33
+ if (!isSitePost(root)) return json({ error: "not-allowed" }, 400);
34
+ await deleteRecord(signedIn, view.uri);
35
+ forgetThread(root);
36
+ return json({ deleted: view.uri });
37
+ } catch (error) {
38
+ console.warn("[atmosphere] delete failed:", error instanceof Error ? error.message : error);
39
+ return json({ error: "post-failed" }, 502);
40
+ }
41
+ };
package/routes/login.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // approve this site's request to post comments for them.
4
4
  import type { APIRoute } from "astro";
5
5
  import { safeReturnPath, withStatus } from "../lib/http";
6
- import { oauthClient, SCOPE } from "../lib/oauth";
6
+ import { DELETE_SCOPE, oauthClient, SCOPE } from "../lib/oauth";
7
7
 
8
8
  export const POST: APIRoute = async ({ request, url, redirect }) => {
9
9
  const form = await request.formData();
@@ -19,7 +19,9 @@ export const POST: APIRoute = async ({ request, url, redirect }) => {
19
19
  try {
20
20
  const client = await oauthClient(url.origin);
21
21
  const authorizeUrl = await client.authorize(handle, {
22
- scope: SCOPE,
22
+ // The delete route sends readers back here (with permission=delete) when
23
+ // they first delete a comment and haven't allowed that yet.
24
+ scope: form.get("permission") === "delete" ? `${SCOPE} ${DELETE_SCOPE}` : SCOPE,
23
25
  state: JSON.stringify({ returnTo }),
24
26
  });
25
27
  return redirect(authorizeUrl.href, 303);
@@ -1,6 +1,10 @@
1
- // Suggestions for an @mention as the reader types (?q=start of a handle or
2
- // name), from Bluesky's public typeahead search. Only for signed-in readers,
3
- // so it can't be used as a free search proxy; avatars come through this site.
1
+ // Account suggestions as the reader types (?q=start of a handle or name), from
2
+ // Bluesky's public typeahead search: for @mentions in the comment box, and for
3
+ // the handle box when signing in, so readers can see they've found the right
4
+ // account. Avatars come through this site. Signed-in readers are limited per
5
+ // account; everyone else per address, with an overall cap as well (behind a
6
+ // proxy every visitor may share one address), so the site can't be used to
7
+ // flood Bluesky's search. Answers are remembered for a minute.
4
8
  import type { APIRoute } from "astro";
5
9
  import { json } from "../lib/http";
6
10
  import { rateLimit } from "../lib/reader";
@@ -8,24 +12,50 @@ import settings from "../lib/settings";
8
12
  import { rememberAvatar } from "../lib/avatars";
9
13
  import { signedInDid } from "../lib/visitor";
10
14
 
11
- const tooMany = rateLimit(60, 1200);
15
+ const perReader = rateLimit(60, 1200);
16
+ const perAddress = rateLimit(30, 300);
17
+ const signedOutOverall = rateLimit(300, 6000);
12
18
 
13
- export const GET: APIRoute = async ({ url, cookies }) => {
19
+ type Actor = { did: string; handle: string; displayName?: string; avatar?: string };
20
+ const recent = new Map<string, { actors: Actor[]; at: number }>();
21
+ const REMEMBER_MS = 60 * 1000;
22
+ const MAX_REMEMBERED = 500;
23
+
24
+ async function search(q: string): Promise<Actor[]> {
25
+ const key = q.toLowerCase();
26
+ const known = recent.get(key);
27
+ if (known && Date.now() - known.at < REMEMBER_MS) return known.actors;
28
+ const response = await fetch(
29
+ `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(q)}&limit=6`,
30
+ { signal: AbortSignal.timeout(4000) },
31
+ );
32
+ if (!response.ok) return [];
33
+ const actors = ((await response.json()) as { actors?: Actor[] }).actors ?? [];
34
+ if (recent.size >= MAX_REMEMBERED) recent.delete(recent.keys().next().value!);
35
+ recent.set(key, { actors, at: Date.now() });
36
+ return actors;
37
+ }
38
+
39
+ export const GET: APIRoute = async ({ url, cookies, clientAddress }) => {
40
+ const q = (url.searchParams.get("q") ?? "").trim().replace(/^@/, "");
14
41
  const did = await signedInDid(cookies);
15
- if (!did) return json({ error: "signed-out" }, 401);
16
- const q = (url.searchParams.get("q") ?? "").trim();
17
- if (!q || q.length > 64 || /[\s<>"]/.test(q)) return json({ actors: [] });
18
- if (tooMany(did)) return json({ error: "too-many" }, 429);
42
+ // One letter is enough for an @mention; signing in needs two.
43
+ if (!q || (!did && q.length < 2) || q.length > 64 || /[\s<>"]/.test(q)) return json({ actors: [] });
44
+
45
+ if (did) {
46
+ if (perReader(did)) return json({ error: "too-many" }, 429);
47
+ } else {
48
+ let address = "unknown";
49
+ try {
50
+ address = clientAddress;
51
+ } catch {
52
+ // Some adapters can't tell; the overall cap still applies.
53
+ }
54
+ if (perAddress(address) || signedOutOverall("all")) return json({ error: "too-many" }, 429);
55
+ }
56
+
19
57
  try {
20
- const response = await fetch(
21
- `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(q)}&limit=6`,
22
- { signal: AbortSignal.timeout(4000) },
23
- );
24
- if (!response.ok) return json({ actors: [] });
25
- const data = (await response.json()) as {
26
- actors?: { did: string; handle: string; displayName?: string; avatar?: string }[];
27
- };
28
- const actors = (data.actors ?? []).map((actor) => {
58
+ const actors = (await search(q)).map((actor) => {
29
59
  if (actor.avatar) rememberAvatar(actor.did, actor.avatar);
30
60
  return {
31
61
  handle: actor.handle,