mobile bluesky app made with flutter
lazurite.stormlightlabs.org/
mobile
bluesky
flutter
1import 'package:flutter/foundation.dart';
2
3enum AtProtoIdentifierValidationErrorCode {
4 empty,
5 unsupportedDid,
6 invalidDid,
7 invalidHandle;
8
9 String get message => switch (this) {
10 AtProtoIdentifierValidationErrorCode.empty => 'Enter a Bluesky handle or DID',
11 AtProtoIdentifierValidationErrorCode.unsupportedDid => 'Use a did:plc:... or did:web:... identifier',
12 AtProtoIdentifierValidationErrorCode.invalidDid => 'Enter a complete DID like did:plc:... or did:web:...',
13 AtProtoIdentifierValidationErrorCode.invalidHandle => 'Enter a full handle like username.bsky.social',
14 };
15}
16
17class AtProtoIdentifierValidationError {
18 const AtProtoIdentifierValidationError(this.code);
19
20 final AtProtoIdentifierValidationErrorCode code;
21}
22
23final RegExp _atprotoHandlePattern = RegExp(
24 r'^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$',
25);
26final RegExp _didPlcSuffixPattern = RegExp(r'^[a-z2-7]{24}$');
27final RegExp _didWebLocalhostPortPattern = RegExp(r'^localhost%3A[0-9]+$', caseSensitive: false);
28final RegExp _didWebHostLabelPattern = RegExp(r'^[A-Za-z0-9-]+$');
29const Set<String> _disallowedDidWebTopLevelDomains = {
30 'alt',
31 'arpa',
32 'example',
33 'internal',
34 'invalid',
35 'local',
36 'localhost',
37 'onion',
38};
39
40String normalizeAtProtoIdentifierForAuth(String identifier) {
41 final trimmed = identifier.trim();
42 final lower = trimmed.toLowerCase();
43 if (lower.startsWith('did:plc:')) {
44 return 'did:plc:${trimmed.substring('did:plc:'.length).toLowerCase()}';
45 }
46 if (lower.startsWith('did:web:')) {
47 return 'did:web:${trimmed.substring('did:web:'.length).toLowerCase()}';
48 }
49 if (lower.startsWith('did:')) {
50 return 'did:${trimmed.substring('did:'.length)}';
51 }
52
53 final withoutAt = trimmed.replaceFirst(RegExp(r'^@+'), '');
54 return withoutAt.toLowerCase();
55}
56
57AtProtoIdentifierValidationError? validateAtProtoIdentifierForAuth(
58 String identifier, {
59 bool allowDevelopmentDidWebHosts = !kReleaseMode,
60}) {
61 if (identifier.isEmpty) {
62 return const AtProtoIdentifierValidationError(AtProtoIdentifierValidationErrorCode.empty);
63 }
64
65 final normalizedLower = identifier.toLowerCase();
66 if (normalizedLower.startsWith('did:')) {
67 if (normalizedLower.startsWith('did:plc:')) {
68 final suffix = identifier.substring('did:plc:'.length).trim();
69 if (!_didPlcSuffixPattern.hasMatch(suffix)) {
70 return const AtProtoIdentifierValidationError(AtProtoIdentifierValidationErrorCode.invalidDid);
71 }
72 return null;
73 }
74
75 if (normalizedLower.startsWith('did:web:')) {
76 final suffix = identifier.substring('did:web:'.length).trim();
77 if (suffix.isEmpty ||
78 suffix.contains(RegExp(r'\s')) ||
79 suffix.contains(':') ||
80 suffix.contains('/') ||
81 suffix.contains('?') ||
82 suffix.contains('#')) {
83 return const AtProtoIdentifierValidationError(AtProtoIdentifierValidationErrorCode.invalidDid);
84 }
85
86 if (!_isValidDidWebHostSuffix(suffix, allowDevelopmentDidWebHosts: allowDevelopmentDidWebHosts)) {
87 return const AtProtoIdentifierValidationError(AtProtoIdentifierValidationErrorCode.invalidDid);
88 }
89 return null;
90 }
91
92 return const AtProtoIdentifierValidationError(AtProtoIdentifierValidationErrorCode.unsupportedDid);
93 }
94
95 if (!_atprotoHandlePattern.hasMatch(identifier)) {
96 return const AtProtoIdentifierValidationError(AtProtoIdentifierValidationErrorCode.invalidHandle);
97 }
98
99 return null;
100}
101
102bool _isValidDidWebHostSuffix(String suffix, {required bool allowDevelopmentDidWebHosts}) {
103 if (allowDevelopmentDidWebHosts && _didWebLocalhostPortPattern.hasMatch(suffix)) {
104 return true;
105 }
106
107 final lower = suffix.toLowerCase();
108 if (allowDevelopmentDidWebHosts && lower == 'localhost') {
109 return true;
110 }
111
112 if (suffix.length > 253 || suffix.startsWith('.') || suffix.endsWith('.') || suffix.contains('..')) {
113 return false;
114 }
115
116 final labels = suffix.split('.');
117 if (labels.length < 2) {
118 return false;
119 }
120
121 for (final label in labels) {
122 if (label.isEmpty ||
123 label.length > 63 ||
124 !_didWebHostLabelPattern.hasMatch(label) ||
125 label.startsWith('-') ||
126 label.endsWith('-')) {
127 return false;
128 }
129 }
130
131 final tld = labels.last.toLowerCase();
132 if (_disallowedDidWebTopLevelDomains.contains(tld)) {
133 return false;
134 }
135
136 return true;
137}