Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +16 −0
| @@ -296,6 +296,13 @@ select:focus { | |||
| 296 | .card form button[type="submit"] { | 296 | .card form button[type="submit"] { |
| 297 | margin-top: 1.1rem; | 297 | margin-top: 1.1rem; |
| 298 | } | 298 | } |
| 299 | .field-hint { | ||
| 300 | margin: 0.25rem 0 0.5rem; | ||
| 301 | font-size: 0.85em; | ||
| 302 | } | ||
| 303 | .link-slot { | ||
| 304 | margin-bottom: 0.5rem; | ||
| 305 | } | ||
| 299 | 306 | ||
| 300 | /* Centered auth card (sign in, invite activation). */ | 307 | /* Centered auth card (sign in, invite activation). */ |
| 301 | .auth-wrap { | 308 | .auth-wrap { |
| @@ -1106,6 +1113,15 @@ pre.code { | |||
| 1106 | color: var(--fb-accent); | 1113 | color: var(--fb-accent); |
| 1107 | } | 1114 | } |
| 1108 | 1115 | ||
| 1116 | /* Rendered markdown preview continues the blob-header box (no seam). */ | ||
| 1117 | .blob-preview { | ||
| 1118 | padding: 1.25rem 1.5rem; | ||
| 1119 | background: var(--fb-bg-raised); | ||
| 1120 | border: 1px solid var(--fb-border); | ||
| 1121 | border-top: none; | ||
| 1122 | border-radius: 0 0 var(--fb-radius) var(--fb-radius); | ||
| 1123 | } | ||
| 1124 | |||
| 1109 | table.blob { | 1125 | table.blob { |
| 1110 | width: 100%; | 1126 | width: 100%; |
| 1111 | border-collapse: collapse; | 1127 | border-collapse: collapse; |
crates/model/src/entity.rs +3 −6
| @@ -69,14 +69,11 @@ pub struct User { | |||
| 69 | pub pronouns: Option<String>, | 69 | pub pronouns: Option<String>, |
| 70 | /// Optional free-text bio shown on the profile. | 70 | /// Optional free-text bio shown on the profile. |
| 71 | pub bio: Option<String>, | 71 | pub bio: Option<String>, |
| 72 | /// Optional personal website URL. | ||
| 73 | pub website: Option<String>, | ||
| 74 | /// Optional location string. | 72 | /// Optional location string. |
| 75 | pub location: Option<String>, | 73 | pub location: Option<String>, |
| 76 | /// Optional `GitHub` handle (username only, no `@`). | 74 | /// Up to five arbitrary profile links (their brand icons are inferred from |
| 77 | pub social_github: Option<String>, | 75 | /// the URL host). |
| 78 | /// Optional Mastodon handle (e.g. `@user@instance`). | 76 | pub links: Vec<String>, |
| 79 | pub social_mastodon: Option<String>, | ||
| 80 | /// Content type of the uploaded avatar, or `None` to use a generated | 77 | /// Content type of the uploaded avatar, or `None` to use a generated |
| 81 | /// identicon. The bytes live on disk, not in this row. | 78 | /// identicon. The bytes live on disk, not in this row. |
| 82 | pub avatar_mime: Option<String>, | 79 | pub avatar_mime: Option<String>, |
crates/store/src/tests.rs +6 −4
| @@ -103,10 +103,11 @@ async fn update_profile_and_avatar_round_trip() { | |||
| 103 | display_name: Some("Professor".to_string()), | 103 | display_name: Some("Professor".to_string()), |
| 104 | pronouns: Some("they/them".to_string()), | 104 | pronouns: Some("they/them".to_string()), |
| 105 | bio: Some("Hello there.".to_string()), | 105 | bio: Some("Hello there.".to_string()), |
| 106 | website: Some("https://example.com".to_string()), | ||
| 107 | location: Some("Everywhere".to_string()), | 106 | location: Some("Everywhere".to_string()), |
| 108 | social_github: Some("prof".to_string()), | 107 | links: vec![ |
| 109 | social_mastodon: Some("@prof@example.social".to_string()), | 108 | "https://example.com".to_string(), |
| 109 | "https://github.com/prof".to_string(), | ||
| 110 | ], | ||
| 110 | }, | 111 | }, |
| 111 | ) | 112 | ) |
| 112 | .await | 113 | .await |
| @@ -116,7 +117,8 @@ async fn update_profile_and_avatar_round_trip() { | |||
| 116 | let got = fx.store.user_by_id(&user.id).await.unwrap().unwrap(); | 117 | let got = fx.store.user_by_id(&user.id).await.unwrap().unwrap(); |
| 117 | assert_eq!(got.pronouns.as_deref(), Some("they/them")); | 118 | assert_eq!(got.pronouns.as_deref(), Some("they/them")); |
| 118 | assert_eq!(got.bio.as_deref(), Some("Hello there.")); | 119 | assert_eq!(got.bio.as_deref(), Some("Hello there.")); |
| 119 | assert_eq!(got.social_mastodon.as_deref(), Some("@prof@example.social")); | 120 | assert_eq!(got.links.len(), 2, "two links stored"); |
| 121 | assert_eq!(got.links[1], "https://github.com/prof"); | ||
| 120 | assert!(got.avatar_mime.is_none(), "no avatar yet"); | 122 | assert!(got.avatar_mime.is_none(), "no avatar yet"); |
| 121 | 123 | ||
| 122 | // Setting then clearing the avatar content type round-trips. | 124 | // Setting then clearing the avatar content type round-trips. |
crates/store/src/users.rs +34 −18
| @@ -13,7 +13,7 @@ use crate::{Store, StoreError, map_conflict, new_id, now_ms}; | |||
| 13 | /// The columns of `users` in a fixed order, shared by every `SELECT` so the | 13 | /// The columns of `users` in a fixed order, shared by every `SELECT` so the |
| 14 | /// row-mapping in [`Store::map_user`] can rely on names. | 14 | /// row-mapping in [`Store::map_user`] can rely on names. |
| 15 | pub(crate) const USER_COLUMNS: &str = "id, username, email, display_name, pronouns, bio, \ | 15 | pub(crate) const USER_COLUMNS: &str = "id, username, email, display_name, pronouns, bio, \ |
| 16 | website, location, social_github, social_mastodon, avatar_mime, password_hash, \ | 16 | location, links, avatar_mime, password_hash, \ |
| 17 | must_change_password, is_admin, disabled_at, created_at, updated_at"; | 17 | must_change_password, is_admin, disabled_at, created_at, updated_at"; |
| 18 | 18 | ||
| 19 | /// The editable profile fields, set together by the settings page. Each `None` | 19 | /// The editable profile fields, set together by the settings page. Each `None` |
| @@ -26,14 +26,36 @@ pub struct ProfileUpdate { | |||
| 26 | pub pronouns: Option<String>, | 26 | pub pronouns: Option<String>, |
| 27 | /// Free-text bio. | 27 | /// Free-text bio. |
| 28 | pub bio: Option<String>, | 28 | pub bio: Option<String>, |
| 29 | /// Website URL. | ||
| 30 | pub website: Option<String>, | ||
| 31 | /// Location. | 29 | /// Location. |
| 32 | pub location: Option<String>, | 30 | pub location: Option<String>, |
| 33 | /// `GitHub` handle. | 31 | /// Up to five arbitrary profile links. |
| 34 | pub social_github: Option<String>, | 32 | pub links: Vec<String>, |
| 35 | /// Mastodon handle. | 33 | } |
| 36 | pub social_mastodon: Option<String>, | 34 | |
| 35 | /// Encode profile links for storage: trimmed, non-empty, capped at five, joined | ||
| 36 | /// by newlines, or `None` when the list is empty. | ||
| 37 | fn encode_links(links: &[String]) -> Option<String> { | ||
| 38 | let joined = links | ||
| 39 | .iter() | ||
| 40 | .map(|s| s.trim()) | ||
| 41 | .filter(|s| !s.is_empty()) | ||
| 42 | .take(5) | ||
| 43 | .collect::<Vec<_>>() | ||
| 44 | .join("\n"); | ||
| 45 | (!joined.is_empty()).then_some(joined) | ||
| 46 | } | ||
| 47 | |||
| 48 | /// Decode the stored newline-separated links column into a bounded list. | ||
| 49 | fn decode_links(raw: Option<&str>) -> Vec<String> { | ||
| 50 | raw.map(|s| { | ||
| 51 | s.lines() | ||
| 52 | .map(str::trim) | ||
| 53 | .filter(|l| !l.is_empty()) | ||
| 54 | .take(5) | ||
| 55 | .map(str::to_string) | ||
| 56 | .collect() | ||
| 57 | }) | ||
| 58 | .unwrap_or_default() | ||
| 37 | } | 59 | } |
| 38 | 60 | ||
| 39 | /// Fields needed to create a user. The server fills in the id, timestamps, and | 61 | /// Fields needed to create a user. The server fills in the id, timestamps, and |
| @@ -75,10 +97,8 @@ impl Store { | |||
| 75 | display_name: new.display_name, | 97 | display_name: new.display_name, |
| 76 | pronouns: None, | 98 | pronouns: None, |
| 77 | bio: None, | 99 | bio: None, |
| 78 | website: None, | ||
| 79 | location: None, | 100 | location: None, |
| 80 | social_github: None, | 101 | links: Vec::new(), |
| 81 | social_mastodon: None, | ||
| 82 | avatar_mime: None, | 102 | avatar_mime: None, |
| 83 | password_hash: new.password_hash, | 103 | password_hash: new.password_hash, |
| 84 | must_change_password: new.must_change_password, | 104 | must_change_password: new.must_change_password, |
| @@ -260,16 +280,14 @@ impl Store { | |||
| 260 | user_id: &str, | 280 | user_id: &str, |
| 261 | profile: ProfileUpdate, | 281 | profile: ProfileUpdate, |
| 262 | ) -> Result<bool, StoreError> { | 282 | ) -> Result<bool, StoreError> { |
| 263 | let sql = "UPDATE users SET display_name = $1, pronouns = $2, bio = $3, website = $4, \ | 283 | let sql = "UPDATE users SET display_name = $1, pronouns = $2, bio = $3, \ |
| 264 | location = $5, social_github = $6, social_mastodon = $7, updated_at = $8 WHERE id = $9"; | 284 | location = $4, links = $5, updated_at = $6 WHERE id = $7"; |
| 265 | let updated = sqlx::query(sql) | 285 | let updated = sqlx::query(sql) |
| 266 | .bind(profile.display_name) | 286 | .bind(profile.display_name) |
| 267 | .bind(profile.pronouns) | 287 | .bind(profile.pronouns) |
| 268 | .bind(profile.bio) | 288 | .bind(profile.bio) |
| 269 | .bind(profile.website) | ||
| 270 | .bind(profile.location) | 289 | .bind(profile.location) |
| 271 | .bind(profile.social_github) | 290 | .bind(encode_links(&profile.links)) |
| 272 | .bind(profile.social_mastodon) | ||
| 273 | .bind(now_ms()) | 291 | .bind(now_ms()) |
| 274 | .bind(user_id) | 292 | .bind(user_id) |
| 275 | .execute(&self.pool) | 293 | .execute(&self.pool) |
| @@ -311,10 +329,8 @@ impl Store { | |||
| 311 | display_name: row.try_get("display_name")?, | 329 | display_name: row.try_get("display_name")?, |
| 312 | pronouns: row.try_get("pronouns")?, | 330 | pronouns: row.try_get("pronouns")?, |
| 313 | bio: row.try_get("bio")?, | 331 | bio: row.try_get("bio")?, |
| 314 | website: row.try_get("website")?, | ||
| 315 | location: row.try_get("location")?, | 332 | location: row.try_get("location")?, |
| 316 | social_github: row.try_get("social_github")?, | 333 | links: decode_links(row.try_get::<Option<String>, _>("links")?.as_deref()), |
| 317 | social_mastodon: row.try_get("social_mastodon")?, | ||
| 318 | avatar_mime: row.try_get("avatar_mime")?, | 334 | avatar_mime: row.try_get("avatar_mime")?, |
| 319 | password_hash: row.try_get("password_hash")?, | 335 | password_hash: row.try_get("password_hash")?, |
| 320 | must_change_password: self.get_bool(row, "must_change_password")?, | 336 | must_change_password: self.get_bool(row, "must_change_password")?, |
crates/web/src/pages.rs +33 −19
| @@ -529,23 +529,29 @@ pub struct SettingsForm { | |||
| 529 | /// Free-text bio. | 529 | /// Free-text bio. |
| 530 | #[serde(default)] | 530 | #[serde(default)] |
| 531 | bio: String, | 531 | bio: String, |
| 532 | /// Website URL. | ||
| 533 | #[serde(default)] | ||
| 534 | website: String, | ||
| 535 | /// Location. | 532 | /// Location. |
| 536 | #[serde(default)] | 533 | #[serde(default)] |
| 537 | location: String, | 534 | location: String, |
| 538 | /// `GitHub` handle. | 535 | /// Up to five arbitrary profile links (`serde_urlencoded` has no sequence |
| 536 | /// support, so each slot is its own field). | ||
| 537 | #[serde(default)] | ||
| 538 | link1: String, | ||
| 539 | #[serde(default)] | ||
| 540 | link2: String, | ||
| 539 | #[serde(default)] | 541 | #[serde(default)] |
| 540 | social_github: String, | 542 | link3: String, |
| 541 | /// Mastodon handle. | ||
| 542 | #[serde(default)] | 543 | #[serde(default)] |
| 543 | social_mastodon: String, | 544 | link4: String, |
| 545 | #[serde(default)] | ||
| 546 | link5: String, | ||
| 544 | /// CSRF token (`_csrf`). | 547 | /// CSRF token (`_csrf`). |
| 545 | #[serde(rename = "_csrf")] | 548 | #[serde(rename = "_csrf")] |
| 546 | csrf: String, | 549 | csrf: String, |
| 547 | } | 550 | } |
| 548 | 551 | ||
| 552 | /// The number of profile-link slots offered on the settings page. | ||
| 553 | const LINK_SLOTS: usize = 5; | ||
| 554 | |||
| 549 | /// `POST /settings` — persist the viewer's profile fields. | 555 | /// `POST /settings` — persist the viewer's profile fields. |
| 550 | pub async fn settings_submit( | 556 | pub async fn settings_submit( |
| 551 | State(state): State<AppState>, | 557 | State(state): State<AppState>, |
| @@ -557,14 +563,24 @@ pub async fn settings_submit( | |||
| 557 | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | 563 | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { |
| 558 | return AppError::Forbidden.into_response(); | 564 | return AppError::Forbidden.into_response(); |
| 559 | } | 565 | } |
| 566 | let links: Vec<String> = [ | ||
| 567 | &form.link1, | ||
| 568 | &form.link2, | ||
| 569 | &form.link3, | ||
| 570 | &form.link4, | ||
| 571 | &form.link5, | ||
| 572 | ] | ||
| 573 | .iter() | ||
| 574 | .map(|s| s.trim().to_string()) | ||
| 575 | .filter(|s| !s.is_empty()) | ||
| 576 | .take(LINK_SLOTS) | ||
| 577 | .collect(); | ||
| 560 | let update = store::ProfileUpdate { | 578 | let update = store::ProfileUpdate { |
| 561 | display_name: norm(&form.display_name), | 579 | display_name: norm(&form.display_name), |
| 562 | pronouns: norm(&form.pronouns), | 580 | pronouns: norm(&form.pronouns), |
| 563 | bio: norm(&form.bio), | 581 | bio: norm(&form.bio), |
| 564 | website: norm(&form.website), | ||
| 565 | location: norm(&form.location), | 582 | location: norm(&form.location), |
| 566 | social_github: norm(&form.social_github).map(|s| s.trim_start_matches('@').to_string()), | 583 | links, |
| 567 | social_mastodon: norm(&form.social_mastodon), | ||
| 568 | }; | 584 | }; |
| 569 | match state.store.update_profile(&user.id, update).await { | 585 | match state.store.update_profile(&user.id, update).await { |
| 570 | Ok(_) => Redirect::to("/settings").into_response(), | 586 | Ok(_) => Redirect::to("/settings").into_response(), |
| @@ -613,15 +629,13 @@ fn settings_body(user: &User, csrf: &str, notice: Option<&str>) -> Markup { | |||
| 613 | textarea id="bio" name="bio" rows="3" { (val(&user.bio)) } | 629 | textarea id="bio" name="bio" rows="3" { (val(&user.bio)) } |
| 614 | label for="location" { "Location" } | 630 | label for="location" { "Location" } |
| 615 | input type="text" id="location" name="location" value=(val(&user.location)); | 631 | input type="text" id="location" name="location" value=(val(&user.location)); |
| 616 | label for="website" { "Website" } | 632 | label for="link1" { "Links" } |
| 617 | input type="text" id="website" name="website" | 633 | p class="muted field-hint" { "Up to five links (website, GitHub, Mastodon, …); icons are chosen from the address." } |
| 618 | value=(val(&user.website)) placeholder="https://example.com"; | 634 | @for i in 0..LINK_SLOTS { |
| 619 | label for="social_github" { "GitHub" } | 635 | input type="url" id=(format!("link{}", i + 1)) name=(format!("link{}", i + 1)) |
| 620 | input type="text" id="social_github" name="social_github" | 636 | class="link-slot" value=(user.links.get(i).map_or("", String::as_str)) |
| 621 | value=(val(&user.social_github)) placeholder="username"; | 637 | placeholder="https://example.com"; |
| 622 | label for="social_mastodon" { "Mastodon" } | 638 | } |
| 623 | input type="text" id="social_mastodon" name="social_mastodon" | ||
| 624 | value=(val(&user.social_mastodon)) placeholder="@user@instance"; | ||
| 625 | button class="btn btn-primary" type="submit" { "Save profile" } | 639 | button class="btn btn-primary" type="submit" { "Save profile" } |
| 626 | } | 640 | } |
| 627 | } | 641 | } |
crates/web/src/repo.rs +35 −35
| @@ -531,7 +531,7 @@ fn render_blob( | |||
| 531 | p { a class="btn" href=(raw_url) { "Download" } } | 531 | p { a class="btn" href=(raw_url) { "Download" } } |
| 532 | } | 532 | } |
| 533 | } @else if show_preview { | 533 | } @else if show_preview { |
| 534 | div class="card markdown" { | 534 | div class="blob-preview markdown" { |
| 535 | (markdown::render(&String::from_utf8_lossy(&blob.content))) | 535 | (markdown::render(&String::from_utf8_lossy(&blob.content))) |
| 536 | } | 536 | } |
| 537 | } @else { | 537 | } @else { |
| @@ -1250,56 +1250,56 @@ fn non_empty(field: Option<&String>) -> Option<&str> { | |||
| 1250 | field.map(|s| s.trim()).filter(|s| !s.is_empty()) | 1250 | field.map(|s| s.trim()).filter(|s| !s.is_empty()) |
| 1251 | } | 1251 | } |
| 1252 | 1252 | ||
| 1253 | /// The profile's location and social links, each an icon-prefixed row. | 1253 | /// The profile's location and its arbitrary links, each an icon-prefixed row. |
| 1254 | /// The brand icon is inferred from the link's host. | ||
| 1254 | fn profile_links(owner: &User) -> Vec<Markup> { | 1255 | fn profile_links(owner: &User) -> Vec<Markup> { |
| 1255 | let mut out = Vec::new(); | 1256 | let mut out = Vec::new(); |
| 1256 | if let Some(location) = non_empty(owner.location.as_ref()) { | 1257 | if let Some(location) = non_empty(owner.location.as_ref()) { |
| 1257 | out.push(html! { li { (icon(Icon::MapPin)) span { (location) } } }); | 1258 | out.push(html! { li { (icon(Icon::MapPin)) span { (location) } } }); |
| 1258 | } | 1259 | } |
| 1259 | if let Some(site) = non_empty(owner.website.as_ref()) { | 1260 | for link in &owner.links { |
| 1260 | let href = if site.starts_with("http://") || site.starts_with("https://") { | 1261 | let href = normalize_url(link); |
| 1261 | site.to_string() | 1262 | let shown = href |
| 1262 | } else { | ||
| 1263 | format!("https://{site}") | ||
| 1264 | }; | ||
| 1265 | let shown = site | ||
| 1266 | .trim_start_matches("https://") | 1263 | .trim_start_matches("https://") |
| 1267 | .trim_start_matches("http://"); | 1264 | .trim_start_matches("http://") |
| 1268 | out.push(html! { | 1265 | .trim_end_matches('/'); |
| 1269 | li { a href=(href) rel="nofollow noopener" { (icon(Icon::Globe)) span { (shown) } } } | ||
| 1270 | }); | ||
| 1271 | } | ||
| 1272 | if let Some(handle) = non_empty(owner.social_github.as_ref()) { | ||
| 1273 | let handle = handle.trim_start_matches('@'); | ||
| 1274 | out.push(html! { | 1266 | out.push(html! { |
| 1275 | li { | 1267 | li { |
| 1276 | a href=(format!("https://github.com/{handle}")) rel="nofollow noopener" { | 1268 | a href=(href) rel="nofollow noopener me" { |
| 1277 | (icon(Icon::Github)) span { (handle) } | 1269 | (icon(link_icon(&href))) span { (shown) } |
| 1278 | } | 1270 | } |
| 1279 | } | 1271 | } |
| 1280 | }); | 1272 | }); |
| 1281 | } | 1273 | } |
| 1282 | if let Some(handle) = non_empty(owner.social_mastodon.as_ref()) { | ||
| 1283 | out.push(html! { li { (mastodon_link(handle)) } }); | ||
| 1284 | } | ||
| 1285 | out | 1274 | out |
| 1286 | } | 1275 | } |
| 1287 | 1276 | ||
| 1288 | /// Render a Mastodon `@user@instance` handle as a link when well-formed, else as | 1277 | /// Prefix a bare URL with `https://` when it has no scheme. |
| 1289 | /// plain text. | 1278 | fn normalize_url(url: &str) -> String { |
| 1290 | fn mastodon_link(handle: &str) -> Markup { | 1279 | let url = url.trim(); |
| 1291 | let trimmed = handle.trim_start_matches('@'); | 1280 | if url.starts_with("http://") || url.starts_with("https://") { |
| 1292 | if let Some((user, instance)) = trimmed.split_once('@') | 1281 | url.to_string() |
| 1293 | && !user.is_empty() | 1282 | } else { |
| 1294 | && !instance.is_empty() | 1283 | format!("https://{url}") |
| 1295 | { | 1284 | } |
| 1296 | return html! { | 1285 | } |
| 1297 | a href=(format!("https://{instance}/@{user}")) rel="nofollow noopener me" { | 1286 | |
| 1298 | (icon(Icon::Mastodon)) span { "@" (user) "@" (instance) } | 1287 | /// Choose a brand icon for a link from its host. |
| 1299 | } | 1288 | fn link_icon(url: &str) -> Icon { |
| 1300 | }; | 1289 | let host = url |
| 1290 | .trim_start_matches("https://") | ||
| 1291 | .trim_start_matches("http://") | ||
| 1292 | .split('/') | ||
| 1293 | .next() | ||
| 1294 | .unwrap_or("") | ||
| 1295 | .to_ascii_lowercase(); | ||
| 1296 | if host == "github.com" || host.ends_with(".github.com") { | ||
| 1297 | Icon::Github | ||
| 1298 | } else if host.contains("mastodon") { | ||
| 1299 | Icon::Mastodon | ||
| 1300 | } else { | ||
| 1301 | Icon::Globe | ||
| 1301 | } | 1302 | } |
| 1302 | html! { span { (icon(Icon::Mastodon)) span { (handle) } } } | ||
| 1303 | } | 1303 | } |
| 1304 | 1304 | ||
| 1305 | /// The repo sub-header: slug, description, and a tab strip sharing its line with | 1305 | /// The repo sub-header: slug, description, and a tab strip sharing its line with |
migrations/postgres/0003_user_links.sql +4 −0
| @@ -0,0 +1,4 @@ | |||
| 1 | -- Replace the fixed website/social handle fields with a free list of up to five | ||
| 2 | -- arbitrary profile links, stored newline-separated. The older social_* and | ||
| 3 | -- website columns are left in place but are no longer read or written. | ||
| 4 | ALTER TABLE users ADD COLUMN links TEXT; | ||
migrations/sqlite/0003_user_links.sql +5 −0
| @@ -0,0 +1,5 @@ | |||
| 1 | -- Replace the fixed website/social handle fields with a free list of up to five | ||
| 2 | -- arbitrary profile links, stored newline-separated. The older social_* and | ||
| 3 | -- website columns are left in place (SQLite cannot easily drop columns) but are | ||
| 4 | -- no longer read or written. | ||
| 5 | ALTER TABLE users ADD COLUMN links TEXT; | ||