feat: limit Discord sharing and support community allowlists
This commit is contained in:
@@ -14,3 +14,5 @@ AUTH_COOKIE_SECRET=
|
||||
# View Channel, Send Messages, and Attach Files in this channel.
|
||||
DISCORD_BOT_TOKEN=
|
||||
DISCORD_SHARING_CHANNEL_ID=
|
||||
# Optional comma-separated Discord user IDs permitted to post. Empty permits all accounts.
|
||||
DISCORD_SHARING_ALLOWED_USER_IDS=
|
||||
|
||||
@@ -335,3 +335,11 @@ It excludes session tokens and credentials. `DELETE /api/account` requires match
|
||||
Origin and `{ "confirmation": "DELETE" }`; it atomically removes owned records and
|
||||
revokes every session. Discord posts and retained operator backups are not erased
|
||||
by this action.
|
||||
|
||||
Discord sharing reserves persistent quotas before posting: one new delivery per
|
||||
Discord user per 60 seconds, and ten per channel per 60 seconds. Rechecking an
|
||||
existing delivery does not consume quota. Rejected attempts consume quota too;
|
||||
429 responses include `Retry-After`. Optional `DISCORD_SHARING_ALLOWED_USER_IDS`
|
||||
(comma-separated Discord IDs) restricts posting to community members you approve.
|
||||
An empty list permits all signed-in accounts. Restricted users can still preview
|
||||
and download PNGs. Quota rows expire and are pruned after 24 hours.
|
||||
|
||||
5
drizzle/0006_sharing_limits.sql
Normal file
5
drizzle/0006_sharing_limits.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE `sharing_limits` (
|
||||
`key` text PRIMARY KEY NOT NULL,
|
||||
`started_at` integer NOT NULL,
|
||||
`count` integer NOT NULL
|
||||
);
|
||||
788
drizzle/meta/0006_snapshot.json
Normal file
788
drizzle/meta/0006_snapshot.json
Normal file
@@ -0,0 +1,788 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "b5822e36-a1a9-42dd-9439-9490297bc097",
|
||||
"prevId": "8241efb4-d92c-4b72-ac08-a5b5c1cd4a18",
|
||||
"tables": {
|
||||
"combined_charts": {
|
||||
"name": "combined_charts",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"habit_ids": {
|
||||
"name": "habit_ids",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"combined_charts_user_idx": {
|
||||
"name": "combined_charts_user_idx",
|
||||
"columns": [
|
||||
"user_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"combined_charts_user_id_users_id_fk": {
|
||||
"name": "combined_charts_user_id_users_id_fk",
|
||||
"tableFrom": "combined_charts",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"discord_deliveries": {
|
||||
"name": "discord_deliveries",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"image_hash": {
|
||||
"name": "image_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"message_url": {
|
||||
"name": "message_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"discord_deliveries_user_id_users_id_fk": {
|
||||
"name": "discord_deliveries_user_id_users_id_fk",
|
||||
"tableFrom": "discord_deliveries",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"habit_calendar_settings": {
|
||||
"name": "habit_calendar_settings",
|
||||
"columns": {
|
||||
"habit_id": {
|
||||
"name": "habit_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"habit_calendar_settings_habit_id_habits_id_fk": {
|
||||
"name": "habit_calendar_settings_habit_id_habits_id_fk",
|
||||
"tableFrom": "habit_calendar_settings",
|
||||
"tableTo": "habits",
|
||||
"columnsFrom": [
|
||||
"habit_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"habit_days": {
|
||||
"name": "habit_days",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"habit_id": {
|
||||
"name": "habit_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"revision_id": {
|
||||
"name": "revision_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"timezone": {
|
||||
"name": "timezone",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ends_at": {
|
||||
"name": "ends_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"count_set": {
|
||||
"name": "count_set",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"count": {
|
||||
"name": "count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"done": {
|
||||
"name": "done",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"habit_days_lookup_idx": {
|
||||
"name": "habit_days_lookup_idx",
|
||||
"columns": [
|
||||
"habit_id",
|
||||
"date",
|
||||
"revision_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"habit_days_habit_id_habits_id_fk": {
|
||||
"name": "habit_days_habit_id_habits_id_fk",
|
||||
"tableFrom": "habit_days",
|
||||
"tableTo": "habits",
|
||||
"columnsFrom": [
|
||||
"habit_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"habit_days_revision_id_habit_revisions_id_fk": {
|
||||
"name": "habit_days_revision_id_habit_revisions_id_fk",
|
||||
"tableFrom": "habit_days",
|
||||
"tableTo": "habit_revisions",
|
||||
"columnsFrom": [
|
||||
"revision_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"habit_revisions": {
|
||||
"name": "habit_revisions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"habit_id": {
|
||||
"name": "habit_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"effective_date": {
|
||||
"name": "effective_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"config": {
|
||||
"name": "config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"habit_revisions_date_idx": {
|
||||
"name": "habit_revisions_date_idx",
|
||||
"columns": [
|
||||
"habit_id",
|
||||
"effective_date",
|
||||
"id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"habit_revisions_habit_id_habits_id_fk": {
|
||||
"name": "habit_revisions_habit_id_habits_id_fk",
|
||||
"tableFrom": "habit_revisions",
|
||||
"tableTo": "habits",
|
||||
"columnsFrom": [
|
||||
"habit_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"habits": {
|
||||
"name": "habits",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_date": {
|
||||
"name": "created_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"materialized_through": {
|
||||
"name": "materialized_through",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"habits_user_idx": {
|
||||
"name": "habits_user_idx",
|
||||
"columns": [
|
||||
"user_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"habits_user_id_users_id_fk": {
|
||||
"name": "habits_user_id_users_id_fk",
|
||||
"tableFrom": "habits",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"progress_events": {
|
||||
"name": "progress_events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"day_id": {
|
||||
"name": "day_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"occurrence_id": {
|
||||
"name": "occurrence_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"before": {
|
||||
"name": "before",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"after": {
|
||||
"name": "after",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"progress_events_day_idx": {
|
||||
"name": "progress_events_day_idx",
|
||||
"columns": [
|
||||
"day_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"progress_events_day_id_habit_days_id_fk": {
|
||||
"name": "progress_events_day_id_habit_days_id_fk",
|
||||
"tableFrom": "progress_events",
|
||||
"tableTo": "habit_days",
|
||||
"columnsFrom": [
|
||||
"day_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sessions": {
|
||||
"name": "sessions",
|
||||
"columns": {
|
||||
"token_hash": {
|
||||
"name": "token_hash",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"sessions_user_id_idx": {
|
||||
"name": "sessions_user_id_idx",
|
||||
"columns": [
|
||||
"user_id"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"sessions_expires_at_idx": {
|
||||
"name": "sessions_expires_at_idx",
|
||||
"columns": [
|
||||
"expires_at"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sharing_limits": {
|
||||
"name": "sharing_limits",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"count": {
|
||||
"name": "count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_occurrences": {
|
||||
"name": "task_occurrences",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"day_id": {
|
||||
"name": "day_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"done": {
|
||||
"name": "done",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"expired_at": {
|
||||
"name": "expired_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"closed_at": {
|
||||
"name": "closed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"task_occurrences_day_idx": {
|
||||
"name": "task_occurrences_day_idx",
|
||||
"columns": [
|
||||
"day_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"task_occurrences_day_id_habit_days_id_fk": {
|
||||
"name": "task_occurrences_day_id_habit_days_id_fk",
|
||||
"tableFrom": "task_occurrences",
|
||||
"tableTo": "habit_days",
|
||||
"columnsFrom": [
|
||||
"day_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"discord_id": {
|
||||
"name": "discord_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"global_name": {
|
||||
"name": "global_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"avatar_hash": {
|
||||
"name": "avatar_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"timezone": {
|
||||
"name": "timezone",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'UTC'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"users_discord_id_unique": {
|
||||
"name": "users_discord_id_unique",
|
||||
"columns": [
|
||||
"discord_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,13 @@
|
||||
"when": 1788536554543,
|
||||
"tag": "0005_bored_firelord",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "6",
|
||||
"when": 1788539042148,
|
||||
"tag": "0006_sharing_limits",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -60,3 +60,9 @@ export const combinedCharts = sqliteTable('combined_charts', {
|
||||
habitIds: text('habit_ids', { mode: 'json' }).$type<string[]>().notNull(), settings: text('settings', { mode: 'json' }).$type<import('../habits/contracts').CalendarSettings>().notNull(),
|
||||
createdAt: integer('created_at').notNull(), updatedAt: integer('updated_at').notNull(),
|
||||
}, t => [index('combined_charts_user_idx').on(t.userId)]);
|
||||
|
||||
export const sharingLimits = sqliteTable('sharing_limits', {
|
||||
key: text('key').primaryKey(),
|
||||
startedAt: integer('started_at').notNull(),
|
||||
count: integer('count').notNull(),
|
||||
});
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
export type DiscordSharingConfig = { token: string; channelId: string };
|
||||
export type DiscordSharingConfig = { token: string; channelId: string; allowedUserIds?: string[] };
|
||||
|
||||
/** Read only from the server entrypoint; never include credentials in public config. */
|
||||
export function readDiscordSharingConfig(env = process.env): DiscordSharingConfig {
|
||||
const allowedUserIds = (env.DISCORD_SHARING_ALLOWED_USER_IDS ?? "").split(",").map(id => id.trim()).filter(Boolean);
|
||||
if (allowedUserIds.some(id => !/^\d{17,20}$/.test(id))) throw new Error("DISCORD_SHARING_ALLOWED_USER_IDS must contain comma-separated Discord user IDs");
|
||||
return {
|
||||
allowedUserIds,
|
||||
token: (env.DISCORD_BOT_TOKEN ?? "").trim().replace(/^Bot\s+/i, ""),
|
||||
channelId: (env.DISCORD_SHARING_CHANNEL_ID ?? "").trim(),
|
||||
};
|
||||
|
||||
@@ -11,9 +11,10 @@ const botConfig = { token: "test-bot-token-do-not-expose", channelId: "223456789
|
||||
const channel = { type: 0, id: botConfig.channelId, guild_id: "323456789012345678", name: "progress" };
|
||||
function setup(request: DiscordFetch = async () => Response.json(channel), config: DiscordSharingConfig = botConfig) {
|
||||
const f = fixture(); fixtures.push(f);
|
||||
const app = createApi(f.db, { origin: f.origin, clientId: "", clientSecret: "", cookieSecret: "test-secret-with-at-least-32-characters" }, undefined, () => Date.parse("2026-09-04T12:00:00Z"), request, config);
|
||||
let timestamp = Date.parse("2026-09-04T12:00:00Z");
|
||||
const app = createApi(f.db, { origin: f.origin, clientId: "", clientSecret: "", cookieSecret: "test-secret-with-at-least-32-characters" }, undefined, () => timestamp, request, config);
|
||||
const call = (path: string, method = "GET", body?: unknown, user = "a", origin = f.origin) => app.request(`${f.origin}/api/sharing${path}`, { method, headers: { Cookie: `minabot_session=${user.repeat(43)}`, Origin: origin, ...(body instanceof FormData ? {} : { "Content-Type": "application/json" }) }, body: body instanceof FormData ? body : body === undefined ? undefined : JSON.stringify(body) });
|
||||
return { f, call, connect: () => call("/discord") };
|
||||
return { f, call, advance: (seconds: number) => { timestamp += seconds * 1000; }, connect: () => call("/discord") };
|
||||
}
|
||||
// A small real PNG is not 1200px wide; use a header fixture for transport validation.
|
||||
function upload(id = crypto.randomUUID()) {
|
||||
@@ -87,12 +88,14 @@ test("delivery sends exactly the PNG with mentions disabled and deduplicates ret
|
||||
});
|
||||
test("uncertain delivery is not resent and a known rate-limit rejection can be retried", async () => {
|
||||
let posts = 0;
|
||||
const { call, connect } = setup(async (_, init) => {
|
||||
const { call, connect, advance } = setup(async (_, init) => {
|
||||
if (init?.method !== "POST") return Response.json(channel);
|
||||
posts++; if (posts === 1) return new Response(null, { status: 429 }); throw new Error("Network failed after sending");
|
||||
});
|
||||
await connect(); const id = crypto.randomUUID();
|
||||
expect((await call("/discord/send", "POST", upload(id))).status).toBe(429);
|
||||
expect((await call("/discord/send", "POST", upload(id))).status).toBe(429);
|
||||
advance(60);
|
||||
expect((await (await call("/discord/send", "POST", upload(id))).json()).status).toBe("uncertain");
|
||||
expect((await (await call("/discord/send", "POST", upload(id))).json()).status).toBe("uncertain"); expect(posts).toBe(2);
|
||||
});
|
||||
@@ -103,3 +106,32 @@ test("invalid files and absent bot configuration cannot send", async () => {
|
||||
await connect(); const form = upload(); form.set("image", new Blob(["not png"], { type: "image/png" }), "x.png");
|
||||
expect((await call("/discord/send", "POST", form)).status).toBe(422); expect(posts).toBe(0);
|
||||
});
|
||||
|
||||
|
||||
test("posting quotas block new deliveries, allow duplicate checks, and expire", async () => {
|
||||
let posts = 0;
|
||||
const { f, call, advance } = setup(async (_, init) => {
|
||||
if (init?.method === "POST") { posts++; return Response.json({ id: "423456789012345678" }); }
|
||||
return Response.json(channel);
|
||||
});
|
||||
const id = crypto.randomUUID();
|
||||
expect((await call("/discord/send", "POST", upload(id))).status).toBe(200);
|
||||
expect((await call("/discord/send", "POST", upload(id))).status).toBe(200);
|
||||
const limited = await call("/discord/send", "POST", upload());
|
||||
expect(limited.status).toBe(429); expect(limited.headers.get("retry-after")).toBe("60");
|
||||
expect((await call("/discord/send", "POST", upload(), "b")).status).toBe(200);
|
||||
expect(posts).toBe(2);
|
||||
f.sqlite.query("UPDATE sharing_limits SET count = 10 WHERE key = ?").run(`channel:${channel.id}`);
|
||||
advance(30);
|
||||
expect((await call("/discord/send", "POST", upload())).status).toBe(429);
|
||||
advance(30);
|
||||
expect((await call("/discord/send", "POST", upload())).status).toBe(200);
|
||||
expect(posts).toBe(3);
|
||||
});
|
||||
|
||||
test("an allowlist blocks unauthorized posting without preventing exports", async () => {
|
||||
const { call } = setup(undefined, { ...botConfig, allowedUserIds: ["bob"] });
|
||||
expect((await call("/discord/send", "POST", upload())).status).toBe(403);
|
||||
expect((await (await call("/discord")).json()).connected).toBe(false);
|
||||
expect((await (await call("/discord", "GET", undefined, "b")).json()).connected).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { Hono } from "hono";
|
||||
import { bodyLimit } from "hono/body-limit";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, lt } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import type { AppDatabase, AuthEnv, createAuth } from "../auth";
|
||||
import { discordDeliveries } from "../db/schema";
|
||||
import { discordDeliveries, sharingLimits } from "../db/schema";
|
||||
import { HabitService, ApiError } from "../habits/service";
|
||||
import { shade } from "../habits/calendar";
|
||||
import { shareInput, type ShareData } from "./contracts";
|
||||
@@ -93,11 +93,14 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof cre
|
||||
});
|
||||
app.get("/discord", async c => {
|
||||
if (!configured) return c.json({ connected: false, message: "Discord sharing has not been configured on this server." });
|
||||
if (config.allowedUserIds?.length && !config.allowedUserIds.includes(c.get("user").discordId))
|
||||
return c.json({ connected: false, message: "Discord sharing is limited to approved community members. PNG download remains available." });
|
||||
const destination = await channel();
|
||||
return c.json({ connected: true, name: destination.name, channelUrl: destination.channelUrl });
|
||||
});
|
||||
app.post("/discord/send", async c => {
|
||||
const userId = c.get("user").id;
|
||||
if (config.allowedUserIds?.length && !config.allowedUserIds.includes(c.get("user").discordId)) return c.json({ error: "Discord sharing is limited to approved community members." }, 403);
|
||||
if (!configured) throw new ApiError(422, "Discord sharing has not been configured on this server.");
|
||||
const form = await c.req.formData().catch(() => { throw new ApiError(400, "Expected a progress image."); });
|
||||
const id = z.string().uuid().safeParse(form.get("deliveryId"));
|
||||
@@ -112,7 +115,38 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof cre
|
||||
if (previous.userId !== userId || previous.imageHash !== imageHash) throw new ApiError(409, "Create a new preview before sending again.");
|
||||
return c.json({ status: previous.status === "sent" ? "sent" : "uncertain", messageUrl: previous.messageUrl ?? undefined });
|
||||
}
|
||||
db.insert(discordDeliveries).values({ id: id.data, userId, imageHash, status: "pending", createdAt: now() }).run();
|
||||
const timestamp = now();
|
||||
// Reserve both quotas and the delivery before any asynchronous outbound work.
|
||||
// SQLite makes limits survive restarts and coordinate concurrent workers.
|
||||
const retryAfter = db.transaction(tx => {
|
||||
const raced = tx.select().from(discordDeliveries).where(eq(discordDeliveries.id, id.data)).get();
|
||||
if (raced) {
|
||||
if (raced.userId !== userId || raced.imageHash !== imageHash) throw new ApiError(409, "Create a new preview before sending again.");
|
||||
return -1;
|
||||
}
|
||||
tx.delete(sharingLimits).where(lt(sharingLimits.startedAt, timestamp - 86400000)).run();
|
||||
const quotas = [
|
||||
{ key: `channel:${config.channelId}`, maximum: 10 },
|
||||
{ key: `channel:${config.channelId}:user:${c.get("user").discordId}`, maximum: 1 },
|
||||
].map(quota => ({ ...quota, row: tx.select().from(sharingLimits).where(eq(sharingLimits.key, quota.key)).get() }));
|
||||
const blocked = quotas.filter(({ row, maximum }) => row && timestamp < row.startedAt + 60000 && row.count >= maximum);
|
||||
if (blocked.length) return Math.max(...blocked.map(({ row }) => Math.ceil((row!.startedAt + 60000 - timestamp) / 1000)));
|
||||
for (const { key, row } of quotas) {
|
||||
const current = row && timestamp < row.startedAt + 60000;
|
||||
tx.insert(sharingLimits).values({ key, startedAt: current ? row.startedAt : timestamp, count: current ? row.count + 1 : 1 })
|
||||
.onConflictDoUpdate({ target: sharingLimits.key, set: { startedAt: current ? row.startedAt : timestamp, count: current ? row.count + 1 : 1 } }).run();
|
||||
}
|
||||
tx.insert(discordDeliveries).values({ id: id.data, userId, imageHash, status: "pending", createdAt: timestamp }).run();
|
||||
return 0;
|
||||
}, { behavior: "immediate" });
|
||||
if (retryAfter === -1) {
|
||||
const delivery = db.select().from(discordDeliveries).where(eq(discordDeliveries.id, id.data)).get()!;
|
||||
return c.json({ status: delivery.status === "sent" ? "sent" : "uncertain", messageUrl: delivery.messageUrl ?? undefined });
|
||||
}
|
||||
if (retryAfter > 0) {
|
||||
c.header("Retry-After", String(retryAfter));
|
||||
return c.json({ error: `Sharing limit reached. Try again in ${retryAfter} seconds.` }, 429);
|
||||
}
|
||||
const attachment = new FormData();
|
||||
attachment.set("payload_json", JSON.stringify({ allowed_mentions: { parse: [] }, nonce: createHash("sha256").update(`${userId}:${id.data}`).digest("hex").slice(0, 24), enforce_nonce: true, attachments: [{ id: 0, filename: "minabot-progress.png", description: "Progress card shared from minabot" }] }));
|
||||
attachment.set("files[0]", image, "minabot-progress.png");
|
||||
|
||||
Reference in New Issue
Block a user