Add endpoint for deleting raw packets of decrypted messages

This commit is contained in:
Jack Kingsman
2026-02-21 00:10:29 -08:00
parent 6d0505ade6
commit 11f07f3501
6 changed files with 135 additions and 13 deletions
+7
View File
@@ -801,6 +801,13 @@ class RawPacketRepository:
await db.conn.commit()
return cursor.rowcount
@staticmethod
async def purge_linked_to_messages() -> int:
"""Delete raw packets that are already linked to a stored message."""
cursor = await db.conn.execute("DELETE FROM raw_packets WHERE message_id IS NOT NULL")
await db.conn.commit()
return cursor.rowcount
@staticmethod
async def get_undecrypted_text_messages() -> list[tuple[int, bytes, int]]:
"""Get all undecrypted TEXT_MESSAGE packets as (id, data, timestamp) tuples.
+26 -10
View File
@@ -236,8 +236,12 @@ async def decrypt_historical_packets(
class MaintenanceRequest(BaseModel):
prune_undecrypted_days: int = Field(
ge=1, description="Delete undecrypted packets older than this many days"
prune_undecrypted_days: int | None = Field(
default=None, ge=1, description="Delete undecrypted packets older than this many days"
)
purge_linked_raw_packets: bool = Field(
default=False,
description="Delete raw packets already linked to a stored message",
)
@@ -249,18 +253,30 @@ class MaintenanceResult(BaseModel):
@router.post("/maintenance", response_model=MaintenanceResult)
async def run_maintenance(request: MaintenanceRequest) -> MaintenanceResult:
"""
Clean up old undecrypted packets and reclaim disk space.
Run packet maintenance tasks and reclaim disk space.
- Deletes undecrypted packets older than the specified number of days
- Optionally deletes undecrypted packets older than the specified number of days
- Optionally deletes raw packets already linked to stored messages
- Runs VACUUM to reclaim disk space
"""
logger.info(
"Running maintenance: pruning packets older than %d days", request.prune_undecrypted_days
)
deleted = 0
# Prune old undecrypted packets
deleted = await RawPacketRepository.prune_old_undecrypted(request.prune_undecrypted_days)
logger.info("Deleted %d old undecrypted packets", deleted)
if request.prune_undecrypted_days is not None:
logger.info(
"Running maintenance: pruning undecrypted packets older than %d days",
request.prune_undecrypted_days,
)
pruned_undecrypted = await RawPacketRepository.prune_old_undecrypted(
request.prune_undecrypted_days
)
deleted += pruned_undecrypted
logger.info("Deleted %d old undecrypted packets", pruned_undecrypted)
if request.purge_linked_raw_packets:
logger.info("Running maintenance: purging raw packets linked to stored messages")
purged_linked = await RawPacketRepository.purge_linked_to_messages()
deleted += purged_linked
logger.info("Deleted %d linked raw packets", purged_linked)
# Run VACUUM to reclaim space on a dedicated connection
async with aiosqlite.connect(db.db_path) as vacuum_conn:
+9 -2
View File
@@ -183,10 +183,17 @@ export const api = {
method: 'POST',
body: JSON.stringify(params),
}),
runMaintenance: (pruneUndecryptedDays: number) =>
runMaintenance: (options: { pruneUndecryptedDays?: number; purgeLinkedRawPackets?: boolean }) =>
fetchJson<MaintenanceResult>('/packets/maintenance', {
method: 'POST',
body: JSON.stringify({ prune_undecrypted_days: pruneUndecryptedDays }),
body: JSON.stringify({
...(options.pruneUndecryptedDays !== undefined && {
prune_undecrypted_days: options.pruneUndecryptedDays,
}),
...(options.purgeLinkedRawPackets !== undefined && {
purge_linked_raw_packets: options.purgeLinkedRawPackets,
}),
}),
}),
// Read State
+42 -1
View File
@@ -145,6 +145,7 @@ export function SettingsModal(props: SettingsModalProps) {
// Database maintenance state
const [retentionDays, setRetentionDays] = useState('14');
const [cleaning, setCleaning] = useState(false);
const [purgingDecryptedRaw, setPurgingDecryptedRaw] = useState(false);
const [autoDecryptOnAdvert, setAutoDecryptOnAdvert] = useState(false);
const [reopenLastConversation, setReopenLastConversation] = useState(
getReopenLastConversationEnabled
@@ -509,7 +510,7 @@ export function SettingsModal(props: SettingsModalProps) {
setCleaning(true);
try {
const result = await api.runMaintenance(days);
const result = await api.runMaintenance({ pruneUndecryptedDays: days });
toast.success('Database cleanup complete', {
description: `Deleted ${result.packets_deleted} old packet${result.packets_deleted === 1 ? '' : 's'}`,
});
@@ -524,6 +525,25 @@ export function SettingsModal(props: SettingsModalProps) {
}
};
const handlePurgeDecryptedRawPackets = async () => {
setPurgingDecryptedRaw(true);
try {
const result = await api.runMaintenance({ purgeLinkedRawPackets: true });
toast.success('Decrypted raw packets purged', {
description: `Deleted ${result.packets_deleted} raw packet${result.packets_deleted === 1 ? '' : 's'}`,
});
await onHealthRefresh();
} catch (err) {
console.error('Failed to purge decrypted raw packets:', err);
toast.error('Failed to purge decrypted raw packets', {
description: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setPurgingDecryptedRaw(false);
}
};
const handleSaveDatabaseSettings = async () => {
setBusySection('database');
setSectionError(null);
@@ -1046,6 +1066,27 @@ export function SettingsModal(props: SettingsModalProps) {
<Separator />
<div className="space-y-3">
<Label>Purge Decrypted Raw Packets</Label>
<p className="text-xs text-muted-foreground">
Deletes raw packet bytes for messages already stored in chat history. No displayed
message data is lost. Only use this if you do not plan to run manual analytics or
parsing on original packet bytes.
</p>
<Button
variant="outline"
onClick={handlePurgeDecryptedRawPackets}
disabled={purgingDecryptedRaw}
className="w-full border-red-500/50 text-red-400 hover:bg-red-500/10"
>
{purgingDecryptedRaw
? 'Purging Decrypted Raw Packets...'
: 'Purge Decrypted Raw Packets'}
</Button>
</div>
<Separator />
<div className="space-y-3">
<Label>DM Decryption</Label>
<label className="flex items-center gap-3 cursor-pointer">
+17
View File
@@ -15,6 +15,7 @@ import {
LAST_VIEWED_CONVERSATION_KEY,
REOPEN_LAST_CONVERSATION_KEY,
} from '../utils/lastViewedConversation';
import { api } from '../api';
const baseConfig: RadioConfig = {
public_key: 'aa'.repeat(32),
@@ -321,6 +322,22 @@ describe('SettingsModal', () => {
expect(localStorage.getItem(LAST_VIEWED_CONVERSATION_KEY)).toBeNull();
});
it('purges decrypted raw packets via maintenance endpoint action', async () => {
const runMaintenanceSpy = vi.spyOn(api, 'runMaintenance').mockResolvedValue({
packets_deleted: 12,
vacuumed: true,
});
renderModal();
openDatabaseSection();
fireEvent.click(screen.getByRole('button', { name: 'Purge Decrypted Raw Packets' }));
await waitFor(() => {
expect(runMaintenanceSpy).toHaveBeenCalledWith({ purgeLinkedRawPackets: true });
});
});
it('renders statistics section with fetched data', async () => {
const mockStats: StatisticsResponse = {
busiest_channels_24h: [
+34
View File
@@ -887,6 +887,23 @@ class TestRawPacketRepository:
deleted = await RawPacketRepository.prune_old_undecrypted(10)
assert deleted == 0
@pytest.mark.asyncio
async def test_purge_linked_to_messages_deletes_only_linked_packets(self, test_db):
"""Purge linked raw packets removes only rows with a message_id."""
ts = int(time.time())
linked_1, _ = await RawPacketRepository.create(b"\x01\x02\x03", ts)
linked_2, _ = await RawPacketRepository.create(b"\x04\x05\x06", ts)
await RawPacketRepository.mark_decrypted(linked_1, 101)
await RawPacketRepository.mark_decrypted(linked_2, 102)
await RawPacketRepository.create(b"\x07\x08\x09", ts) # undecrypted, should remain
deleted = await RawPacketRepository.purge_linked_to_messages()
assert deleted == 2
remaining = await RawPacketRepository.get_undecrypted_count()
assert remaining == 1
class TestMaintenanceEndpoint:
"""Test database maintenance endpoint."""
@@ -909,6 +926,23 @@ class TestMaintenanceEndpoint:
assert result.packets_deleted == 2
assert result.vacuumed is True
@pytest.mark.asyncio
async def test_maintenance_can_purge_linked_raw_packets(self, test_db):
"""Maintenance endpoint can purge raw packets linked to messages."""
from app.routers.packets import MaintenanceRequest, run_maintenance
ts = int(time.time())
linked_1, _ = await RawPacketRepository.create(b"\x0A\x0B\x0C", ts)
linked_2, _ = await RawPacketRepository.create(b"\x0D\x0E\x0F", ts)
await RawPacketRepository.mark_decrypted(linked_1, 201)
await RawPacketRepository.mark_decrypted(linked_2, 202)
request = MaintenanceRequest(purge_linked_raw_packets=True)
result = await run_maintenance(request)
assert result.packets_deleted == 2
assert result.vacuumed is True
class TestHealthEndpointDatabaseSize:
"""Test database size reporting in health endpoint."""