From 4b755edf25e1a78861bfccfb8be7267f9efd4d6c Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:37:18 -0600 Subject: [PATCH] fix(devvit): scrub deleted content, 30-day default retention, GPL-3.0-only license Declare onPostDelete and onCommentDelete. A USER or ADMIN deletion blanks targetAuthor, permalink, and reason on matching rows and the prior-removal reason, then forces a wiki rebuild. MODERATOR deletions are kept. onAccountDelete is not in the config schema, so default retention and display fall to 30 days. PRIVACY notes wiki revision history. Co-Authored-By: Claude Sonnet 5.5 --- devvit/DEVELOPING.md | 9 ++--- devvit/PRIVACY.md | 12 ++++--- devvit/README.md | 14 +++++--- devvit/TERMS.md | 2 +- devvit/devvit.json | 8 +++-- devvit/package-lock.json | 2 +- devvit/package.json | 2 +- devvit/src/core/constants.ts | 4 +-- devvit/src/core/deletion.ts | 26 ++++++++++++++ devvit/src/core/store.ts | 23 +++++++++++++ devvit/src/server/index.ts | 28 ++++++++++++++- devvit/test/deletion.test.ts | 66 ++++++++++++++++++++++++++++++++++++ devvit/test/settings.test.ts | 4 +-- devvit/test/store.test.ts | 24 +++++++++++++ 14 files changed, 201 insertions(+), 23 deletions(-) create mode 100644 devvit/src/core/deletion.ts create mode 100644 devvit/test/deletion.test.ts diff --git a/devvit/DEVELOPING.md b/devvit/DEVELOPING.md index 361bc60..a12a8dd 100644 --- a/devvit/DEVELOPING.md +++ b/devvit/DEVELOPING.md @@ -11,12 +11,13 @@ row. Reddit hosts and authenticates it: no bot password, no daemon, no SQLite. | Path | Purpose | | --- | --- | -| `devvit.json` | App manifest: server bundle, Redis + Reddit permissions, cron task, menu items, settings | -| `src/server/index.ts` | Hono server: `/internal/cron/*`, `/internal/menu/*`, `/internal/settings/*` | +| `devvit.json` | App manifest: server bundle, Redis + Reddit permissions, cron task, delete triggers, menu items, settings | +| `src/server/index.ts` | Hono server: `/internal/cron/*`, `/internal/triggers/*`, `/internal/menu/*`, `/internal/settings/*` | | `src/server/reddit.ts` | Mod log and wiki calls through `reddit` from `@devvit/web/server` | | `src/core/record.ts` | Mod-log entry to stored row (Python `store_processed_action`) | | `src/core/render.ts` | Page renderer (Python `build_wiki_content`), FR-7 merging, FR-8 approval gating | | `src/core/store.ts` | Redis layout (hashes and sorted sets only) | +| `src/core/deletion.ts` | Scrub rules for the `onPostDelete` and `onCommentDelete` triggers | | `src/core/publish.ts` | One cycle: ingest, render, hash-skip, size guard, write, prune | | `src/core/settings.ts` | Setting validators and config loader | | `test/` | Vitest + `@devvit/test`; `test/parity/` holds the Python golden comparison | @@ -34,8 +35,8 @@ row. Reddit hosts and authenticates it: no bot password, no daemon, no SQLite. | Setting | Default | Notes | | --- | --- | --- | | Wiki page name | `modlog-devvit` | `config/*`, `automoderator`, `usernotes`, `toolbox` are refused | -| Days shown on the wiki page | 90 | 1-365, capped at days kept | -| Days of history kept | 90 | 1-365 | +| Days shown on the wiki page | 30 | 1-365, capped at days kept | +| Days of history kept | 30 | 1-365 | | Actions shown on the wiki page | removals, spam, `addremovalreason`, approvals | Python `DEFAULT_WIKI_ACTIONS` | | Anonymize moderators | on | Cannot be turned off, as in the Python app | | Ignored moderators | empty | Comma-separated; their actions are never stored | diff --git a/devvit/PRIVACY.md b/devvit/PRIVACY.md index d0f08f1..2779f42 100644 --- a/devvit/PRIVACY.md +++ b/devvit/PRIVACY.md @@ -7,9 +7,9 @@ autowikimod is not an official Reddit product. It runs on Reddit's Developer Pla ## Summary - The app copies mod-log entries to a wiki page in the subreddit that installed it. -- It keeps those entries in Reddit-hosted storage for that subreddit only, for 90 days by default. +- It keeps those entries in Reddit-hosted storage for that subreddit only, for 30 days by default. - It sends nothing to any outside server. It has no analytics, ads, payments, or AI use. -- Known gap: it does not yet remove an entry when the post, comment, or account is deleted. See "Deleted content". +- When a user or an admin deletes a post or comment, it blanks the stored author, link, and reason and rewrites the wiki page. Account deletion sends it no signal, so the retention period applies. See "Deleted content". ## What the app handles @@ -40,14 +40,16 @@ The app calls no service other than Reddit's. ## Retention -- Stored entries are deleted when they are older than "Days of history kept". Default 90 days, allowed 1 to 365. The check runs after every publish cycle, which is every 20 minutes. -- Rows leave the wiki page when they pass "Days shown" (default 90, never more than the retention days) or when the page is trimmed for size. +- Stored entries are deleted when they are older than "Days of history kept". Default 30 days, allowed 1 to 365. The check runs after every publish cycle, which is every 20 minutes. +- Rows leave the wiki page when they pass "Days shown" (default 30, never more than the retention days) or when the page is trimmed for size. - Run status values are overwritten on each run and are not deleted on a schedule. - The wiki page itself is not deleted by the app. ## Deleted content -The app does not yet delete a stored entry when its post, comment, or author account is deleted on Reddit. The author's username, the content link, and the reason text stay in app storage. They also stay on the wiki page. This lasts until the retention period ends or the page is rewritten without them. Reddit's Devvit Rules require removal on deletion. This is a known gap. Use a short retention period until it is fixed. +- Post or comment deleted by its author or an admin: the app receives Reddit's delete event and blanks the author's username, the content link, and the reason text on every stored entry for that item. It then rewrites the wiki page. The action type, moderator label, and time remain. An entry for a removal by a moderator is kept, because that is a mod action and not a deletion. +- Author account deleted: Reddit sends the app no event for this. Entries are not scrubbed early. They are deleted when the retention period ends (default 30 days). +- Wiki revision history: the app cannot edit or delete revisions of the wiki page. Text it wrote before a scrub, including a deleted author's username, may remain in the page's revision history. Reddit's policies apply to that history, and moderators can manage it in Reddit's wiki tools. ## Uninstall and removal diff --git a/devvit/README.md b/devvit/README.md index 4628484..e5740ac 100644 --- a/devvit/README.md +++ b/devvit/README.md @@ -8,7 +8,7 @@ Before installing, note the following. - The wiki page shows the author's username for each action ("Post by u/name") and the moderator's reason text. Set the wiki page's permission to what you want visible, in the subreddit's wiki settings, before the first run. - Human moderators are always shown as `HumanModerator`. This cannot be turned off. `AutoModerator` and `Reddit` are shown by name. -- The app stores rows for up to the retention period you set (default 90 days). A deleted post, comment, or account is not yet scrubbed from the app's storage or the wiki page. See [Deleted content](#deleted-content). Until that is fixed, use the shortest retention that works for you. +- The app stores rows for up to the retention period you set (default 30 days). When a user or an admin deletes a post or comment, the app blanks that row's author, link, and reason and rewrites the wiki page. Account deletions are not signaled to the app, so retention is the limit there. See [Deleted content](#deleted-content). - The app writes to one wiki page that you name. It refuses `config/*`, `automoderator`, `usernotes`, and `toolbox`. Point it at a page that holds nothing else, because each run overwrites the page. ## What it does @@ -29,8 +29,8 @@ Before installing, note the following. | Setting | Default | What it does | | --- | --- | --- | | Wiki page name | `modlog-devvit` | Page the log is written to. Lowercase letters, digits, `_`, `-`, `/`. Up to 128 characters. | -| Days shown on the wiki page | 90 | 1 to 365. Never more than the retention days. | -| Days of history kept | 90 | 1 to 365. Older entries are deleted from app storage each run. | +| Days shown on the wiki page | 30 | 1 to 365. Never more than the retention days. | +| Days of history kept | 30 | 1 to 365. Older entries are deleted from app storage each run. | | Actions shown on the wiki page | removals, spam removals, removal reasons, approvals | Pick from 43 mod-log action types, such as `banuser`, `lock`, or `addnote`. At least one is required. | | Anonymize moderators | on | Human moderators show as `HumanModerator`. Cannot be turned off. | | Ignored moderators | empty | Comma-separated usernames. Their actions are never stored. | @@ -58,7 +58,12 @@ The app does not use HTTP fetch, payments, or any LLM. It sends no data off Redd ## Deleted content -The app does not yet remove a stored row when its post, comment, or account is deleted. The author's username, the post or comment link, and the reason text stay in app storage and on the wiki page until the retention period ends. Reddit's Devvit Rules require removal on deletion. This is a known gap. If you need an entry removed sooner, lower "Days of history kept" and run **Modlog: Force rebuild**, or edit the wiki page, or contact us. +Reddit's Devvit Rules do not allow keeping deleted content, even anonymized. The app handles this in two ways. + +- Post or comment deleted by its author or an admin: the app receives Reddit's delete event, blanks the author, link, and reason on every stored row for that item, and rewrites the wiki page. A removal by a moderator is a mod action, not a deletion, and its row stays. +- Account deleted: Reddit sends the app no event for this, so rows are not scrubbed early. The retention period (default 30 days) is the limit. Lower "Days of history kept" for a shorter limit. + +Reddit keeps earlier versions of a wiki page in its revision history. The app cannot edit or delete revisions, so text it wrote before a scrub can remain there. Moderators can manage revisions in Reddit's wiki tools. ## Privacy and terms @@ -73,6 +78,7 @@ To report an app that breaks Reddit's rules, use Reddit's own report flow. ## Changelog +- Unreleased: scrub author, link, and reason from stored rows and the wiki page when a user or admin deletes a post or comment. Default days kept and days shown are now 30. The package license is now GPL-3.0-only, matching the repository. - 0.0.0: first Devvit Web version. Cron publish, three menu items, six settings. ## License diff --git a/devvit/TERMS.md b/devvit/TERMS.md index 61a9fca..962c4b0 100644 --- a/devvit/TERMS.md +++ b/devvit/TERMS.md @@ -23,7 +23,7 @@ The app's source code is licensed under the GNU General Public License v3.0 (see ## 5. Privacy -See the [Privacy Policy](PRIVACY.md). It includes a known gap in how deleted content is handled. +See the [Privacy Policy](PRIVACY.md). It covers how deleted content is handled. ## 6. No warranty diff --git a/devvit/devvit.json b/devvit/devvit.json index 6137624..9ccee0a 100644 --- a/devvit/devvit.json +++ b/devvit/devvit.json @@ -9,6 +9,10 @@ "reddit": true, "redis": true }, + "triggers": { + "onPostDelete": "/internal/triggers/on-post-delete", + "onCommentDelete": "/internal/triggers/on-comment-delete" + }, "scheduler": { "tasks": { "publish-modlog": { @@ -55,14 +59,14 @@ "type": "number", "label": "Days shown on the wiki page", "helpText": "1-365. Capped at the retention days.", - "defaultValue": 90, + "defaultValue": 30, "validationEndpoint": "/internal/settings/validate-days" }, "retentionDays": { "type": "number", "label": "Days of history kept", "helpText": "1-365. Older entries are deleted from app storage.", - "defaultValue": 90, + "defaultValue": 30, "validationEndpoint": "/internal/settings/validate-days" }, "wikiActions": { diff --git a/devvit/package-lock.json b/devvit/package-lock.json index 6f53a9b..16cd2c4 100644 --- a/devvit/package-lock.json +++ b/devvit/package-lock.json @@ -7,7 +7,7 @@ "": { "name": "autowikimod", "version": "0.0.0", - "license": "BSD-3-Clause", + "license": "GPL-3.0-only", "dependencies": { "@devvit/start": "0.14.8", "@devvit/web": "0.14.8", diff --git a/devvit/package.json b/devvit/package.json index de215b7..dbc20ac 100644 --- a/devvit/package.json +++ b/devvit/package.json @@ -2,7 +2,7 @@ "private": true, "name": "autowikimod", "version": "0.0.0", - "license": "BSD-3-Clause", + "license": "GPL-3.0-only", "type": "module", "description": "Publishes a subreddit's moderation log to a wiki page (Reddit Devvit Web app).", "scripts": { diff --git a/devvit/src/core/constants.ts b/devvit/src/core/constants.ts index 2d2879a..6f6f82b 100644 --- a/devvit/src/core/constants.ts +++ b/devvit/src/core/constants.ts @@ -60,8 +60,8 @@ export const ANON_LABEL = 'HumanModerator'; export const DAYS_MIN = 1; export const DAYS_MAX = 365; -export const DEFAULT_RETENTION_DAYS = 90; -export const DEFAULT_DISPLAY_DAYS = 90; +export const DEFAULT_RETENTION_DAYS = 30; +export const DEFAULT_DISPLAY_DAYS = 30; export const DEFAULT_WIKI_PAGE = 'modlog-devvit'; /** Python max_wiki_entries_per_page default. */ diff --git a/devvit/src/core/deletion.ts b/devvit/src/core/deletion.ts new file mode 100644 index 0000000..ebab146 --- /dev/null +++ b/devvit/src/core/deletion.ts @@ -0,0 +1,26 @@ +/** + * Devvit Rules: deleted content must not be retained, even anonymized. A post or + * comment deleted by its author or an admin has its author, link and reason + * blanked in storage. A moderator removal is a mod action, not a deletion. + */ +import type { Store } from './store.js'; + +export type DeletionSource = number | string | undefined; + +/** EventSource arrives as an enum number or name. MODERATOR is the only source that keeps rows. */ +export function shouldScrub(source: DeletionSource): boolean { + return source !== 3 && source !== 'MODERATOR'; +} + +/** "t3_abc", "t1_abc" or "abc" to "abc"; null for an empty id. */ +export function bareId(id: string | undefined): string | null { + const bare = (id ?? '').replace(/^t[13]_/, ''); + return bare === '' ? null : bare; +} + +/** Scrub stored rows for a deleted target. Returns rows changed (0 when ignored). */ +export async function scrubDeleted(store: Store, rawId: string | undefined, source: DeletionSource): Promise { + const id = bareId(rawId); + if (!id || !shouldScrub(source) || !/^[a-z0-9]+$/i.test(id)) return 0; + return store.scrubTarget(id); +} diff --git a/devvit/src/core/store.ts b/devvit/src/core/store.ts index 12ac64e..19c95e6 100644 --- a/devvit/src/core/store.ts +++ b/devvit/src/core/store.ts @@ -140,6 +140,29 @@ export class Store { return removed; } + /** + * Blank targetAuthor, permalink and reason on every stored row for one post or + * comment, and the reason on its prior-removal record. Returns rows changed. + */ + async scrubTarget(contentId: string): Promise { + const all = await this.redis.hGetAll(K.act); + const updates: Record = {}; + for (const [id, json] of Object.entries(all)) { + const row = JSON.parse(json) as StoredAction; + if (bareContentId(row.permalink) !== contentId) continue; + updates[id] = JSON.stringify({ ...row, targetAuthor: null, permalink: null, reason: '' } satisfies StoredAction); + } + for (const batch of chunks(Object.keys(updates), CHUNK)) { + await this.redis.hSet(K.act, Object.fromEntries(batch.map((id) => [id, updates[id] as string]))); + } + const prior = await this.redis.hGet(K.prior, contentId); + if (prior) { + const prev = JSON.parse(prior) as PriorRemoval; + await this.redis.hSet(K.prior, { [contentId]: JSON.stringify({ ...prev, reason: '' } satisfies PriorRemoval) }); + } + return Object.keys(updates).length; + } + async storedCount(): Promise { return this.redis.hLen(K.act); } diff --git a/devvit/src/server/index.ts b/devvit/src/server/index.ts index f0384d9..78f3ebd 100644 --- a/devvit/src/server/index.ts +++ b/devvit/src/server/index.ts @@ -5,9 +5,10 @@ import { serve } from '@hono/node-server'; import { context, createServer, getServerPort, redis, settings } from '@devvit/web/server'; import type { TaskResponse } from '@devvit/web/server'; -import type { MenuItemRequest, SettingsValidationRequest, SettingsValidationResponse, UiResponse } from '@devvit/web/shared'; +import type { MenuItemRequest, OnCommentDeleteRequest, OnPostDeleteRequest, SettingsValidationRequest, SettingsValidationResponse, UiResponse } from '@devvit/web/shared'; import { Hono } from 'hono'; +import { scrubDeleted } from '../core/deletion.js'; import { describeResult, runCycle, type CycleMode } from '../core/publish.js'; import { loadConfig, @@ -85,6 +86,31 @@ app.post('/internal/menu/test-connection', async (c) => { } }); +async function handleDeletion(kind: string, id: string | undefined, source: number | string | undefined): Promise { + try { + const scrubbed = await scrubDeleted(store, id, source); + console.log(`${kind} deleted: scrubbed ${scrubbed} stored rows`); + if (scrubbed === 0) return; + const cfg = await currentConfig(); + // A busy run is fine: the scrub changed the page content, so the next cycle rewrites it. + await runCycle(devvitReddit, store, cfg, 'force-rebuild'); + } catch (err) { + console.error(`${kind} delete handler failed: ${errorText(err)}`); + } +} + +app.post('/internal/triggers/on-post-delete', async (c) => { + const body = await c.req.json(); + await handleDeletion('post', body.postId, body.source); + return c.json({}, 200); +}); + +app.post('/internal/triggers/on-comment-delete', async (c) => { + const body = await c.req.json(); + await handleDeletion('comment', body.commentId, body.source); + return c.json({}, 200); +}); + function validationRoute(path: string, validate: Validator): void { app.post(path, async (c) => { const { value } = await c.req.json>(); diff --git a/devvit/test/deletion.test.ts b/devvit/test/deletion.test.ts new file mode 100644 index 0000000..cb57060 --- /dev/null +++ b/devvit/test/deletion.test.ts @@ -0,0 +1,66 @@ +import { redis } from '@devvit/web/server'; +import { createDevvitTest } from '@devvit/test/server/vitest'; +import { expect } from 'vitest'; + +import { bareId, scrubDeleted, shouldScrub } from '../src/core/deletion.js'; +import { runCycle } from '../src/core/publish.js'; +import { Store } from '../src/core/store.js'; +import type { StoredAction } from '../src/core/types.js'; +import { FakeReddit, config } from './helpers.js'; + +const test = createDevvitTest(); +const P = (id: string) => `https://reddit.com/r/Sub/comments/${id}/t/`; +const NOW = new Date('2026-11-03T12:00:00Z'); +const nowSec = Math.floor(NOW.getTime() / 1000); + +function row(over: Partial): StoredAction { + return { id: 'a1', action: 'removelink', moderator: 'HumanModerator', permalink: P('p1'), reason: 'secret reason', targetAuthor: 'victim', createdAt: nowSec - 60, ...over }; +} + +test('shouldScrub keeps rows only for MODERATOR deletions', () => { + for (const s of [1, 2, 'USER', 'ADMIN', 0, undefined]) expect(shouldScrub(s)).toBe(true); + for (const s of [3, 'MODERATOR']) expect(shouldScrub(s)).toBe(false); +}); + +test('bareId strips prefixes and rejects empty ids', () => { + expect(bareId('t3_abc')).toBe('abc'); + expect(bareId('t1_xyz')).toBe('xyz'); + expect(bareId('')).toBeNull(); + expect(bareId(undefined)).toBeNull(); +}); + +test('a USER deletion scrubs rows and a forced rebuild drops the identifying text from the wiki', async () => { + const store = new Store(redis); + await store.saveAction(row({})); + const reddit = new FakeReddit([]); + const cfg = config(); + await runCycle(reddit, store, cfg, 'force-rebuild', NOW); + expect(reddit.pages.get(cfg.wikiPage)).toContain('victim'); + + expect(await scrubDeleted(store, 't3_p1', 1)).toBe(1); + await runCycle(reddit, store, cfg, 'force-rebuild', NOW); + const page = reddit.pages.get(cfg.wikiPage) as string; + expect(page).not.toContain('victim'); + expect(page).not.toContain('secret reason'); + expect(page).not.toContain('/comments/p1/'); +}); + +test('an ADMIN deletion scrubs too', async () => { + const store = new Store(redis); + await store.saveAction(row({})); + expect(await scrubDeleted(store, 't3_p1', 'ADMIN')).toBe(1); +}); + +test('a MODERATOR deletion leaves rows untouched', async () => { + const store = new Store(redis); + await store.saveAction(row({})); + expect(await scrubDeleted(store, 't3_p1', 'MODERATOR')).toBe(0); + expect((await store.loadDisplayRows(['removelink'], 0))[0]).toMatchObject({ targetAuthor: 'victim', reason: 'secret reason' }); +}); + +test('an unknown target scrubs nothing', async () => { + const store = new Store(redis); + await store.saveAction(row({})); + expect(await scrubDeleted(store, 't1_other', 1)).toBe(0); + expect(await scrubDeleted(store, undefined, 1)).toBe(0); +}); diff --git a/devvit/test/settings.test.ts b/devvit/test/settings.test.ts index f5408e1..6fce17f 100644 --- a/devvit/test/settings.test.ts +++ b/devvit/test/settings.test.ts @@ -54,8 +54,8 @@ describe('loadConfig', () => { expect(cfg).toEqual({ subredditName: 'OpenSignups', wikiPage: 'modlog-devvit', - retentionDays: 90, - displayDays: 90, + retentionDays: 30, + displayDays: 30, wikiActions: [...DEFAULT_WIKI_ACTIONS], anonymizeModerators: true, ignoredModerators: [], diff --git a/devvit/test/store.test.ts b/devvit/test/store.test.ts index d410b03..5577b98 100644 --- a/devvit/test/store.test.ts +++ b/devvit/test/store.test.ts @@ -71,3 +71,27 @@ test('wiki hash and status round-trip', async () => { await store.setStatus({ lastResult: 'published' }); expect((await store.getStatus()).lastResult).toBe('published'); }); + +test('scrubTarget blanks author, link and reason for one target only', async () => { + const store = new Store(redis); + await store.saveAction(row({ id: 'r1', permalink: P('gone'), reason: 'spam', targetAuthor: 'bob', moderator: 'AutoModerator' })); + await store.saveAction(row({ id: 'r2', action: 'addremovalreason', permalink: P('gone'), reason: 'rule 1', targetAuthor: 'bob' })); + await store.saveAction(row({ id: 'k1', permalink: P('keep'), reason: 'kept', targetAuthor: 'amy' })); + + expect(await store.scrubTarget('gone')).toBe(2); + + const rows = await store.loadDisplayRows(['removelink', 'addremovalreason'], 0); + const byId = new Map(rows.map((r) => [r.id, r])); + expect(byId.get('r1')).toMatchObject({ targetAuthor: null, permalink: null, reason: '' }); + expect(byId.get('r2')).toMatchObject({ targetAuthor: null, permalink: null, reason: '' }); + expect(byId.get('k1')).toMatchObject({ targetAuthor: 'amy', permalink: P('keep'), reason: 'kept' }); + expect((await store.priorRemovalsFor([row({ permalink: P('keep') })])).size).toBe(0); +}); + +test('scrubTarget blanks the prior-removal reason but keeps the record', async () => { + const store = new Store(redis); + await store.saveAction(row({ id: 'a', moderator: 'Reddit', reason: 'filtered', permalink: P('p9'), createdAt: 100 })); + await store.scrubTarget('p9'); + const raw = await redis.hGet('prior', 'p9'); + expect(raw ? JSON.parse(raw) : null).toEqual({ moderator: 'Reddit', reason: '', createdAt: 100 }); +});