social bookmarking for atproto
1/*
2 * clippr: a social bookmarking service for the AT Protocol
3 * Copyright (c) 2025 clippr contributors.
4 * SPDX-License-Identifier: AGPL-3.0-only
5 */
6
7import {
8 SocialClipprActorProfile,
9 SocialClipprFeedClip,
10 SocialClipprFeedTag,
11} from "@clipprjs/lexicons";
12import {
13 isDatetime,
14 isGenericUri,
15 isLanguageCode,
16} from "@atcute/lexicons/syntax";
17import Logger from "../logger.js";
18import { ComAtprotoRepoStrongRef } from "@atcute/atproto";
19import { is } from "@atcute/lexicons";
20
21export async function validateProfile(
22 record: SocialClipprActorProfile.Main,
23): Promise<boolean> {
24 if (!isDatetime(record.createdAt)) {
25 Logger.verbose(
26 "Invalid createdAt timestamp for incoming profile record",
27 record,
28 );
29 return false;
30 }
31
32 if (record.displayName) {
33 if (record.displayName.length > 64) {
34 Logger.verbose(
35 "Too long displayName from incoming profile record",
36 record,
37 );
38 return false;
39 }
40
41 if (record.displayName.length < 1) {
42 Logger.verbose(
43 "Too short displayName from incoming profile record",
44 record,
45 );
46 return false;
47 }
48 } else {
49 Logger.verbose("No displayName from incoming profile record", record);
50 return false;
51 }
52
53 if (record.description) {
54 if (record.description.length > 500) {
55 Logger.verbose(
56 "Too long description from incoming profile record",
57 record,
58 );
59 return false;
60 }
61
62 if (record.description.length < 1) {
63 Logger.verbose(
64 "Too short description from incoming profile record",
65 record,
66 );
67 return false;
68 }
69 }
70
71 return true;
72}
73
74export async function validateTag(
75 record: SocialClipprFeedTag.Main,
76): Promise<boolean> {
77 if (!isDatetime(record.createdAt)) {
78 Logger.verbose(
79 "Invalid createdAt timestamp for incoming tag record",
80 record,
81 );
82 return false;
83 }
84
85 if (record.name.length > 64) {
86 Logger.verbose("Name from incoming tag record is too long", record);
87 return false;
88 }
89
90 if (record.color) {
91 if (record.color.length > 7) {
92 Logger.verbose("Color from incoming tag record is too long", record);
93 return false;
94 }
95
96 if (!record.color.match("^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$")) {
97 Logger.verbose(
98 "Invalid hexadecimal color for incoming tag record",
99 record,
100 );
101 return false;
102 }
103 }
104
105 if (record.description) {
106 if (record.description.length > 500) {
107 Logger.verbose(
108 "Description from incoming tag record is too long",
109 record,
110 );
111 return false;
112 }
113 }
114
115 return true;
116}
117
118export async function validateClip(
119 record: SocialClipprFeedClip.Main,
120): Promise<boolean> {
121 if (!isGenericUri(record.url)) {
122 Logger.verbose("Invalid url from incoming clip record", record);
123 return false;
124 }
125
126 if (record.url.length > 2000) {
127 Logger.verbose("Too long url from incoming clip record", record);
128 return false;
129 }
130
131 if (record.title.length > 2048) {
132 Logger.verbose("Too long title from incoming clip record", record);
133 return false;
134 }
135
136 if (record.description.length > 4096) {
137 Logger.verbose("Too long description from incoming clip record", record);
138 return false;
139 }
140
141 if (record.notes) {
142 if (record.notes.length > 10000) {
143 Logger.verbose("Too long notes from incoming clip record", record);
144 return false;
145 }
146 }
147
148 if (record.tags) {
149 if (
150 record.tags.some((tag) => {
151 return tag.$type !== "com.atproto.repo.strongRef";
152 })
153 ) {
154 Logger.verbose(
155 "A tag from incoming clip record is not typed as strongRef",
156 record,
157 );
158 return false;
159 }
160
161 if (
162 record.tags.some((tag) => {
163 return !is(ComAtprotoRepoStrongRef.mainSchema, tag);
164 })
165 ) {
166 Logger.verbose(
167 "A tag from incoming clip record is not a valid strongRef",
168 record,
169 );
170 return false;
171 }
172
173 // There should definitely be more tests here, but I'm not exactly sure what to add...
174 }
175
176 if (typeof record.unlisted !== "boolean") {
177 Logger.verbose(
178 "Unlisted value from incoming clip record is not a boolean",
179 record,
180 );
181 return false;
182 }
183
184 // Same with "unread" but it's not required so
185
186 if (record.languages) {
187 if (record.languages.some((lang) => !isLanguageCode(lang))) {
188 Logger.verbose(
189 "An item in the incoming clip record's languages array is not a valid language code",
190 record,
191 );
192 return false;
193 }
194 }
195
196 if (!isDatetime(record.createdAt)) {
197 Logger.verbose(
198 "Invalid createdAt timestamp for incoming clip record",
199 record,
200 );
201 return false;
202 }
203
204 return true;
205}