this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add tags support for links and quotes

Add a one-to-many tags system for links and quotes with full
CRUD API endpoints. Tags are stored lowercased with no spaces
allowed (hyphens/underscores for multi-word). Tags can be added
at creation time or managed via dedicated endpoints.

Security model: DELETE requires admin API key; POST/PUT allowed
with admin key or within 10 minutes of post creation with
matching username. Includes input validation (max 100 chars,
max 50 tags per resource), request body size limits, and
validate-before-delete on PUT replace operations.

+1254 -9
+564 -3
internal/assets/openapi.json
··· 100 100 "type": "string", 101 101 "format": "date-time", 102 102 "description": "When the link was submitted" 103 + }, 104 + "tags": { 105 + "type": "array", 106 + "items": { 107 + "type": "string" 108 + }, 109 + "description": "Tags associated with this link" 103 110 } 104 111 }, 105 112 "required": ["id", "url", "user", "created_at"], ··· 109 116 "title": "Example Article", 110 117 "user": "alice", 111 118 "clicks": 15, 112 - "created_at": "2026-01-15T10:30:00Z" 119 + "created_at": "2026-01-15T10:30:00Z", 120 + "tags": ["golang", "tutorial"] 113 121 } 114 122 }, 115 123 "APILinkCreateResponse": { ··· 211 219 "type": "string", 212 220 "format": "date-time", 213 221 "description": "When the quote was submitted" 222 + }, 223 + "tags": { 224 + "type": "array", 225 + "items": { 226 + "type": "string" 227 + }, 228 + "description": "Tags associated with this quote" 214 229 } 215 230 }, 216 231 "required": ["id", "quote", "created_at"], ··· 219 234 "quote": "The only way to do great work is to love what you do.", 220 235 "author": "Steve Jobs", 221 236 "poster": "alice", 222 - "created_at": "2026-01-15T10:30:00Z" 237 + "created_at": "2026-01-15T10:30:00Z", 238 + "tags": ["inspiration", "tech"] 223 239 } 224 240 }, 225 241 "APIQuotesListResponse": { ··· 415 431 "user": { 416 432 "type": "string", 417 433 "description": "Username of the submitter" 434 + }, 435 + "tags": { 436 + "type": "array", 437 + "items": { 438 + "type": "string" 439 + }, 440 + "description": "Optional tags to add to the link (lowercased, no spaces)" 418 441 } 419 442 }, 420 443 "required": ["url", "user"], 421 444 "example": { 422 445 "url": "https://example.com/article", 446 + "user": "alice", 447 + "tags": ["golang", "tutorial"] 448 + } 449 + }, 450 + "APITagResponse": { 451 + "type": "object", 452 + "properties": { 453 + "id": { 454 + "type": "integer", 455 + "description": "Unique tag ID" 456 + }, 457 + "tag": { 458 + "type": "string", 459 + "description": "Tag string (lowercased, no spaces)" 460 + }, 461 + "resource_type": { 462 + "type": "string", 463 + "enum": ["link", "quote"], 464 + "description": "Type of resource this tag is attached to" 465 + }, 466 + "resource_id": { 467 + "type": "integer", 468 + "description": "ID of the resource this tag is attached to" 469 + }, 470 + "created_by": { 471 + "type": "string", 472 + "description": "Username who created this tag" 473 + }, 474 + "created_at": { 475 + "type": "string", 476 + "format": "date-time", 477 + "description": "When the tag was created" 478 + } 479 + }, 480 + "required": ["id", "tag", "resource_type", "resource_id", "created_at"], 481 + "example": { 482 + "id": 1, 483 + "tag": "golang", 484 + "resource_type": "link", 485 + "resource_id": 42, 486 + "created_by": "alice", 487 + "created_at": "2026-02-08T14:30:00Z" 488 + } 489 + }, 490 + "APITagsResponse": { 491 + "type": "object", 492 + "properties": { 493 + "data": { 494 + "type": "array", 495 + "items": { 496 + "$ref": "#/components/schemas/APITagResponse" 497 + } 498 + } 499 + }, 500 + "required": ["data"] 501 + }, 502 + "TagCreateRequest": { 503 + "type": "object", 504 + "properties": { 505 + "tags": { 506 + "type": "array", 507 + "items": { 508 + "type": "string" 509 + }, 510 + "description": "Array of tag strings (lowercased, no spaces, hyphens/underscores allowed)" 511 + }, 512 + "user": { 513 + "type": "string", 514 + "description": "Username creating the tags (required for authorization if not using API key)" 515 + } 516 + }, 517 + "required": ["tags"], 518 + "example": { 519 + "tags": ["golang", "web-dev", "tutorial"], 423 520 "user": "alice" 424 521 } 425 522 }, ··· 437 534 "poster": { 438 535 "type": "string", 439 536 "description": "Username of the submitter" 537 + }, 538 + "tags": { 539 + "type": "array", 540 + "items": { 541 + "type": "string" 542 + }, 543 + "description": "Optional tags to add to the quote (lowercased, no spaces)" 440 544 } 441 545 }, 442 546 "required": ["quote"], 443 547 "example": { 444 548 "quote": "The only way to do great work is to love what you do.", 445 549 "author": "Steve Jobs", 446 - "poster": "alice" 550 + "poster": "alice", 551 + "tags": ["inspiration"] 447 552 } 448 553 } 449 554 } ··· 943 1048 } 944 1049 } 945 1050 }, 1051 + "/api/v1/links/{id}/tags": { 1052 + "get": { 1053 + "summary": "List Tags for Link", 1054 + "description": "Returns all tags associated with a link.", 1055 + "tags": ["Tags"], 1056 + "parameters": [ 1057 + { 1058 + "name": "id", 1059 + "in": "path", 1060 + "description": "The link ID", 1061 + "required": true, 1062 + "schema": { 1063 + "type": "integer" 1064 + } 1065 + } 1066 + ], 1067 + "responses": { 1068 + "200": { 1069 + "description": "Tags for the link", 1070 + "content": { 1071 + "application/json": { 1072 + "schema": { 1073 + "$ref": "#/components/schemas/APITagsResponse" 1074 + } 1075 + } 1076 + } 1077 + }, 1078 + "500": { 1079 + "description": "Server error", 1080 + "content": { 1081 + "application/json": { 1082 + "schema": { 1083 + "$ref": "#/components/schemas/APIError" 1084 + } 1085 + } 1086 + } 1087 + } 1088 + } 1089 + }, 1090 + "post": { 1091 + "summary": "Add Tags to Link", 1092 + "description": "Adds tags to a link. Authorized if admin API key is present, or if within 10 minutes of link creation and username matches the original poster.", 1093 + "tags": ["Tags"], 1094 + "parameters": [ 1095 + { 1096 + "name": "id", 1097 + "in": "path", 1098 + "description": "The link ID", 1099 + "required": true, 1100 + "schema": { 1101 + "type": "integer" 1102 + } 1103 + } 1104 + ], 1105 + "requestBody": { 1106 + "required": true, 1107 + "content": { 1108 + "application/json": { 1109 + "schema": { 1110 + "$ref": "#/components/schemas/TagCreateRequest" 1111 + } 1112 + } 1113 + } 1114 + }, 1115 + "responses": { 1116 + "201": { 1117 + "description": "Tags created successfully", 1118 + "content": { 1119 + "application/json": { 1120 + "schema": { 1121 + "$ref": "#/components/schemas/APITagsResponse" 1122 + } 1123 + } 1124 + } 1125 + }, 1126 + "400": { 1127 + "description": "Bad request (malformed JSON)", 1128 + "content": { 1129 + "application/json": { 1130 + "schema": { 1131 + "$ref": "#/components/schemas/APIError" 1132 + } 1133 + } 1134 + } 1135 + }, 1136 + "403": { 1137 + "description": "Forbidden (not authorized to add tags)", 1138 + "content": { 1139 + "application/json": { 1140 + "schema": { 1141 + "$ref": "#/components/schemas/APIError" 1142 + } 1143 + } 1144 + } 1145 + }, 1146 + "404": { 1147 + "description": "Link not found", 1148 + "content": { 1149 + "application/json": { 1150 + "schema": { 1151 + "$ref": "#/components/schemas/APIError" 1152 + } 1153 + } 1154 + } 1155 + }, 1156 + "422": { 1157 + "description": "Validation error (invalid tag format)", 1158 + "content": { 1159 + "application/json": { 1160 + "schema": { 1161 + "$ref": "#/components/schemas/APIError" 1162 + } 1163 + } 1164 + } 1165 + } 1166 + } 1167 + }, 1168 + "put": { 1169 + "summary": "Replace Tags on Link", 1170 + "description": "Replaces all tags on a link. Same authorization rules as POST.", 1171 + "tags": ["Tags"], 1172 + "parameters": [ 1173 + { 1174 + "name": "id", 1175 + "in": "path", 1176 + "description": "The link ID", 1177 + "required": true, 1178 + "schema": { 1179 + "type": "integer" 1180 + } 1181 + } 1182 + ], 1183 + "requestBody": { 1184 + "required": true, 1185 + "content": { 1186 + "application/json": { 1187 + "schema": { 1188 + "$ref": "#/components/schemas/TagCreateRequest" 1189 + } 1190 + } 1191 + } 1192 + }, 1193 + "responses": { 1194 + "200": { 1195 + "description": "Tags replaced successfully", 1196 + "content": { 1197 + "application/json": { 1198 + "schema": { 1199 + "$ref": "#/components/schemas/APITagsResponse" 1200 + } 1201 + } 1202 + } 1203 + }, 1204 + "403": { 1205 + "description": "Forbidden", 1206 + "content": { 1207 + "application/json": { 1208 + "schema": { 1209 + "$ref": "#/components/schemas/APIError" 1210 + } 1211 + } 1212 + } 1213 + }, 1214 + "404": { 1215 + "description": "Link not found", 1216 + "content": { 1217 + "application/json": { 1218 + "schema": { 1219 + "$ref": "#/components/schemas/APIError" 1220 + } 1221 + } 1222 + } 1223 + } 1224 + } 1225 + } 1226 + }, 1227 + "/api/v1/links/{id}/tags/{tag}": { 1228 + "delete": { 1229 + "summary": "Remove Tag from Link", 1230 + "description": "Removes a specific tag from a link. Requires admin API key.", 1231 + "tags": ["Tags"], 1232 + "security": [ 1233 + { "apiKey": [] } 1234 + ], 1235 + "parameters": [ 1236 + { 1237 + "name": "id", 1238 + "in": "path", 1239 + "description": "The link ID", 1240 + "required": true, 1241 + "schema": { 1242 + "type": "integer" 1243 + } 1244 + }, 1245 + { 1246 + "name": "tag", 1247 + "in": "path", 1248 + "description": "The tag string to remove", 1249 + "required": true, 1250 + "schema": { 1251 + "type": "string" 1252 + } 1253 + } 1254 + ], 1255 + "responses": { 1256 + "204": { 1257 + "description": "Tag removed successfully" 1258 + }, 1259 + "403": { 1260 + "description": "Forbidden (invalid or missing API key)", 1261 + "content": { 1262 + "application/json": { 1263 + "schema": { 1264 + "$ref": "#/components/schemas/APIError" 1265 + } 1266 + } 1267 + } 1268 + }, 1269 + "404": { 1270 + "description": "Tag not found on this resource", 1271 + "content": { 1272 + "application/json": { 1273 + "schema": { 1274 + "$ref": "#/components/schemas/APIError" 1275 + } 1276 + } 1277 + } 1278 + } 1279 + } 1280 + } 1281 + }, 1282 + "/api/v1/quotes/{id}/tags": { 1283 + "get": { 1284 + "summary": "List Tags for Quote", 1285 + "description": "Returns all tags associated with a quote.", 1286 + "tags": ["Tags"], 1287 + "parameters": [ 1288 + { 1289 + "name": "id", 1290 + "in": "path", 1291 + "description": "The quote ID", 1292 + "required": true, 1293 + "schema": { 1294 + "type": "integer" 1295 + } 1296 + } 1297 + ], 1298 + "responses": { 1299 + "200": { 1300 + "description": "Tags for the quote", 1301 + "content": { 1302 + "application/json": { 1303 + "schema": { 1304 + "$ref": "#/components/schemas/APITagsResponse" 1305 + } 1306 + } 1307 + } 1308 + }, 1309 + "500": { 1310 + "description": "Server error", 1311 + "content": { 1312 + "application/json": { 1313 + "schema": { 1314 + "$ref": "#/components/schemas/APIError" 1315 + } 1316 + } 1317 + } 1318 + } 1319 + } 1320 + }, 1321 + "post": { 1322 + "summary": "Add Tags to Quote", 1323 + "description": "Adds tags to a quote. Authorized if admin API key is present, or if within 10 minutes of quote creation and username matches the original author.", 1324 + "tags": ["Tags"], 1325 + "parameters": [ 1326 + { 1327 + "name": "id", 1328 + "in": "path", 1329 + "description": "The quote ID", 1330 + "required": true, 1331 + "schema": { 1332 + "type": "integer" 1333 + } 1334 + } 1335 + ], 1336 + "requestBody": { 1337 + "required": true, 1338 + "content": { 1339 + "application/json": { 1340 + "schema": { 1341 + "$ref": "#/components/schemas/TagCreateRequest" 1342 + } 1343 + } 1344 + } 1345 + }, 1346 + "responses": { 1347 + "201": { 1348 + "description": "Tags created successfully", 1349 + "content": { 1350 + "application/json": { 1351 + "schema": { 1352 + "$ref": "#/components/schemas/APITagsResponse" 1353 + } 1354 + } 1355 + } 1356 + }, 1357 + "403": { 1358 + "description": "Forbidden", 1359 + "content": { 1360 + "application/json": { 1361 + "schema": { 1362 + "$ref": "#/components/schemas/APIError" 1363 + } 1364 + } 1365 + } 1366 + }, 1367 + "404": { 1368 + "description": "Quote not found", 1369 + "content": { 1370 + "application/json": { 1371 + "schema": { 1372 + "$ref": "#/components/schemas/APIError" 1373 + } 1374 + } 1375 + } 1376 + }, 1377 + "422": { 1378 + "description": "Validation error (invalid tag format)", 1379 + "content": { 1380 + "application/json": { 1381 + "schema": { 1382 + "$ref": "#/components/schemas/APIError" 1383 + } 1384 + } 1385 + } 1386 + } 1387 + } 1388 + }, 1389 + "put": { 1390 + "summary": "Replace Tags on Quote", 1391 + "description": "Replaces all tags on a quote. Same authorization rules as POST.", 1392 + "tags": ["Tags"], 1393 + "parameters": [ 1394 + { 1395 + "name": "id", 1396 + "in": "path", 1397 + "description": "The quote ID", 1398 + "required": true, 1399 + "schema": { 1400 + "type": "integer" 1401 + } 1402 + } 1403 + ], 1404 + "requestBody": { 1405 + "required": true, 1406 + "content": { 1407 + "application/json": { 1408 + "schema": { 1409 + "$ref": "#/components/schemas/TagCreateRequest" 1410 + } 1411 + } 1412 + } 1413 + }, 1414 + "responses": { 1415 + "200": { 1416 + "description": "Tags replaced successfully", 1417 + "content": { 1418 + "application/json": { 1419 + "schema": { 1420 + "$ref": "#/components/schemas/APITagsResponse" 1421 + } 1422 + } 1423 + } 1424 + }, 1425 + "403": { 1426 + "description": "Forbidden", 1427 + "content": { 1428 + "application/json": { 1429 + "schema": { 1430 + "$ref": "#/components/schemas/APIError" 1431 + } 1432 + } 1433 + } 1434 + }, 1435 + "404": { 1436 + "description": "Quote not found", 1437 + "content": { 1438 + "application/json": { 1439 + "schema": { 1440 + "$ref": "#/components/schemas/APIError" 1441 + } 1442 + } 1443 + } 1444 + } 1445 + } 1446 + } 1447 + }, 1448 + "/api/v1/quotes/{id}/tags/{tag}": { 1449 + "delete": { 1450 + "summary": "Remove Tag from Quote", 1451 + "description": "Removes a specific tag from a quote. Requires admin API key.", 1452 + "tags": ["Tags"], 1453 + "security": [ 1454 + { "apiKey": [] } 1455 + ], 1456 + "parameters": [ 1457 + { 1458 + "name": "id", 1459 + "in": "path", 1460 + "description": "The quote ID", 1461 + "required": true, 1462 + "schema": { 1463 + "type": "integer" 1464 + } 1465 + }, 1466 + { 1467 + "name": "tag", 1468 + "in": "path", 1469 + "description": "The tag string to remove", 1470 + "required": true, 1471 + "schema": { 1472 + "type": "string" 1473 + } 1474 + } 1475 + ], 1476 + "responses": { 1477 + "204": { 1478 + "description": "Tag removed successfully" 1479 + }, 1480 + "403": { 1481 + "description": "Forbidden (invalid or missing API key)", 1482 + "content": { 1483 + "application/json": { 1484 + "schema": { 1485 + "$ref": "#/components/schemas/APIError" 1486 + } 1487 + } 1488 + } 1489 + }, 1490 + "404": { 1491 + "description": "Tag not found on this resource", 1492 + "content": { 1493 + "application/json": { 1494 + "schema": { 1495 + "$ref": "#/components/schemas/APIError" 1496 + } 1497 + } 1498 + } 1499 + } 1500 + } 1501 + } 1502 + }, 946 1503 "/api/v1/stats": { 947 1504 "get": { 948 1505 "summary": "Get Site Statistics", ··· 1819 2376 { 1820 2377 "name": "Search", 1821 2378 "description": "Search functionality" 2379 + }, 2380 + { 2381 + "name": "Tags", 2382 + "description": "Tag management for links and quotes" 1822 2383 }, 1823 2384 { 1824 2385 "name": "Cache",
+47 -1
internal/data/gorm_store.go
··· 26 26 } 27 27 28 28 func (s *GormStore) Bootstrap(ctx context.Context) error { 29 - return s.db.AutoMigrate(&IRCLink{}, &Image{}, &Quote{}, &LinkPreview{}) 29 + return s.db.AutoMigrate(&IRCLink{}, &Image{}, &Quote{}, &LinkPreview{}, &Tag{}) 30 30 } 31 31 32 32 func (s *GormStore) GetRecentIRCLinks(ctx context.Context, startDays int, endDays int) ([]IRCLink, error) { ··· 414 414 return 0, result.Error 415 415 } 416 416 return int(result.RowsAffected), nil 417 + } 418 + 419 + func (s *GormStore) CreateTag(ctx context.Context, tag Tag) (*Tag, error) { 420 + err := s.db.WithContext(ctx).Create(&tag).Error 421 + if err != nil { 422 + return nil, err 423 + } 424 + return &tag, nil 425 + } 426 + 427 + func (s *GormStore) GetTagsByResource(ctx context.Context, resourceType string, resourceID int) ([]Tag, error) { 428 + var tags []Tag 429 + err := s.db.WithContext(ctx). 430 + Where("resource_type = ? AND resource_id = ?", resourceType, resourceID). 431 + Order("created_at ASC"). 432 + Find(&tags).Error 433 + return tags, err 434 + } 435 + 436 + func (s *GormStore) GetTagByID(ctx context.Context, id int) (*Tag, error) { 437 + var tag Tag 438 + err := s.db.WithContext(ctx).First(&tag, id).Error 439 + if err != nil { 440 + if err == gorm.ErrRecordNotFound { 441 + return nil, nil 442 + } 443 + return nil, err 444 + } 445 + return &tag, nil 446 + } 447 + 448 + func (s *GormStore) DeleteTag(ctx context.Context, id int) error { 449 + result := s.db.WithContext(ctx).Delete(&Tag{}, id) 450 + if result.Error != nil { 451 + return result.Error 452 + } 453 + if result.RowsAffected == 0 { 454 + return fmt.Errorf("tag not found") 455 + } 456 + return nil 457 + } 458 + 459 + func (s *GormStore) DeleteTagsByResource(ctx context.Context, resourceType string, resourceID int) error { 460 + return s.db.WithContext(ctx). 461 + Where("resource_type = ? AND resource_id = ?", resourceType, resourceID). 462 + Delete(&Tag{}).Error 417 463 } 418 464 419 465 func (s *GormStore) GetLinksByPopularity(ctx context.Context, limit int, offset int) ([]IRCLink, error) {
+21
internal/data/store.go
··· 65 65 ContentType string `json:"contentType" gorm:"column:content_type"` // For links (to detect images) 66 66 } 67 67 68 + type Tag struct { 69 + ID int `json:"id" gorm:"column:id;primaryKey;autoIncrement"` 70 + Tag string `json:"tag" gorm:"column:tag;type:varchar(255);index"` 71 + ResourceType string `json:"resource_type" gorm:"column:resource_type;type:varchar(50);index:idx_tag_resource"` 72 + ResourceID int `json:"resource_id" gorm:"column:resource_id;index:idx_tag_resource"` 73 + CreatedBy string `json:"created_by" gorm:"column:created_by;type:varchar(255)"` 74 + CreatedAt time.Time `json:"created_at" gorm:"column:created_at;autoCreateTime"` 75 + } 76 + 77 + // TableName overrides the table name to `tags` 78 + func (Tag) TableName() string { 79 + return "tags" 80 + } 81 + 68 82 type LinkPreview struct { 69 83 URL string `json:"url" gorm:"column:url;primaryKey"` 70 84 Data []byte `json:"data" gorm:"column:data;type:text"` // JSON blob of the map[string]string metadata ··· 108 122 InsertLinkPreview(ctx context.Context, url string, data []byte) error 109 123 DeleteLinkPreview(ctx context.Context, url string) error 110 124 DeleteAllLinkPreviews(ctx context.Context) (int, error) 125 + 126 + // Tag operations 127 + CreateTag(ctx context.Context, tag Tag) (*Tag, error) 128 + GetTagsByResource(ctx context.Context, resourceType string, resourceID int) ([]Tag, error) 129 + GetTagByID(ctx context.Context, id int) (*Tag, error) 130 + DeleteTag(ctx context.Context, id int) error 131 + DeleteTagsByResource(ctx context.Context, resourceType string, resourceID int) error 111 132 112 133 // Image operations 113 134 InsertImage(ctx context.Context, title, link, url string) (int, error)
+29
internal/handler/api_v1_integration_test.go
··· 128 128 return m.searchQuotes, nil 129 129 } 130 130 131 + func (m *integrationMockStore) CreateTag(ctx context.Context, tag data.Tag) (*data.Tag, error) { 132 + if m.err != nil { 133 + return nil, m.err 134 + } 135 + return &tag, nil 136 + } 137 + 138 + func (m *integrationMockStore) GetTagsByResource(ctx context.Context, resourceType string, resourceID int) ([]data.Tag, error) { 139 + if m.err != nil { 140 + return nil, m.err 141 + } 142 + return nil, nil 143 + } 144 + 145 + func (m *integrationMockStore) GetTagByID(ctx context.Context, id int) (*data.Tag, error) { 146 + if m.err != nil { 147 + return nil, m.err 148 + } 149 + return nil, nil 150 + } 151 + 152 + func (m *integrationMockStore) DeleteTag(ctx context.Context, id int) error { 153 + return m.err 154 + } 155 + 156 + func (m *integrationMockStore) DeleteTagsByResource(ctx context.Context, resourceType string, resourceID int) error { 157 + return m.err 158 + } 159 + 131 160 // setupIntegrationTest creates a handler and mux with all v1 routes registered. 132 161 func setupIntegrationTest(store *integrationMockStore) (*http.ServeMux, *Handler) { 133 162 cfg := &config.Config{
+22 -2
internal/handler/api_v1_links.go
··· 34 34 writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed") 35 35 } 36 36 default: 37 + // Check if this is a tags sub-resource (e.g., "5/tags" or "5/tags/foo") 38 + if strings.Contains(path, "/tags") { 39 + h.APIv1LinkTagsHandler(w, r) 40 + return 41 + } 42 + 37 43 // Individual resource endpoints: GET or DELETE 38 44 // Path should be the ID 39 45 id, err := strconv.Atoi(path) ··· 94 100 User: link.User, 95 101 Clicks: link.Clicks, 96 102 CreatedAt: link.Timestamp, 103 + Tags: h.getTagStrings(ctx, "link", link.ID), 97 104 }) 98 105 } 99 106 ··· 111 118 112 119 // APILinkCreateRequest is the request body for POST /api/v1/links. 113 120 type APILinkCreateRequest struct { 114 - URL string `json:"url"` 115 - User string `json:"user"` 121 + URL string `json:"url"` 122 + User string `json:"user"` 123 + Tags []string `json:"tags,omitempty"` 116 124 } 117 125 118 126 // apiV1CreateLink handles POST /api/v1/links ··· 184 192 return 185 193 } 186 194 195 + // Create tags if provided 196 + var tagStrings []string 197 + if len(req.Tags) > 0 { 198 + if errMsg := h.createTagsForResource(ctx, "link", linkID, req.Tags, req.User); errMsg != "" { 199 + writeValidationError(w, map[string]string{"tags": errMsg}) 200 + return 201 + } 202 + tagStrings = h.getTagStrings(ctx, "link", linkID) 203 + } 204 + 187 205 resp := APILinkCreateResponse{ 188 206 APILinkResponse: APILinkResponse{ 189 207 ID: linkID, ··· 192 210 User: req.User, 193 211 Clicks: 0, 194 212 CreatedAt: time.Now(), 213 + Tags: tagStrings, 195 214 }, 196 215 IsDuplicate: isDuplicate, 197 216 PreviousSubmissions: previousSubmissions, ··· 229 248 User: link.User, 230 249 Clicks: link.Clicks, 231 250 CreatedAt: link.Timestamp, 251 + Tags: h.getTagStrings(ctx, "link", link.ID), 232 252 }) 233 253 } 234 254
+27 -3
internal/handler/api_v1_quotes.go
··· 34 34 writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed") 35 35 } 36 36 default: 37 + // Check if this is a tags sub-resource (e.g., "5/tags" or "5/tags/foo") 38 + if strings.Contains(path, "/tags") { 39 + h.APIv1QuoteTagsHandler(w, r) 40 + return 41 + } 42 + 37 43 // Individual resource endpoints: GET or DELETE 38 44 // Path should be the ID 39 45 id, err := strconv.Atoi(path) ··· 93 99 Author: quote.Author, 94 100 Poster: quote.Poster, 95 101 CreatedAt: quote.Timestamp, 102 + Tags: h.getTagStrings(ctx, "quote", quote.ID), 96 103 }) 97 104 } 98 105 ··· 110 117 111 118 // APIQuoteCreateRequest is the request body for POST /api/v1/quotes. 112 119 type APIQuoteCreateRequest struct { 113 - Quote string `json:"quote"` 114 - Author string `json:"author"` 115 - Poster string `json:"poster"` 120 + Quote string `json:"quote"` 121 + Author string `json:"author"` 122 + Poster string `json:"poster"` 123 + Tags []string `json:"tags,omitempty"` 116 124 } 117 125 118 126 // apiV1CreateQuote handles POST /api/v1/quotes ··· 156 164 return 157 165 } 158 166 167 + // Create tags if provided 168 + var tagStrings []string 169 + if len(req.Tags) > 0 { 170 + poster := req.Poster 171 + if poster == "" { 172 + poster = req.Author 173 + } 174 + if errMsg := h.createTagsForResource(ctx, "quote", quoteID, req.Tags, poster); errMsg != "" { 175 + writeValidationError(w, map[string]string{"tags": errMsg}) 176 + return 177 + } 178 + tagStrings = h.getTagStrings(ctx, "quote", quoteID) 179 + } 180 + 159 181 resp := APIQuoteResponse{ 160 182 ID: quoteID, 161 183 Quote: req.Quote, 162 184 Author: req.Author, 163 185 Poster: req.Poster, 164 186 CreatedAt: time.Now(), 187 + Tags: tagStrings, 165 188 } 166 189 167 190 writeJSON(w, http.StatusCreated, resp) ··· 199 222 Author: quote.Author, 200 223 Poster: quote.Poster, 201 224 CreatedAt: quote.Timestamp, 225 + Tags: h.getTagStrings(ctx, "quote", quote.ID), 202 226 }) 203 227 } 204 228
+20
internal/handler/api_v1_quotes_test.go
··· 62 62 return nil 63 63 } 64 64 65 + func (m *mockQuoteStore) GetTagsByResource(ctx context.Context, resourceType string, resourceID int) ([]data.Tag, error) { 66 + return nil, nil 67 + } 68 + 69 + func (m *mockQuoteStore) CreateTag(ctx context.Context, tag data.Tag) (*data.Tag, error) { 70 + return &tag, nil 71 + } 72 + 73 + func (m *mockQuoteStore) GetTagByID(ctx context.Context, id int) (*data.Tag, error) { 74 + return nil, nil 75 + } 76 + 77 + func (m *mockQuoteStore) DeleteTag(ctx context.Context, id int) error { 78 + return nil 79 + } 80 + 81 + func (m *mockQuoteStore) DeleteTagsByResource(ctx context.Context, resourceType string, resourceID int) error { 82 + return nil 83 + } 84 + 65 85 func TestAPIv1_ListQuotes(t *testing.T) { 66 86 now := time.Now() 67 87
+459
internal/handler/api_v1_tags.go
··· 1 + package handler 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "net/http" 7 + "strconv" 8 + "strings" 9 + "time" 10 + 11 + "tumble/internal/data" 12 + ) 13 + 14 + // getTagStrings fetches tags for a resource and returns just the tag strings. 15 + func (h *Handler) getTagStrings(ctx context.Context, resourceType string, resourceID int) []string { 16 + tags, err := h.Store.GetTagsByResource(ctx, resourceType, resourceID) 17 + if err != nil || len(tags) == 0 { 18 + return nil 19 + } 20 + result := make([]string, len(tags)) 21 + for i, t := range tags { 22 + result[i] = t.Tag 23 + } 24 + return result 25 + } 26 + 27 + // createTagsForResource creates tags for a given resource, validating each tag. 28 + // Returns an error string if validation fails, empty string on success. 29 + func (h *Handler) createTagsForResource(ctx context.Context, resourceType string, resourceID int, tags []string, user string) string { 30 + if len(tags) > maxTagsPerResource { 31 + return "too many tags: maximum " + strconv.Itoa(maxTagsPerResource) + " tags per resource" 32 + } 33 + for _, tagStr := range tags { 34 + normalized, valid := validateTag(tagStr) 35 + if !valid { 36 + return "invalid tag (no spaces allowed, must be non-empty, max 100 chars)" 37 + } 38 + tag := data.Tag{ 39 + Tag: normalized, 40 + ResourceType: resourceType, 41 + ResourceID: resourceID, 42 + CreatedBy: user, 43 + } 44 + if _, err := h.Store.CreateTag(ctx, tag); err != nil { 45 + return "failed to create tag" 46 + } 47 + } 48 + return "" 49 + } 50 + 51 + // maxTagsPerResource is the maximum number of tags allowed on a single resource. 52 + const maxTagsPerResource = 50 53 + 54 + // maxTagLength is the maximum length of a single tag string. 55 + const maxTagLength = 100 56 + 57 + // validateTag checks that a tag string is valid: non-empty, no spaces, lowercased, within length limit. 58 + // Returns the normalized tag and whether it is valid. 59 + func validateTag(tag string) (string, bool) { 60 + tag = strings.ToLower(strings.TrimSpace(tag)) 61 + if tag == "" { 62 + return "", false 63 + } 64 + if len(tag) > maxTagLength { 65 + return "", false 66 + } 67 + if strings.ContainsAny(tag, " \t\n\r") { 68 + return "", false 69 + } 70 + return tag, true 71 + } 72 + 73 + // isTagWriteAuthorized checks if a tag write (POST/PUT) is authorized. 74 + // Allowed if admin API key is present, or if same username within 10 minutes of post creation. 75 + func isTagWriteAuthorized(r *http.Request, adminSecret string, postUser string, postCreatedAt time.Time, requestUser string) bool { 76 + if isAuthorizedAPIKey(r, adminSecret) { 77 + return true 78 + } 79 + if requestUser == "" || postUser == "" { 80 + return false 81 + } 82 + if requestUser != postUser { 83 + return false 84 + } 85 + if time.Since(postCreatedAt) > 10*time.Minute { 86 + return false 87 + } 88 + return true 89 + } 90 + 91 + // APIv1LinkTagsHandler handles /api/v1/links/{id}/tags and /api/v1/links/{id}/tags/{tag} 92 + func (h *Handler) APIv1LinkTagsHandler(w http.ResponseWriter, r *http.Request) { 93 + // Parse the path: /api/v1/links/{id}/tags[/{tag}] 94 + path := strings.TrimPrefix(r.URL.Path, "/api/v1/links/") 95 + path = trimFormatSuffix(path) 96 + 97 + parts := strings.SplitN(path, "/", 3) 98 + if len(parts) < 2 || parts[1] != "tags" { 99 + writeAPIError(w, http.StatusNotFound, "not_found", "Not found") 100 + return 101 + } 102 + 103 + linkID, err := strconv.Atoi(parts[0]) 104 + if err != nil { 105 + writeAPIError(w, http.StatusBadRequest, "invalid_id", "Invalid link ID") 106 + return 107 + } 108 + 109 + // Check if there's a specific tag in the path (for DELETE) 110 + tagName := "" 111 + if len(parts) == 3 && parts[2] != "" { 112 + tagName = parts[2] 113 + } 114 + 115 + switch r.Method { 116 + case http.MethodGet: 117 + h.apiV1GetResourceTags(w, r, "link", linkID) 118 + case http.MethodPost: 119 + h.apiV1AddLinkTags(w, r, linkID) 120 + case http.MethodPut: 121 + h.apiV1ReplaceLinkTags(w, r, linkID) 122 + case http.MethodDelete: 123 + if tagName == "" { 124 + writeAPIError(w, http.StatusBadRequest, "bad_request", "Tag name required for delete") 125 + return 126 + } 127 + h.apiV1DeleteResourceTag(w, r, "link", linkID, tagName) 128 + default: 129 + writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed") 130 + } 131 + } 132 + 133 + // APIv1QuoteTagsHandler handles /api/v1/quotes/{id}/tags and /api/v1/quotes/{id}/tags/{tag} 134 + func (h *Handler) APIv1QuoteTagsHandler(w http.ResponseWriter, r *http.Request) { 135 + // Parse the path: /api/v1/quotes/{id}/tags[/{tag}] 136 + path := strings.TrimPrefix(r.URL.Path, "/api/v1/quotes/") 137 + path = trimFormatSuffix(path) 138 + 139 + parts := strings.SplitN(path, "/", 3) 140 + if len(parts) < 2 || parts[1] != "tags" { 141 + writeAPIError(w, http.StatusNotFound, "not_found", "Not found") 142 + return 143 + } 144 + 145 + quoteID, err := strconv.Atoi(parts[0]) 146 + if err != nil { 147 + writeAPIError(w, http.StatusBadRequest, "invalid_id", "Invalid quote ID") 148 + return 149 + } 150 + 151 + tagName := "" 152 + if len(parts) == 3 && parts[2] != "" { 153 + tagName = parts[2] 154 + } 155 + 156 + switch r.Method { 157 + case http.MethodGet: 158 + h.apiV1GetResourceTags(w, r, "quote", quoteID) 159 + case http.MethodPost: 160 + h.apiV1AddQuoteTags(w, r, quoteID) 161 + case http.MethodPut: 162 + h.apiV1ReplaceQuoteTags(w, r, quoteID) 163 + case http.MethodDelete: 164 + if tagName == "" { 165 + writeAPIError(w, http.StatusBadRequest, "bad_request", "Tag name required for delete") 166 + return 167 + } 168 + h.apiV1DeleteResourceTag(w, r, "quote", quoteID, tagName) 169 + default: 170 + writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed") 171 + } 172 + } 173 + 174 + // apiV1GetResourceTags handles GET for tags on a resource. 175 + func (h *Handler) apiV1GetResourceTags(w http.ResponseWriter, r *http.Request, resourceType string, resourceID int) { 176 + ctx := r.Context() 177 + 178 + tags, err := h.Store.GetTagsByResource(ctx, resourceType, resourceID) 179 + if err != nil { 180 + writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch tags") 181 + return 182 + } 183 + 184 + apiTags := make([]APITagResponse, 0, len(tags)) 185 + for _, t := range tags { 186 + apiTags = append(apiTags, APITagResponse{ 187 + ID: t.ID, 188 + Tag: t.Tag, 189 + ResourceType: t.ResourceType, 190 + ResourceID: t.ResourceID, 191 + CreatedBy: t.CreatedBy, 192 + CreatedAt: t.CreatedAt, 193 + }) 194 + } 195 + 196 + writeJSON(w, http.StatusOK, APITagsResponse{Data: apiTags}) 197 + } 198 + 199 + // apiV1AddLinkTags handles POST /api/v1/links/{id}/tags 200 + func (h *Handler) apiV1AddLinkTags(w http.ResponseWriter, r *http.Request, linkID int) { 201 + ctx := r.Context() 202 + 203 + link, err := h.Store.GetIRCLinkByID(ctx, linkID) 204 + if err != nil { 205 + writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch link") 206 + return 207 + } 208 + if link == nil { 209 + writeAPIError(w, http.StatusNotFound, "not_found", "Link not found") 210 + return 211 + } 212 + 213 + var req APITagCreateRequest 214 + r.Body = http.MaxBytesReader(w, r.Body, 64*1024) 215 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 216 + writeAPIError(w, http.StatusBadRequest, "invalid_request", "Invalid JSON request body") 217 + return 218 + } 219 + 220 + if !isTagWriteAuthorized(r, h.Config.AdminSecret, link.User, link.Timestamp, req.User) { 221 + writeAPIError(w, http.StatusForbidden, "forbidden", "Not authorized to add tags to this resource") 222 + return 223 + } 224 + 225 + h.apiV1CreateTags(w, r, "link", linkID, req) 226 + } 227 + 228 + // apiV1AddQuoteTags handles POST /api/v1/quotes/{id}/tags 229 + func (h *Handler) apiV1AddQuoteTags(w http.ResponseWriter, r *http.Request, quoteID int) { 230 + ctx := r.Context() 231 + 232 + quote, err := h.Store.GetQuoteByID(ctx, quoteID) 233 + if err != nil { 234 + writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch quote") 235 + return 236 + } 237 + if quote == nil { 238 + writeAPIError(w, http.StatusNotFound, "not_found", "Quote not found") 239 + return 240 + } 241 + 242 + var req APITagCreateRequest 243 + r.Body = http.MaxBytesReader(w, r.Body, 64*1024) 244 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 245 + writeAPIError(w, http.StatusBadRequest, "invalid_request", "Invalid JSON request body") 246 + return 247 + } 248 + 249 + // For quotes, the poster is the "owner" for tag authorization 250 + if !isTagWriteAuthorized(r, h.Config.AdminSecret, quote.Author, quote.Timestamp, req.User) { 251 + writeAPIError(w, http.StatusForbidden, "forbidden", "Not authorized to add tags to this resource") 252 + return 253 + } 254 + 255 + h.apiV1CreateTags(w, r, "quote", quoteID, req) 256 + } 257 + 258 + // apiV1CreateTags is a shared helper for creating tags on a resource. 259 + func (h *Handler) apiV1CreateTags(w http.ResponseWriter, r *http.Request, resourceType string, resourceID int, req APITagCreateRequest) { 260 + ctx := r.Context() 261 + 262 + if len(req.Tags) == 0 { 263 + writeValidationError(w, map[string]string{"tags": "at least one tag is required"}) 264 + return 265 + } 266 + 267 + // Check existing tag count to enforce limit 268 + existingTags, err := h.Store.GetTagsByResource(ctx, resourceType, resourceID) 269 + if err != nil { 270 + writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch existing tags") 271 + return 272 + } 273 + if len(existingTags)+len(req.Tags) > maxTagsPerResource { 274 + writeValidationError(w, map[string]string{"tags": "too many tags: maximum " + strconv.Itoa(maxTagsPerResource) + " tags per resource"}) 275 + return 276 + } 277 + 278 + createdTags := make([]APITagResponse, 0, len(req.Tags)) 279 + for _, tagStr := range req.Tags { 280 + normalized, valid := validateTag(tagStr) 281 + if !valid { 282 + writeValidationError(w, map[string]string{"tags": "invalid tag (no spaces allowed, must be non-empty, max 100 chars)"}) 283 + return 284 + } 285 + 286 + tag := data.Tag{ 287 + Tag: normalized, 288 + ResourceType: resourceType, 289 + ResourceID: resourceID, 290 + CreatedBy: req.User, 291 + } 292 + 293 + created, err := h.Store.CreateTag(ctx, tag) 294 + if err != nil { 295 + writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to create tag") 296 + return 297 + } 298 + 299 + createdTags = append(createdTags, APITagResponse{ 300 + ID: created.ID, 301 + Tag: created.Tag, 302 + ResourceType: created.ResourceType, 303 + ResourceID: created.ResourceID, 304 + CreatedBy: created.CreatedBy, 305 + CreatedAt: created.CreatedAt, 306 + }) 307 + } 308 + 309 + writeJSON(w, http.StatusCreated, APITagsResponse{Data: createdTags}) 310 + } 311 + 312 + // apiV1ReplaceLinkTags handles PUT /api/v1/links/{id}/tags 313 + func (h *Handler) apiV1ReplaceLinkTags(w http.ResponseWriter, r *http.Request, linkID int) { 314 + ctx := r.Context() 315 + 316 + link, err := h.Store.GetIRCLinkByID(ctx, linkID) 317 + if err != nil { 318 + writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch link") 319 + return 320 + } 321 + if link == nil { 322 + writeAPIError(w, http.StatusNotFound, "not_found", "Link not found") 323 + return 324 + } 325 + 326 + var req APITagCreateRequest 327 + r.Body = http.MaxBytesReader(w, r.Body, 64*1024) 328 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 329 + writeAPIError(w, http.StatusBadRequest, "invalid_request", "Invalid JSON request body") 330 + return 331 + } 332 + 333 + if !isTagWriteAuthorized(r, h.Config.AdminSecret, link.User, link.Timestamp, req.User) { 334 + writeAPIError(w, http.StatusForbidden, "forbidden", "Not authorized to modify tags on this resource") 335 + return 336 + } 337 + 338 + h.apiV1ReplaceTags(w, r, "link", linkID, req) 339 + } 340 + 341 + // apiV1ReplaceQuoteTags handles PUT /api/v1/quotes/{id}/tags 342 + func (h *Handler) apiV1ReplaceQuoteTags(w http.ResponseWriter, r *http.Request, quoteID int) { 343 + ctx := r.Context() 344 + 345 + quote, err := h.Store.GetQuoteByID(ctx, quoteID) 346 + if err != nil { 347 + writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch quote") 348 + return 349 + } 350 + if quote == nil { 351 + writeAPIError(w, http.StatusNotFound, "not_found", "Quote not found") 352 + return 353 + } 354 + 355 + var req APITagCreateRequest 356 + r.Body = http.MaxBytesReader(w, r.Body, 64*1024) 357 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 358 + writeAPIError(w, http.StatusBadRequest, "invalid_request", "Invalid JSON request body") 359 + return 360 + } 361 + 362 + if !isTagWriteAuthorized(r, h.Config.AdminSecret, quote.Author, quote.Timestamp, req.User) { 363 + writeAPIError(w, http.StatusForbidden, "forbidden", "Not authorized to modify tags on this resource") 364 + return 365 + } 366 + 367 + h.apiV1ReplaceTags(w, r, "quote", quoteID, req) 368 + } 369 + 370 + // apiV1ReplaceTags is a shared helper for replacing all tags on a resource. 371 + func (h *Handler) apiV1ReplaceTags(w http.ResponseWriter, r *http.Request, resourceType string, resourceID int, req APITagCreateRequest) { 372 + ctx := r.Context() 373 + 374 + // Enforce tag limit 375 + if len(req.Tags) > maxTagsPerResource { 376 + writeValidationError(w, map[string]string{"tags": "too many tags: maximum " + strconv.Itoa(maxTagsPerResource) + " tags per resource"}) 377 + return 378 + } 379 + 380 + // Validate ALL tags before making any changes to avoid partial state 381 + normalizedTags := make([]string, 0, len(req.Tags)) 382 + for _, tagStr := range req.Tags { 383 + normalized, valid := validateTag(tagStr) 384 + if !valid { 385 + writeValidationError(w, map[string]string{"tags": "invalid tag (no spaces allowed, must be non-empty, max 100 chars)"}) 386 + return 387 + } 388 + normalizedTags = append(normalizedTags, normalized) 389 + } 390 + 391 + // Delete existing tags 392 + if err := h.Store.DeleteTagsByResource(ctx, resourceType, resourceID); err != nil { 393 + writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to remove existing tags") 394 + return 395 + } 396 + 397 + // If no new tags, return empty 398 + if len(normalizedTags) == 0 { 399 + writeJSON(w, http.StatusOK, APITagsResponse{Data: []APITagResponse{}}) 400 + return 401 + } 402 + 403 + createdTags := make([]APITagResponse, 0, len(normalizedTags)) 404 + for _, normalized := range normalizedTags { 405 + tag := data.Tag{ 406 + Tag: normalized, 407 + ResourceType: resourceType, 408 + ResourceID: resourceID, 409 + CreatedBy: req.User, 410 + } 411 + 412 + created, err := h.Store.CreateTag(ctx, tag) 413 + if err != nil { 414 + writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to create tag") 415 + return 416 + } 417 + 418 + createdTags = append(createdTags, APITagResponse{ 419 + ID: created.ID, 420 + Tag: created.Tag, 421 + ResourceType: created.ResourceType, 422 + ResourceID: created.ResourceID, 423 + CreatedBy: created.CreatedBy, 424 + CreatedAt: created.CreatedAt, 425 + }) 426 + } 427 + 428 + writeJSON(w, http.StatusOK, APITagsResponse{Data: createdTags}) 429 + } 430 + 431 + // apiV1DeleteResourceTag handles DELETE /api/v1/{links|quotes}/{id}/tags/{tag} 432 + func (h *Handler) apiV1DeleteResourceTag(w http.ResponseWriter, r *http.Request, resourceType string, resourceID int, tagName string) { 433 + if !isAuthorizedAPIKey(r, h.Config.AdminSecret) { 434 + writeAPIError(w, http.StatusForbidden, "forbidden", "Invalid or missing API key") 435 + return 436 + } 437 + 438 + ctx := r.Context() 439 + 440 + tags, err := h.Store.GetTagsByResource(ctx, resourceType, resourceID) 441 + if err != nil { 442 + writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch tags") 443 + return 444 + } 445 + 446 + normalized := strings.ToLower(strings.TrimSpace(tagName)) 447 + for _, t := range tags { 448 + if t.Tag == normalized { 449 + if err := h.Store.DeleteTag(ctx, t.ID); err != nil { 450 + writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to delete tag") 451 + return 452 + } 453 + w.WriteHeader(http.StatusNoContent) 454 + return 455 + } 456 + } 457 + 458 + writeAPIError(w, http.StatusNotFound, "not_found", "Tag not found on this resource") 459 + }
+23
internal/handler/api_v1_types.go
··· 31 31 User string `json:"user"` 32 32 Clicks int `json:"clicks"` 33 33 CreatedAt time.Time `json:"created_at"` 34 + Tags []string `json:"tags,omitempty"` 34 35 } 35 36 36 37 // APIPreviousSubmission contains information about a previous submission ··· 62 63 Author string `json:"author"` 63 64 Poster string `json:"poster,omitempty"` 64 65 CreatedAt time.Time `json:"created_at"` 66 + Tags []string `json:"tags,omitempty"` 65 67 } 66 68 67 69 // APIQuotesResponse is the paginated response for a list of quotes. ··· 119 121 Date string `json:"date"` 120 122 Fetched bool `json:"fetched"` 121 123 } 124 + 125 + // APITagResponse represents a single tag in API responses. 126 + type APITagResponse struct { 127 + ID int `json:"id"` 128 + Tag string `json:"tag"` 129 + ResourceType string `json:"resource_type"` 130 + ResourceID int `json:"resource_id"` 131 + CreatedBy string `json:"created_by"` 132 + CreatedAt time.Time `json:"created_at"` 133 + } 134 + 135 + // APITagsResponse is the response for a list of tags on a resource. 136 + type APITagsResponse struct { 137 + Data []APITagResponse `json:"data"` 138 + } 139 + 140 + // APITagCreateRequest is the request body for creating tags. 141 + type APITagCreateRequest struct { 142 + Tags []string `json:"tags"` 143 + User string `json:"user"` 144 + }
+11
sql/mysql/000003_add_tags.up.sql
··· 1 + CREATE TABLE IF NOT EXISTS `tags` ( 2 + `id` int(16) NOT NULL AUTO_INCREMENT, 3 + `tag` varchar(255) NOT NULL DEFAULT '', 4 + `resource_type` varchar(50) NOT NULL DEFAULT '', 5 + `resource_id` int(16) NOT NULL DEFAULT 0, 6 + `created_by` varchar(255) NOT NULL DEFAULT '', 7 + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, 8 + PRIMARY KEY (`id`), 9 + KEY `idx_tags_tag` (`tag`), 10 + KEY `idx_tags_resource` (`resource_type`, `resource_id`) 11 + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+11
sql/sqlite/000003_add_tags.up.sql
··· 1 + CREATE TABLE IF NOT EXISTS tags ( 2 + id INTEGER PRIMARY KEY AUTOINCREMENT, 3 + tag TEXT NOT NULL DEFAULT '', 4 + resource_type TEXT NOT NULL DEFAULT '', 5 + resource_id INTEGER NOT NULL DEFAULT 0, 6 + created_by TEXT NOT NULL DEFAULT '', 7 + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP 8 + ); 9 + 10 + CREATE INDEX IF NOT EXISTS idx_tags_tag ON tags(tag); 11 + CREATE INDEX IF NOT EXISTS idx_tags_resource ON tags(resource_type, resource_id);