-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcomplete-database-setup.sql
More file actions
1611 lines (1420 loc) · 54.9 KB
/
complete-database-setup.sql
File metadata and controls
1611 lines (1420 loc) · 54.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
-- ========================================
-- COMPLETE DATABASE SETUP FOR TENDLY
-- ========================================
-- This migration sets up the entire database from scratch for new developers
-- It includes all tables, functions, storage, rules, and policies with existence checks
-- Version: 2.0 (Complete Setup)
-- Date: 2025-01-28
-- ========================================
-- 1. EXTENSIONS
-- ========================================
-- Enable required extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ========================================
-- 1.1 FUNCTIONS REQUIRED BY TABLE DEFAULTS
-- ========================================
-- Define expiry default functions BEFORE tables that reference them
CREATE OR REPLACE FUNCTION get_drop_expiry_default() RETURNS TIMESTAMP WITH TIME ZONE AS $$
DECLARE
v_minutes INTEGER;
BEGIN
-- Will read from app_settings later if present; fallback to 5 minutes
BEGIN
SELECT int_value INTO v_minutes FROM app_settings WHERE key = 'drop_default_expiry_minutes';
EXCEPTION WHEN undefined_table THEN
v_minutes := NULL;
END;
RETURN NOW() + ((COALESCE(v_minutes, 5)) || ' minutes')::INTERVAL;
END;
$$ LANGUAGE plpgsql STABLE;
CREATE OR REPLACE FUNCTION get_encrypted_content_expiry_default() RETURNS TIMESTAMP WITH TIME ZONE AS $$
DECLARE
v_minutes INTEGER;
BEGIN
BEGIN
SELECT int_value INTO v_minutes FROM app_settings WHERE key = 'encrypted_content_default_expiry_minutes';
EXCEPTION WHEN undefined_table THEN
v_minutes := NULL;
END;
RETURN NOW() + ((COALESCE(v_minutes, 5)) || ' minutes')::INTERVAL;
END;
$$ LANGUAGE plpgsql STABLE;
CREATE OR REPLACE FUNCTION get_share_code_expiry_default() RETURNS TIMESTAMP WITH TIME ZONE AS $$
DECLARE
v_minutes INTEGER;
BEGIN
BEGIN
SELECT int_value INTO v_minutes FROM app_settings WHERE key = 'drop_default_expiry_minutes';
EXCEPTION WHEN undefined_table THEN
v_minutes := NULL;
END;
RETURN NOW() + ((COALESCE(v_minutes, 5)) || ' minutes')::INTERVAL;
END;
$$ LANGUAGE plpgsql STABLE;
-- ========================================
-- 2. CORE TABLES
-- ========================================
-- Core drops table with all necessary columns
CREATE TABLE IF NOT EXISTS drops (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
share_code TEXT NOT NULL UNIQUE,
title TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE DEFAULT get_drop_expiry_default(),
view_count INTEGER DEFAULT 0,
ip_address TEXT, -- Legacy column for backward compatibility
user_agent TEXT, -- Legacy column for backward compatibility
session_token_hash TEXT,
created_by_ip TEXT, -- Legacy column for backward compatibility
last_accessed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Security additions (privacy-first)
ip_hash TEXT, -- Hashed IP only
created_at_secure TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at_secure TIMESTAMP WITH TIME ZONE DEFAULT (NOW() + INTERVAL '5 minutes')
);
-- Ensure expiry is never null or past on insert
DROP FUNCTION IF EXISTS set_drop_expiry_defaults() CASCADE;
CREATE OR REPLACE FUNCTION set_drop_expiry_defaults() RETURNS trigger AS $$
BEGIN
IF NEW.expires_at IS NULL OR NEW.expires_at <= NOW() THEN
NEW.expires_at := get_drop_expiry_default();
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS tr_set_drop_expiry ON drops;
CREATE TRIGGER tr_set_drop_expiry
BEFORE INSERT ON drops
FOR EACH ROW
EXECUTE FUNCTION set_drop_expiry_defaults();
-- Core drop_items table with all necessary columns
CREATE TABLE IF NOT EXISTS drop_items (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
drop_id UUID NOT NULL REFERENCES drops(id) ON DELETE CASCADE,
item_type TEXT NOT NULL CHECK (item_type IN ('text', 'file')),
content_data TEXT,
file_name TEXT,
file_size INTEGER,
file_type TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Security additions
content_size INTEGER,
content_hash TEXT,
sanitized_content TEXT,
-- Zero-knowledge encryption columns
encrypted_key TEXT,
key_iv TEXT,
key_tag TEXT,
encryption_version TEXT DEFAULT '1.0'
);
-- Pro waitlist table (preserve existing users - do not truncate)
CREATE TABLE IF NOT EXISTS pro_waitlist (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email TEXT NOT NULL UNIQUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Privacy-first rate limiting table (hashed IPs only)
CREATE TABLE IF NOT EXISTS rate_limits_private (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
ip_hash TEXT NOT NULL, -- Hashed IP, never store actual IP
action_type TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
request_count INTEGER DEFAULT 1,
UNIQUE(ip_hash, action_type)
);
-- Temporary session storage (expires quickly, no user tracking)
CREATE TABLE IF NOT EXISTS temp_sessions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
session_hash TEXT NOT NULL UNIQUE, -- Hashed session data
drop_id UUID REFERENCES drops(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
-- NO IP storage, NO user agent storage, NO fingerprinting
CONSTRAINT temp_sessions_expires_check CHECK (expires_at <= created_at + INTERVAL '1 hour')
);
-- Blocked IP hashes (not actual IPs)
CREATE TABLE IF NOT EXISTS blocked_ip_hashes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
ip_hash TEXT NOT NULL UNIQUE, -- Hashed IP only
blocked_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE,
is_permanent BOOLEAN DEFAULT false
);
-- Ensure drops expiration defaults to 5 minutes
ALTER TABLE drops ALTER COLUMN expires_at SET DEFAULT get_drop_expiry_default();
-- Backfill existing rows that are NULL or already expired to 5 minutes from now
UPDATE drops
SET expires_at = get_drop_expiry_default()
WHERE expires_at IS NULL OR expires_at <= NOW();
-- Security events table with hashed IPs (privacy-preserving)
CREATE TABLE IF NOT EXISTS security_events (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
event_type TEXT NOT NULL,
ip_hash TEXT NOT NULL, -- Hashed IP address (privacy-preserving)
details JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Encrypted content table for zero-knowledge encryption
CREATE TABLE IF NOT EXISTS encrypted_content (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
content_hash TEXT NOT NULL UNIQUE,
encrypted_data TEXT NOT NULL,
iv TEXT NOT NULL,
salt TEXT NOT NULL,
tag TEXT NOT NULL,
file_name TEXT,
file_size BIGINT,
file_type TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE DEFAULT get_encrypted_content_expiry_default(),
encryption_version TEXT DEFAULT '4.0',
view_count INTEGER DEFAULT 0,
is_pro_content BOOLEAN DEFAULT false,
pro_expiry_hours INTEGER,
ip_hash TEXT,
user_agent_hash TEXT,
last_accessed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Share code mappings for encrypted content
CREATE TABLE IF NOT EXISTS share_code_mappings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
share_code TEXT NOT NULL UNIQUE,
content_hash TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE DEFAULT get_share_code_expiry_default()
);
-- ========================================
-- 2.1 APPLICATION SETTINGS (CONFIGURABLE DEFAULTS)
-- ========================================
-- Simple key->integer settings store (idempotent)
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
int_value INTEGER,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Seed default settings
INSERT INTO app_settings (key, int_value)
VALUES ('drop_default_expiry_minutes', 5)
ON CONFLICT (key) DO NOTHING;
INSERT INTO app_settings (key, int_value)
VALUES ('encrypted_content_default_expiry_minutes', 5)
ON CONFLICT (key) DO NOTHING;
-- ========================================
-- 3. INDEXES FOR PERFORMANCE
-- ========================================
-- Core table indexes
CREATE INDEX IF NOT EXISTS idx_drops_share_code ON drops(share_code);
CREATE INDEX IF NOT EXISTS idx_drops_expires_at ON drops(expires_at);
CREATE INDEX IF NOT EXISTS idx_drops_created_at ON drops(created_at);
CREATE INDEX IF NOT EXISTS idx_drop_items_drop_id ON drop_items(drop_id);
CREATE INDEX IF NOT EXISTS idx_drop_items_created_at ON drop_items(created_at);
CREATE INDEX IF NOT EXISTS idx_pro_waitlist_email ON pro_waitlist(email);
-- Security table indexes
CREATE INDEX IF NOT EXISTS idx_rate_limits_private_hash ON rate_limits_private(ip_hash, action_type);
CREATE INDEX IF NOT EXISTS idx_rate_limits_private_expires ON rate_limits_private(expires_at);
CREATE INDEX IF NOT EXISTS idx_temp_sessions_hash ON temp_sessions(session_hash);
CREATE INDEX IF NOT EXISTS idx_temp_sessions_expires ON temp_sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_blocked_ip_hashes_hash ON blocked_ip_hashes(ip_hash);
CREATE INDEX IF NOT EXISTS idx_blocked_ip_hashes_expires ON blocked_ip_hashes(expires_at) WHERE expires_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_security_events_event_type ON security_events(event_type);
CREATE INDEX IF NOT EXISTS idx_security_events_created_at ON security_events(created_at);
CREATE INDEX IF NOT EXISTS idx_security_events_ip_hash ON security_events(ip_hash);
-- Encrypted content indexes
CREATE INDEX IF NOT EXISTS idx_encrypted_content_hash ON encrypted_content(content_hash);
CREATE INDEX IF NOT EXISTS idx_encrypted_content_expires_at ON encrypted_content(expires_at);
CREATE INDEX IF NOT EXISTS idx_share_code_mappings_share_code ON share_code_mappings(share_code);
CREATE INDEX IF NOT EXISTS idx_share_code_mappings_content_hash ON share_code_mappings(content_hash);
CREATE INDEX IF NOT EXISTS idx_share_code_mappings_expires_at ON share_code_mappings(expires_at);
-- ========================================
-- 4. UTILITY FUNCTIONS
-- ========================================
-- Drop existing utility functions first to handle signature changes
DROP FUNCTION IF EXISTS hash_ip(INET) CASCADE;
DROP FUNCTION IF EXISTS is_ip_hash_blocked(INET) CASCADE;
DROP FUNCTION IF EXISTS check_rate_limit_private(INET, TEXT, INTEGER, INTEGER) CASCADE;
-- Function to hash IP addresses (privacy-preserving)
CREATE OR REPLACE FUNCTION hash_ip(p_ip_address INET) RETURNS TEXT AS $$
BEGIN
-- Hash IP with salt for additional privacy
RETURN encode(digest(p_ip_address::TEXT || 'tendly_salt_2025', 'sha256'), 'hex');
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Config-driven expiry defaults
DROP FUNCTION IF EXISTS get_drop_expiry_default() CASCADE;
CREATE OR REPLACE FUNCTION get_drop_expiry_default() RETURNS TIMESTAMP WITH TIME ZONE AS $$
DECLARE
v_minutes INTEGER;
BEGIN
SELECT int_value INTO v_minutes FROM app_settings WHERE key = 'drop_default_expiry_minutes';
RETURN NOW() + ((COALESCE(v_minutes, 5)) || ' minutes')::INTERVAL;
END;
$$ LANGUAGE plpgsql STABLE;
DROP FUNCTION IF EXISTS get_encrypted_content_expiry_default() CASCADE;
CREATE OR REPLACE FUNCTION get_encrypted_content_expiry_default() RETURNS TIMESTAMP WITH TIME ZONE AS $$
DECLARE
v_minutes INTEGER;
BEGIN
SELECT int_value INTO v_minutes FROM app_settings WHERE key = 'encrypted_content_default_expiry_minutes';
RETURN NOW() + ((COALESCE(v_minutes, 5)) || ' minutes')::INTERVAL;
END;
$$ LANGUAGE plpgsql STABLE;
DROP FUNCTION IF EXISTS get_share_code_expiry_default() CASCADE;
CREATE OR REPLACE FUNCTION get_share_code_expiry_default() RETURNS TIMESTAMP WITH TIME ZONE AS $$
DECLARE
v_minutes INTEGER;
BEGIN
SELECT int_value INTO v_minutes FROM app_settings WHERE key = 'drop_default_expiry_minutes';
RETURN NOW() + ((COALESCE(v_minutes, 5)) || ' minutes')::INTERVAL;
END;
$$ LANGUAGE plpgsql STABLE;
-- Function to check if IP hash is blocked
CREATE OR REPLACE FUNCTION is_ip_hash_blocked(p_ip_address INET) RETURNS BOOLEAN AS $$
DECLARE
v_ip_hash TEXT;
v_is_blocked BOOLEAN;
BEGIN
v_ip_hash := hash_ip(p_ip_address);
SELECT EXISTS(
SELECT 1 FROM blocked_ip_hashes
WHERE ip_hash = v_ip_hash
AND (expires_at IS NULL OR expires_at > NOW())
) INTO v_is_blocked;
RETURN v_is_blocked;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Privacy-preserving rate limiting (no user tracking)
CREATE OR REPLACE FUNCTION check_rate_limit_private(
p_ip_address INET,
p_action_type TEXT,
p_max_requests INTEGER DEFAULT 10,
p_window_minutes INTEGER DEFAULT 15
) RETURNS BOOLEAN AS $$
DECLARE
v_ip_hash TEXT;
v_count INTEGER;
v_expires_at TIMESTAMP WITH TIME ZONE;
BEGIN
-- Hash IP immediately - we never store the actual IP
v_ip_hash := hash_ip(p_ip_address);
-- Check if IP hash is blocked
IF is_ip_hash_blocked(p_ip_address) THEN
RETURN false;
END IF;
-- Clean up expired rate limit records
DELETE FROM rate_limits_private
WHERE expires_at < NOW();
-- Set expiration time
v_expires_at := NOW() + (p_window_minutes || ' minutes')::INTERVAL;
-- Try to insert or update rate limit record
INSERT INTO rate_limits_private (ip_hash, action_type, expires_at, request_count)
VALUES (v_ip_hash, p_action_type, v_expires_at, 1)
ON CONFLICT (ip_hash, action_type)
DO UPDATE SET
request_count = rate_limits_private.request_count + 1,
expires_at = v_expires_at
WHERE rate_limits_private.expires_at > NOW();
-- Get current count
SELECT request_count INTO v_count
FROM rate_limits_private
WHERE ip_hash = v_ip_hash
AND action_type = p_action_type
AND expires_at > NOW();
-- Return true if under limit
RETURN COALESCE(v_count, 0) <= p_max_requests;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Privacy-preserving content validation (no user tracking)
-- Drop existing function first to handle parameter name changes
DROP FUNCTION IF EXISTS validate_content_private(TEXT, TEXT, INTEGER, TEXT) CASCADE;
CREATE OR REPLACE FUNCTION validate_content_private(
p_content TEXT,
p_content_type TEXT DEFAULT 'text',
p_max_size_kb INTEGER DEFAULT 10,
p_file_name TEXT DEFAULT NULL
) RETURNS JSON AS $$
DECLARE
v_content_size INTEGER;
v_sanitized_content TEXT;
v_suspicious_patterns TEXT[] DEFAULT ARRAY[]::TEXT[];
BEGIN
-- Check content size
v_content_size := LENGTH(p_content);
IF v_content_size > (p_max_size_kb * 1024) THEN
RETURN json_build_object(
'valid', false,
'error', 'Content too large',
'size', v_content_size,
'max_size', p_max_size_kb * 1024
);
END IF;
-- Initialize sanitized content
v_sanitized_content := p_content;
-- Basic security checks (no user tracking)
IF p_content_type = 'text' THEN
-- Check for script tags
IF p_content ~* '<script[^>]*>.*?</script>' THEN
v_suspicious_patterns := array_append(v_suspicious_patterns, 'script_tag');
END IF;
-- Check for javascript: protocol
IF p_content ~* 'javascript:' THEN
v_suspicious_patterns := array_append(v_suspicious_patterns, 'javascript_protocol');
END IF;
-- Check for event handlers
IF p_content ~* 'on\w+\s*=' THEN
v_suspicious_patterns := array_append(v_suspicious_patterns, 'event_handler');
END IF;
-- Check for iframe/object/embed tags
IF p_content ~* '<(iframe|object|embed)' THEN
v_suspicious_patterns := array_append(v_suspicious_patterns, 'embedding_tag');
END IF;
-- Check for data: protocol
IF p_content ~* 'data:text/html' THEN
v_suspicious_patterns := array_append(v_suspicious_patterns, 'data_protocol');
END IF;
-- Check for excessive URLs (spam detection)
IF (LENGTH(p_content) - LENGTH(REPLACE(p_content, 'http', ''))) / 4 > 5 THEN
v_suspicious_patterns := array_append(v_suspicious_patterns, 'excessive_urls');
END IF;
-- Apply sanitization
v_sanitized_content := REGEXP_REPLACE(v_sanitized_content, '<script[^>]*>.*?</script>', '', 'gi');
v_sanitized_content := REGEXP_REPLACE(v_sanitized_content, 'javascript:', '', 'gi');
v_sanitized_content := REGEXP_REPLACE(v_sanitized_content, 'on\w+\s*=', '', 'gi');
v_sanitized_content := REGEXP_REPLACE(v_sanitized_content, '<(iframe|object|embed)', '', 'gi');
v_sanitized_content := REGEXP_REPLACE(v_sanitized_content, 'data:text/html', '', 'gi');
v_sanitized_content := TRIM(v_sanitized_content);
END IF;
-- File type validation for file uploads
IF p_content_type = 'file' AND p_file_name IS NOT NULL THEN
-- Check for dangerous file extensions
IF p_file_name ~* '\.(exe|bat|cmd|com|pif|scr|vbs|js|jar|war|ear|apk|dmg|deb|rpm|msi|app)$' THEN
v_suspicious_patterns := array_append(v_suspicious_patterns, 'dangerous_file_type');
END IF;
-- Check for double extensions
IF p_file_name ~* '\.(txt|jpg|png|gif|pdf)\.(exe|bat|cmd|com|pif|scr|vbs|js)$' THEN
v_suspicious_patterns := array_append(v_suspicious_patterns, 'double_extension');
END IF;
END IF;
-- Determine if content is valid
IF array_length(v_suspicious_patterns, 1) > 0 THEN
RETURN json_build_object(
'valid', false,
'error', 'Content contains suspicious patterns',
'suspicious_patterns', v_suspicious_patterns,
'sanitized_content', v_sanitized_content
);
ELSE
RETURN json_build_object(
'valid', true,
'sanitized_content', v_sanitized_content,
'size', v_content_size
);
END IF;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- ========================================
-- 5. SESSION MANAGEMENT FUNCTIONS
-- ========================================
-- Drop existing session functions first to handle signature changes
DROP FUNCTION IF EXISTS generate_temp_session(UUID) CASCADE;
DROP FUNCTION IF EXISTS validate_temp_session(TEXT, UUID) CASCADE;
-- Privacy-preserving session token generation (temporary, no tracking)
CREATE OR REPLACE FUNCTION generate_temp_session(
p_drop_id UUID
) RETURNS TEXT AS $$
DECLARE
v_token TEXT;
v_session_hash TEXT;
v_expires_at TIMESTAMP WITH TIME ZONE;
BEGIN
-- Generate secure token
v_token := encode(gen_random_bytes(32), 'hex');
v_session_hash := encode(digest(v_token, 'sha256'), 'hex');
-- Set short expiration (1 hour max for privacy)
v_expires_at := NOW() + INTERVAL '1 hour';
-- Store hashed session (no IP, no user agent, no tracking)
INSERT INTO temp_sessions (session_hash, drop_id, expires_at)
VALUES (v_session_hash, p_drop_id, v_expires_at);
-- Update drop with hashed session (no IP tracking)
UPDATE drops
SET session_token_hash = v_session_hash
WHERE id = p_drop_id;
RETURN v_token;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Privacy-preserving session validation (no tracking)
CREATE OR REPLACE FUNCTION validate_temp_session(
p_token TEXT,
p_drop_id UUID
) RETURNS BOOLEAN AS $$
DECLARE
v_session_hash TEXT;
v_is_valid BOOLEAN;
BEGIN
-- Hash the provided token
v_session_hash := encode(digest(p_token, 'sha256'), 'hex');
-- Check if session is valid and active
SELECT EXISTS(
SELECT 1 FROM temp_sessions
WHERE session_hash = v_session_hash
AND drop_id = p_drop_id
AND expires_at > NOW()
) INTO v_is_valid;
RETURN v_is_valid;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- ========================================
-- 6. SECURITY EVENT LOGGING
-- ========================================
-- Drop existing security event function first to handle signature changes
DROP FUNCTION IF EXISTS log_security_event(TEXT, TEXT, JSONB) CASCADE;
-- Function to log security events with hashed IPs
CREATE OR REPLACE FUNCTION log_security_event(
p_event_type TEXT,
p_ip_address TEXT,
p_details JSONB DEFAULT NULL
)
RETURNS void AS $$
DECLARE
v_ip_hash TEXT;
BEGIN
-- Hash the IP address for privacy
v_ip_hash := hash_ip(p_ip_address::INET);
-- Insert security event with hashed IP
INSERT INTO security_events (
event_type,
ip_hash,
details
) VALUES (
p_event_type,
v_ip_hash,
p_details
);
-- Optional: Clean up old events (keep last 30 days)
DELETE FROM security_events
WHERE created_at < NOW() - INTERVAL '30 days';
EXCEPTION
WHEN OTHERS THEN
-- Silently ignore errors to avoid blocking main functionality
NULL;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- ========================================
-- 7. CORE DROP MANAGEMENT FUNCTIONS
-- ========================================
-- Drop existing functions first to handle any signature changes
DROP FUNCTION IF EXISTS secure_create_drop(TEXT, INET) CASCADE;
DROP FUNCTION IF EXISTS secure_add_drop_item(UUID, TEXT, TEXT, TEXT, INET, TEXT, INTEGER, TEXT) CASCADE;
DROP FUNCTION IF EXISTS secure_update_drop_item(UUID, TEXT, TEXT, INET) CASCADE;
DROP FUNCTION IF EXISTS update_drop_item_with_code(UUID, TEXT, TEXT) CASCADE;
-- Privacy-first create drop function (no user tracking)
CREATE OR REPLACE FUNCTION secure_create_drop(
p_share_code TEXT,
p_ip_address INET
) RETURNS JSON AS $$
DECLARE
v_drop_id UUID;
v_session_token TEXT;
v_ip_hash TEXT;
BEGIN
-- Hash IP for privacy
v_ip_hash := hash_ip(p_ip_address);
-- Check if IP hash is blocked
IF is_ip_hash_blocked(p_ip_address) THEN
RETURN json_build_object(
'success', false,
'error', 'Access denied'
);
END IF;
-- Check rate limiting
IF NOT check_rate_limit_private(p_ip_address, 'create_drop', 3, 10) THEN
RETURN json_build_object(
'success', false,
'error', 'Rate limit exceeded. Please wait before creating more drops.'
);
END IF;
-- Validate share code
IF LENGTH(p_share_code) < 6 OR LENGTH(p_share_code) > 20 THEN
RETURN json_build_object(
'success', false,
'error', 'Invalid share code length'
);
END IF;
IF NOT p_share_code ~ '^[A-Za-z0-9]+$' THEN
RETURN json_build_object(
'success', false,
'error', 'Share code contains invalid characters'
);
END IF;
-- Create the drop (no user tracking)
INSERT INTO drops (share_code, ip_hash, created_at_secure, expires_at_secure)
VALUES (p_share_code, v_ip_hash, NOW(), NOW() + INTERVAL '5 minutes')
RETURNING id INTO v_drop_id;
-- Generate temporary session token
v_session_token := generate_temp_session(v_drop_id);
RETURN json_build_object(
'success', true,
'drop_id', v_drop_id,
'session_token', v_session_token
);
EXCEPTION
WHEN OTHERS THEN
RETURN json_build_object(
'success', false,
'error', 'Failed to create drop'
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Privacy-first add item function (no user tracking)
CREATE OR REPLACE FUNCTION secure_add_drop_item(
p_drop_id UUID,
p_item_type TEXT,
p_content_data TEXT,
p_session_token TEXT,
p_ip_address INET,
p_file_name TEXT DEFAULT NULL,
p_file_size INTEGER DEFAULT NULL,
p_file_type TEXT DEFAULT NULL
) RETURNS JSON AS $$
DECLARE
v_validation_result JSON;
v_content_size INTEGER;
v_sanitized_content TEXT;
v_content_hash TEXT;
v_item_id UUID;
BEGIN
-- Check if IP hash is blocked
IF is_ip_hash_blocked(p_ip_address) THEN
RETURN json_build_object(
'success', false,
'error', 'Access denied'
);
END IF;
-- Check rate limiting
IF NOT check_rate_limit_private(p_ip_address, 'add_item', 10, 5) THEN
RETURN json_build_object(
'success', false,
'error', 'Rate limit exceeded. Please wait before adding more items.'
);
END IF;
-- Validate session token
IF NOT validate_temp_session(p_session_token, p_drop_id) THEN
RETURN json_build_object(
'success', false,
'error', 'Invalid or expired session token'
);
END IF;
-- Validate content
v_validation_result := validate_content_private(p_content_data, p_item_type, 10, p_file_name);
IF (v_validation_result->>'valid')::BOOLEAN = false THEN
RETURN json_build_object(
'success', false,
'error', v_validation_result->>'error'
);
END IF;
-- Extract validated data
v_sanitized_content := v_validation_result->>'sanitized_content';
v_content_size := (v_validation_result->>'size')::INTEGER;
v_content_hash := encode(digest(v_sanitized_content, 'sha256'), 'hex');
-- Insert the item
INSERT INTO drop_items (
drop_id,
item_type,
content_data,
file_name,
file_size,
file_type,
content_size,
content_hash,
sanitized_content
)
VALUES (
p_drop_id,
p_item_type,
v_sanitized_content,
p_file_name,
p_file_size,
p_file_type,
v_content_size,
v_content_hash,
v_sanitized_content
)
RETURNING id INTO v_item_id;
RETURN json_build_object(
'success', true,
'item_id', v_item_id,
'message', 'Item added successfully'
);
EXCEPTION
WHEN OTHERS THEN
RETURN json_build_object(
'success', false,
'error', 'Failed to add item'
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Privacy-first update function (no user tracking)
CREATE OR REPLACE FUNCTION secure_update_drop_item(
p_drop_item_id UUID,
p_new_content_data TEXT,
p_session_token TEXT,
p_ip_address INET
) RETURNS JSON AS $$
DECLARE
v_drop_id UUID;
v_validation_result JSON;
v_content_size INTEGER;
v_sanitized_content TEXT;
v_content_hash TEXT;
BEGIN
-- Check if IP hash is blocked
IF is_ip_hash_blocked(p_ip_address) THEN
RETURN json_build_object(
'success', false,
'error', 'Access denied'
);
END IF;
-- Check rate limiting
IF NOT check_rate_limit_private(p_ip_address, 'update_content', 5, 5) THEN
RETURN json_build_object(
'success', false,
'error', 'Rate limit exceeded. Please wait before making more changes.'
);
END IF;
-- Get drop_id for the item
SELECT drop_id INTO v_drop_id
FROM drop_items
WHERE id = p_drop_item_id;
IF v_drop_id IS NULL THEN
RETURN json_build_object(
'success', false,
'error', 'Drop item not found'
);
END IF;
-- Validate session token
IF NOT validate_temp_session(p_session_token, v_drop_id) THEN
RETURN json_build_object(
'success', false,
'error', 'Invalid or expired session token'
);
END IF;
-- Validate content
v_validation_result := validate_content_private(p_new_content_data, 'text', 10);
IF (v_validation_result->>'valid')::BOOLEAN = false THEN
RETURN json_build_object(
'success', false,
'error', v_validation_result->>'error'
);
END IF;
-- Extract validated data
v_sanitized_content := v_validation_result->>'sanitized_content';
v_content_size := (v_validation_result->>'size')::INTEGER;
v_content_hash := encode(digest(v_sanitized_content, 'sha256'), 'hex');
-- Update the drop item
UPDATE drop_items
SET content_data = v_sanitized_content,
content_size = v_content_size,
content_hash = v_content_hash,
sanitized_content = v_sanitized_content
WHERE id = p_drop_item_id;
RETURN json_build_object(
'success', true,
'message', 'Content updated successfully'
);
EXCEPTION
WHEN OTHERS THEN
RETURN json_build_object(
'success', false,
'error', 'Database error occurred'
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Update drop item using share code (for public editing)
CREATE OR REPLACE FUNCTION update_drop_item_with_code(
p_drop_item_id UUID,
p_new_content_data TEXT,
p_share_code TEXT
) RETURNS JSON AS $$
DECLARE
v_drop_id UUID;
v_validation_result JSON;
v_content_size INTEGER;
v_sanitized_content TEXT;
v_content_hash TEXT;
BEGIN
-- Get drop_id and verify share code
SELECT di.drop_id INTO v_drop_id
FROM drop_items di
JOIN drops d ON d.id = di.drop_id
WHERE di.id = p_drop_item_id
AND d.share_code = p_share_code
AND d.expires_at > NOW();
IF v_drop_id IS NULL THEN
RETURN json_build_object(
'success', false,
'error', 'Drop item not found or share code invalid'
);
END IF;
-- Validate content
v_validation_result := validate_content_private(p_new_content_data, 'text', 10);
IF (v_validation_result->>'valid')::BOOLEAN = false THEN
RETURN json_build_object(
'success', false,
'error', v_validation_result->>'error'
);
END IF;
-- Extract validated data
v_sanitized_content := v_validation_result->>'sanitized_content';
v_content_size := (v_validation_result->>'size')::INTEGER;
v_content_hash := encode(digest(v_sanitized_content, 'sha256'), 'hex');
-- Update the drop item
UPDATE drop_items
SET content_data = v_sanitized_content,
content_size = v_content_size,
content_hash = v_content_hash,
sanitized_content = v_sanitized_content
WHERE id = p_drop_item_id;
RETURN json_build_object(
'success', true,
'message', 'Content updated successfully'
);
EXCEPTION
WHEN OTHERS THEN
RETURN json_build_object(
'success', false,
'error', 'Database error occurred'
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- ========================================
-- 8. ENCRYPTED CONTENT FUNCTIONS
-- ========================================
-- Drop existing encrypted content functions first to handle signature changes
DROP FUNCTION IF EXISTS store_encrypted_content(TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, BIGINT, TEXT, BOOLEAN, INTEGER) CASCADE;
DROP FUNCTION IF EXISTS store_encrypted_text_content(TEXT, TEXT, TEXT, TEXT, TEXT) CASCADE;
DROP FUNCTION IF EXISTS store_encrypted_file_content(TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, BIGINT, TEXT) CASCADE;
DROP FUNCTION IF EXISTS get_encrypted_content_by_hash(TEXT) CASCADE;
DROP FUNCTION IF EXISTS get_encrypted_content_by_share_code(TEXT) CASCADE;
-- Function to store encrypted content securely
CREATE OR REPLACE FUNCTION store_encrypted_content(
p_content_hash TEXT,
p_encrypted_data TEXT,
p_iv TEXT,
p_salt TEXT,
p_tag TEXT,
p_file_name TEXT DEFAULT NULL,
p_file_size BIGINT DEFAULT NULL,
p_file_type TEXT DEFAULT NULL,
p_is_pro_content BOOLEAN DEFAULT false,
p_pro_expiry_hours INTEGER DEFAULT NULL
)
RETURNS UUID AS $$
DECLARE
v_id UUID;
BEGIN
-- Validate content hash format
IF p_content_hash IS NULL OR LENGTH(p_content_hash) != 64 OR p_content_hash !~ '^[a-f0-9]+$' THEN
RAISE EXCEPTION 'Invalid content hash format';
END IF;
-- Validate pro plan parameters
IF p_is_pro_content = true AND (p_pro_expiry_hours IS NULL OR p_pro_expiry_hours < 1 OR p_pro_expiry_hours > 168) THEN
RAISE EXCEPTION 'Pro plan expiry must be between 1 and 168 hours';
END IF;
-- Insert encrypted content
INSERT INTO encrypted_content (
content_hash,
encrypted_data,
iv,
salt,
tag,
file_name,
file_size,
file_type,
created_at,
expires_at,
encryption_version,
is_pro_content,
pro_expiry_hours,
ip_hash,
user_agent_hash
) VALUES (
p_content_hash,
p_encrypted_data,
p_iv,
p_salt,
p_tag,
p_file_name,
p_file_size,
p_file_type,
NOW(),
CASE
WHEN p_is_pro_content = true THEN NOW() + (p_pro_expiry_hours || ' hours')::INTERVAL
ELSE NOW() + INTERVAL '5 minutes'
END,
'4.0',
p_is_pro_content,
p_pro_expiry_hours,
encode(sha256(inet_client_addr()::text::bytea), 'hex'),
encode(sha256(current_setting('request.headers')::text::bytea), 'hex')
)
RETURNING id INTO v_id;
RETURN v_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Function to store encrypted text content (simplified version)
CREATE OR REPLACE FUNCTION store_encrypted_text_content(
p_content_hash TEXT,
p_encrypted_data TEXT,
p_iv TEXT,
p_salt TEXT,
p_tag TEXT
)
RETURNS UUID AS $$
BEGIN
RETURN store_encrypted_content(
p_content_hash,
p_encrypted_data,
p_iv,
p_salt,
p_tag,