RFC 3339 timestamps in identity and repos
Times are stored and returned as UTC text such as 2026-10-02T05:16:19.000Z instead of Unix integers. Both databases are migrated; the work and events services follow when they are ported.
14 files+248−620/14 viewed
| 167 | 167 | ]; | |
| 168 | 168 | ||
| 169 | 169 | /** Compact relative time such as "5m ago". */ | |
| 170 | − | export function TimeAgo({ at }: { at: number }) { | |
| 171 | − | const elapsed = Date.now() - at; | |
| 170 | + | export function TimeAgo({ at }: { at: string | number }) { | |
| 171 | + | // Timestamps are RFC 3339 strings; a few older ones are still numbers. | |
| 172 | + | const elapsed = Date.now() - new Date(at).getTime(); | |
| 172 | 173 | const unit = UNITS.find(([, size]) => elapsed >= size); | |
| 173 | 174 | const label = unit ? `${Math.floor(elapsed / unit[1])}${unit[0]} ago` : "just now"; | |
| 174 | 175 | return ( |
| 13 | 13 | pub id: String, | |
| 14 | 14 | pub title: String, | |
| 15 | 15 | pub fingerprint: String, | |
| 16 | − | /// Milliseconds since the epoch. | |
| 17 | − | pub created_at: u64, | |
| 16 | + | /// RFC 3339. | |
| 17 | + | pub created_at: String, | |
| 18 | 18 | } | |
| 19 | 19 | ||
| 20 | 20 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 22 | 22 | pub struct AccessToken { | |
| 23 | 23 | pub id: String, | |
| 24 | 24 | pub name: String, | |
| 25 | − | pub created_at: u64, | |
| 25 | + | /// RFC 3339. | |
| 26 | + | pub created_at: String, | |
| 26 | 27 | } | |
| 27 | 28 | ||
| 28 | 29 | /// `sign_in`: verifies a username and password for website sign-in. |
| 10 | 10 | mod names; | |
| 11 | 11 | mod outcome; | |
| 12 | 12 | pub mod repos; | |
| 13 | + | pub mod time; | |
| 13 | 14 | ||
| 14 | 15 | pub use ids::new_id; | |
| 15 | 16 | pub use names::{is_valid_namespace, is_valid_repo_name}; |
| 20 | 20 | pub default_branch: String, | |
| 21 | 21 | /// Set when this repo is an attempt's working copy of another repo. | |
| 22 | 22 | pub fork_of: Option<String>, | |
| 23 | − | /// Milliseconds since the epoch. | |
| 24 | − | pub created_at: u64, | |
| 23 | + | /// RFC 3339. | |
| 24 | + | pub created_at: String, | |
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | 27 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 44 | 44 | pub message: String, | |
| 45 | 45 | pub author: Signature, | |
| 46 | 46 | pub parents: Vec<String>, | |
| 47 | − | /// Milliseconds since the epoch. | |
| 48 | − | pub authored_at: u64, | |
| 47 | + | /// RFC 3339. | |
| 48 | + | pub authored_at: String, | |
| 49 | 49 | } | |
| 50 | 50 | ||
| 51 | 51 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 1 | + | //! Timestamps. Everywhere in g1t, in storage and on the wire, a time is an | |
| 2 | + | //! RFC 3339 string in UTC with millisecond precision, such as | |
| 3 | + | //! `2026-10-02T05:16:19.000Z`. Strings in this one fixed format sort and | |
| 4 | + | //! compare as text, so they need no parsing to be ordered. | |
| 5 | + | ||
| 6 | + | /// SQLite's expression for the current time in g1t's format. | |
| 7 | + | pub const SQL_NOW: &str = "strftime('%Y-%m-%dT%H:%M:%fZ', 'now')"; | |
| 8 | + | ||
| 9 | + | /// SQLite's expression for a time `seconds` from now, in g1t's format. | |
| 10 | + | pub fn sql_after(seconds: u64) -> String { | |
| 11 | + | format!("strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '+{seconds} seconds')") | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | /// Milliseconds since the Unix epoch as an RFC 3339 UTC timestamp. | |
| 15 | + | pub fn rfc3339(ms: u64) -> String { | |
| 16 | + | let (seconds, millis) = (ms / 1000, ms % 1000); | |
| 17 | + | let (days, rest) = (seconds / 86_400, seconds % 86_400); | |
| 18 | + | let (hour, minute, second) = (rest / 3600, rest % 3600 / 60, rest % 60); | |
| 19 | + | ||
| 20 | + | // Days since the epoch to a calendar date, after Howard Hinnant's | |
| 21 | + | // civil_from_days. The era arithmetic starts years in March. | |
| 22 | + | let z = days as i64 + 719_468; | |
| 23 | + | let era = z.div_euclid(146_097); | |
| 24 | + | let day_of_era = z.rem_euclid(146_097); | |
| 25 | + | let year_of_era = | |
| 26 | + | (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; | |
| 27 | + | let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); | |
| 28 | + | let shifted_month = (5 * day_of_year + 2) / 153; | |
| 29 | + | let day = day_of_year - (153 * shifted_month + 2) / 5 + 1; | |
| 30 | + | let month = if shifted_month < 10 { | |
| 31 | + | shifted_month + 3 | |
| 32 | + | } else { | |
| 33 | + | shifted_month - 9 | |
| 34 | + | }; | |
| 35 | + | let year = year_of_era + era * 400 + i64::from(month <= 2); | |
| 36 | + | ||
| 37 | + | format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{millis:03}Z") | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | #[cfg(test)] | |
| 41 | + | mod tests { | |
| 42 | + | use super::*; | |
| 43 | + | ||
| 44 | + | #[test] | |
| 45 | + | fn formats_known_instants() { | |
| 46 | + | assert_eq!(rfc3339(0), "1970-01-01T00:00:00.000Z"); | |
| 47 | + | assert_eq!(rfc3339(951_782_400_000), "2000-02-29T00:00:00.000Z"); | |
| 48 | + | assert_eq!(rfc3339(1_790_918_179_123), "2026-10-02T05:16:19.123Z"); | |
| 49 | + | assert_eq!(rfc3339(4_102_444_799_999), "2099-12-31T23:59:59.999Z"); | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | #[test] | |
| 53 | + | fn later_times_sort_later_as_text() { | |
| 54 | + | let times: Vec<String> = [0, 999, 1000, 86_399_999, 86_400_000, 1_790_918_179_123] | |
| 55 | + | .into_iter() | |
| 56 | + | .map(rfc3339) | |
| 57 | + | .collect(); | |
| 58 | + | assert!(times.windows(2).all(|pair| pair[0] < pair[1])); | |
| 59 | + | } | |
| 60 | + | } |
| 17 | 17 | id: string; | |
| 18 | 18 | title: string; | |
| 19 | 19 | fingerprint: string; | |
| 20 | − | createdAt: number; | |
| 20 | + | /** RFC 3339. */ | |
| 21 | + | createdAt: string; | |
| 21 | 22 | }; | |
| 22 | 23 | ||
| 23 | − | export type AccessToken = { id: string; name: string; createdAt: number }; | |
| 24 | + | export type AccessToken = { id: string; name: string; createdAt: string }; | |
| 24 | 25 | ||
| 25 | 26 | export type DeviceStart = { | |
| 26 | 27 | /** Secret held by the tool and exchanged for a token once approved. */ |
| 12 | 12 | defaultBranch: string; | |
| 13 | 13 | /** Set when this repo is an attempt's working copy of another repo. */ | |
| 14 | 14 | forkOf: string | null; | |
| 15 | − | createdAt: number; | |
| 15 | + | /** RFC 3339. */ | |
| 16 | + | createdAt: string; | |
| 16 | 17 | }; | |
| 17 | 18 | ||
| 18 | 19 | export type RepoPath = { namespace: string; name: string }; | |
| 23 | 24 | message: string; | |
| 24 | 25 | author: { name: string; email: string }; | |
| 25 | 26 | parents: string[]; | |
| 26 | − | authoredAt: number; | |
| 27 | + | /** RFC 3339. */ | |
| 28 | + | authoredAt: string; | |
| 27 | 29 | }; | |
| 28 | 30 | ||
| 29 | 31 | export type TreeEntry = { |
| 1 | + | -- Every timestamp becomes RFC 3339 UTC text (2026-10-02T05:16:19.000Z) | |
| 2 | + | -- instead of Unix seconds. SQLite cannot change a column's type, so each | |
| 3 | + | -- table is rebuilt and its rows converted. | |
| 4 | + | PRAGMA defer_foreign_keys = on; | |
| 5 | + | ||
| 6 | + | CREATE TABLE users_new ( | |
| 7 | + | id TEXT PRIMARY KEY, | |
| 8 | + | username TEXT NOT NULL UNIQUE, | |
| 9 | + | email TEXT, | |
| 10 | + | password_hash TEXT NOT NULL, | |
| 11 | + | email_verified_at TEXT, | |
| 12 | + | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) | |
| 13 | + | ); | |
| 14 | + | INSERT INTO users_new (id, username, email, password_hash, email_verified_at, created_at) | |
| 15 | + | SELECT id, username, email, password_hash, | |
| 16 | + | strftime('%Y-%m-%dT%H:%M:%fZ', email_verified_at, 'unixepoch'), | |
| 17 | + | strftime('%Y-%m-%dT%H:%M:%fZ', created_at, 'unixepoch') | |
| 18 | + | FROM users; | |
| 19 | + | ||
| 20 | + | -- id is the SHA-256 of the session token. | |
| 21 | + | CREATE TABLE sessions_new ( | |
| 22 | + | id TEXT PRIMARY KEY, | |
| 23 | + | user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, | |
| 24 | + | expires_at TEXT NOT NULL | |
| 25 | + | ); | |
| 26 | + | INSERT INTO sessions_new (id, user_id, expires_at) | |
| 27 | + | SELECT id, user_id, strftime('%Y-%m-%dT%H:%M:%fZ', expires_at, 'unixepoch') FROM sessions; | |
| 28 | + | ||
| 29 | + | CREATE TABLE access_tokens_new ( | |
| 30 | + | id TEXT PRIMARY KEY, | |
| 31 | + | user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, | |
| 32 | + | name TEXT NOT NULL, | |
| 33 | + | token_hash TEXT NOT NULL UNIQUE, | |
| 34 | + | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), | |
| 35 | + | -- Null means the token does not expire. | |
| 36 | + | expires_at TEXT | |
| 37 | + | ); | |
| 38 | + | INSERT INTO access_tokens_new (id, user_id, name, token_hash, created_at, expires_at) | |
| 39 | + | SELECT id, user_id, name, token_hash, | |
| 40 | + | strftime('%Y-%m-%dT%H:%M:%fZ', created_at, 'unixepoch'), | |
| 41 | + | strftime('%Y-%m-%dT%H:%M:%fZ', expires_at, 'unixepoch') | |
| 42 | + | FROM access_tokens; | |
| 43 | + | ||
| 44 | + | CREATE TABLE ssh_keys_new ( | |
| 45 | + | id TEXT PRIMARY KEY, | |
| 46 | + | user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, | |
| 47 | + | title TEXT NOT NULL, | |
| 48 | + | public_key TEXT NOT NULL, | |
| 49 | + | fingerprint TEXT NOT NULL UNIQUE, | |
| 50 | + | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) | |
| 51 | + | ); | |
| 52 | + | INSERT INTO ssh_keys_new (id, user_id, title, public_key, fingerprint, created_at) | |
| 53 | + | SELECT id, user_id, title, public_key, fingerprint, | |
| 54 | + | strftime('%Y-%m-%dT%H:%M:%fZ', created_at, 'unixepoch') | |
| 55 | + | FROM ssh_keys; | |
| 56 | + | ||
| 57 | + | -- One-time links sent by email. id is the SHA-256 of the token in the link. | |
| 58 | + | CREATE TABLE email_tokens_new ( | |
| 59 | + | id TEXT PRIMARY KEY, | |
| 60 | + | user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, | |
| 61 | + | -- 'verify' or 'reset' | |
| 62 | + | kind TEXT NOT NULL, | |
| 63 | + | expires_at TEXT NOT NULL | |
| 64 | + | ); | |
| 65 | + | INSERT INTO email_tokens_new (id, user_id, kind, expires_at) | |
| 66 | + | SELECT id, user_id, kind, strftime('%Y-%m-%dT%H:%M:%fZ', expires_at, 'unixepoch') | |
| 67 | + | FROM email_tokens; | |
| 68 | + | ||
| 69 | + | DROP TABLE sessions; | |
| 70 | + | DROP TABLE access_tokens; | |
| 71 | + | DROP TABLE ssh_keys; | |
| 72 | + | DROP TABLE email_tokens; | |
| 73 | + | DROP TABLE users; | |
| 74 | + | ||
| 75 | + | ALTER TABLE users_new RENAME TO users; | |
| 76 | + | ALTER TABLE sessions_new RENAME TO sessions; | |
| 77 | + | ALTER TABLE access_tokens_new RENAME TO access_tokens; | |
| 78 | + | ALTER TABLE ssh_keys_new RENAME TO ssh_keys; | |
| 79 | + | ALTER TABLE email_tokens_new RENAME TO email_tokens; | |
| 80 | + | ||
| 81 | + | CREATE UNIQUE INDEX users_email ON users (email) WHERE email IS NOT NULL; | |
| 82 | + | CREATE INDEX access_tokens_user ON access_tokens (user_id); | |
| 83 | + | CREATE INDEX ssh_keys_user ON ssh_keys (user_id); | |
| 84 | + | CREATE INDEX email_tokens_user ON email_tokens (user_id, kind); |
| 7 | 7 | //! creation and passwords stay in the browser, where they can be protected. | |
| 8 | 8 | ||
| 9 | 9 | use g1t_contracts::identity::*; | |
| 10 | + | use g1t_contracts::time::{SQL_NOW, sql_after}; | |
| 10 | 11 | use g1t_contracts::{FailureCode, Outcome, User}; | |
| 11 | 12 | use serde::Deserialize; | |
| 12 | 13 | use worker::Result; | |
| 13 | 14 | ||
| 14 | 15 | use crate::{Identity, crypto}; | |
| 15 | 16 | ||
| 16 | − | const EXPIRES_IN_SECONDS: u32 = 15 * 60; | |
| 17 | + | const EXPIRES_IN_SECONDS: u64 = 15 * 60; | |
| 17 | 18 | const POLL_INTERVAL_SECONDS: u32 = 5; | |
| 18 | 19 | /// No vowels, so a code never spells a word, and nothing easily confused. | |
| 19 | 20 | const USER_CODE_ALPHABET: &[u8] = b"BCDFGHJKLMNPQRSTVWXZ"; | |
| 20 | − | /// SQLite's expression for the current time as RFC 3339 UTC text. | |
| 21 | − | const NOW: &str = "strftime('%Y-%m-%dT%H:%M:%fZ', 'now')"; | |
| 22 | 21 | ||
| 23 | 22 | #[derive(Deserialize)] | |
| 24 | 23 | struct DeviceRow { | |
| 63 | 62 | self.db | |
| 64 | 63 | .prepare(format!( | |
| 65 | 64 | "INSERT INTO device_codes (id, user_code, client_name, expires_at) | |
| 66 | − | VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '+{EXPIRES_IN_SECONDS} seconds'))" | |
| 65 | + | VALUES (?, ?, ?, {})", | |
| 66 | + | sql_after(EXPIRES_IN_SECONDS) | |
| 67 | 67 | )) | |
| 68 | 68 | .bind(&[ | |
| 69 | 69 | crypto::sha256_hex(&device_code).into(), | |
| 75 | 75 | Ok(DeviceStart { | |
| 76 | 76 | device_code, | |
| 77 | 77 | user_code, | |
| 78 | − | expires_in: EXPIRES_IN_SECONDS, | |
| 78 | + | expires_in: EXPIRES_IN_SECONDS as u32, | |
| 79 | 79 | interval: POLL_INTERVAL_SECONDS, | |
| 80 | 80 | }) | |
| 81 | 81 | } | |
| 85 | 85 | self.db | |
| 86 | 86 | .prepare(format!( | |
| 87 | 87 | "SELECT user_code, client_name, status, user_id FROM device_codes | |
| 88 | − | WHERE user_code = ? AND status = 'pending' AND expires_at > {NOW}" | |
| 88 | + | WHERE user_code = ? AND status = 'pending' AND expires_at > {SQL_NOW}" | |
| 89 | 89 | )) | |
| 90 | 90 | .bind(&[normalize(user_code).into()])? | |
| 91 | 91 | .first::<DeviceRow>(None) | |
| 127 | 127 | .db | |
| 128 | 128 | .prepare(format!( | |
| 129 | 129 | "SELECT user_code, client_name, status, user_id FROM device_codes | |
| 130 | − | WHERE id = ? AND expires_at > {NOW}" | |
| 130 | + | WHERE id = ? AND expires_at > {SQL_NOW}" | |
| 131 | 131 | )) | |
| 132 | 132 | .bind(&[id.as_str().into()])? | |
| 133 | 133 | .first::<DeviceRow>(None) |
| 8 | 8 | mod email; | |
| 9 | 9 | ||
| 10 | 10 | use g1t_contracts::identity::*; | |
| 11 | + | use g1t_contracts::time::{SQL_NOW, rfc3339, sql_after}; | |
| 11 | 12 | use g1t_contracts::{FailureCode, Outcome, User, Viewer, is_valid_namespace, new_id}; | |
| 12 | 13 | use g1t_kit::{args, now_ms, reply, rpc_method}; | |
| 13 | 14 | use serde::Deserialize; | |
| 14 | 15 | use worker::wasm_bindgen::JsValue; | |
| 15 | 16 | use worker::{Context, D1Database, Env, Request, Response, Result, event}; | |
| 16 | 17 | ||
| 17 | − | const SESSION_TTL_SECONDS: u32 = 30 * 24 * 60 * 60; | |
| 18 | − | const VERIFY_TTL_SECONDS: u32 = 24 * 60 * 60; | |
| 19 | − | const RESET_TTL_SECONDS: u32 = 60 * 60; | |
| 18 | + | const SESSION_TTL_SECONDS: u64 = 30 * 24 * 60 * 60; | |
| 19 | + | const VERIFY_TTL_SECONDS: u64 = 24 * 60 * 60; | |
| 20 | + | const RESET_TTL_SECONDS: u64 = 60 * 60; | |
| 20 | 21 | const TOKEN_PREFIX: &str = "g1t_"; | |
| 21 | 22 | const MIN_PASSWORD_LENGTH: usize = 10; | |
| 22 | 23 | const PASSWORD_TOO_SHORT: &str = "Use a password of at least 10 characters."; | |
| 60 | 61 | id: String, | |
| 61 | 62 | title: String, | |
| 62 | 63 | fingerprint: String, | |
| 63 | − | created_at: u64, | |
| 64 | + | created_at: String, | |
| 64 | 65 | } | |
| 65 | 66 | ||
| 66 | 67 | impl From<KeyRow> for SshKey { | |
| 69 | 70 | id: row.id, | |
| 70 | 71 | title: row.title, | |
| 71 | 72 | fingerprint: row.fingerprint, | |
| 72 | − | created_at: row.created_at * 1000, | |
| 73 | + | created_at: row.created_at, | |
| 73 | 74 | } | |
| 74 | 75 | } | |
| 75 | 76 | } | |
| 78 | 79 | struct TokenRow { | |
| 79 | 80 | id: String, | |
| 80 | 81 | name: String, | |
| 81 | − | created_at: u64, | |
| 82 | + | created_at: String, | |
| 82 | 83 | } | |
| 83 | 84 | ||
| 84 | 85 | impl From<TokenRow> for AccessToken { | |
| 86 | 87 | AccessToken { | |
| 87 | 88 | id: row.id, | |
| 88 | 89 | name: row.name, | |
| 89 | − | created_at: row.created_at * 1000, | |
| 90 | + | created_at: row.created_at, | |
| 90 | 91 | } | |
| 91 | 92 | } | |
| 92 | 93 | } | |
| 109 | 110 | } | |
| 110 | 111 | ||
| 111 | 112 | /// Stores a one-time token of `kind` for the user and returns it. | |
| 112 | − | async fn issue_email_token(&self, user_id: &str, kind: &str, ttl: u32) -> Result<String> { | |
| 113 | + | async fn issue_email_token(&self, user_id: &str, kind: &str, ttl: u64) -> Result<String> { | |
| 113 | 114 | let token = crypto::random_hex(32); | |
| 114 | 115 | self.db | |
| 115 | − | .prepare( | |
| 116 | + | .prepare(format!( | |
| 116 | 117 | "INSERT INTO email_tokens (id, user_id, kind, expires_at) | |
| 117 | − | VALUES (?, ?, ?, unixepoch() + ?)", | |
| 118 | − | ) | |
| 118 | + | VALUES (?, ?, ?, {})", | |
| 119 | + | sql_after(ttl) | |
| 120 | + | )) | |
| 119 | 121 | .bind(&[ | |
| 120 | 122 | crypto::sha256_hex(&token).into(), | |
| 121 | 123 | user_id.into(), | |
| 122 | 124 | kind.into(), | |
| 123 | − | ttl.into(), | |
| 124 | 125 | ])? | |
| 125 | 126 | .run() | |
| 126 | 127 | .await?; | |
| 132 | 133 | let id = crypto::sha256_hex(token); | |
| 133 | 134 | let owner = self | |
| 134 | 135 | .db | |
| 135 | − | .prepare( | |
| 136 | + | .prepare(format!( | |
| 136 | 137 | "SELECT users.id, users.username, users.email FROM email_tokens | |
| 137 | 138 | JOIN users ON users.id = email_tokens.user_id | |
| 138 | 139 | WHERE email_tokens.id = ? AND email_tokens.kind = ? | |
| 139 | − | AND email_tokens.expires_at > unixepoch()", | |
| 140 | − | ) | |
| 140 | + | AND email_tokens.expires_at > {SQL_NOW}" | |
| 141 | + | )) | |
| 141 | 142 | .bind(&[id.as_str().into(), kind.into()])? | |
| 142 | 143 | .first::<TokenOwner>(None) | |
| 143 | 144 | .await?; | |
| 189 | 190 | )); | |
| 190 | 191 | }; | |
| 191 | 192 | self.db | |
| 192 | − | .prepare("UPDATE users SET email_verified_at = unixepoch() WHERE id = ?") | |
| 193 | + | .prepare(format!( | |
| 194 | + | "UPDATE users SET email_verified_at = {SQL_NOW} WHERE id = ?" | |
| 195 | + | )) | |
| 193 | 196 | .bind(&[owner.id.as_str().into()])? | |
| 194 | 197 | .run() | |
| 195 | 198 | .await?; | |
| 234 | 237 | }; | |
| 235 | 238 | // Following an emailed link also proves the address. | |
| 236 | 239 | self.db | |
| 237 | − | .prepare( | |
| 240 | + | .prepare(format!( | |
| 238 | 241 | "UPDATE users SET password_hash = ?, | |
| 239 | − | email_verified_at = COALESCE(email_verified_at, unixepoch()) | |
| 240 | − | WHERE id = ?", | |
| 241 | − | ) | |
| 242 | + | email_verified_at = COALESCE(email_verified_at, {SQL_NOW}) | |
| 243 | + | WHERE id = ?" | |
| 244 | + | )) | |
| 242 | 245 | .bind(&[ | |
| 243 | 246 | crypto::hash_password(&a.password).into(), | |
| 244 | 247 | owner.id.as_str().into(), | |
| 340 | 343 | async fn start_session(&self, user: User) -> Result<Outcome<SignedIn>> { | |
| 341 | 344 | let session_token = crypto::random_hex(32); | |
| 342 | 345 | self.db | |
| 343 | − | .prepare( | |
| 344 | − | "INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, unixepoch() + ?)", | |
| 345 | − | ) | |
| 346 | + | .prepare(format!( | |
| 347 | + | "INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, {})", | |
| 348 | + | sql_after(SESSION_TTL_SECONDS) | |
| 349 | + | )) | |
| 346 | 350 | .bind(&[ | |
| 347 | 351 | crypto::sha256_hex(&session_token).into(), | |
| 348 | 352 | user.id.as_str().into(), | |
| 349 | − | SESSION_TTL_SECONDS.into(), | |
| 350 | 353 | ])? | |
| 351 | 354 | .run() | |
| 352 | 355 | .await?; | |
| 367 | 370 | ||
| 368 | 371 | async fn user_for_session(&self, a: SessionArgs) -> Result<Viewer> { | |
| 369 | 372 | self.find_user( | |
| 370 | − | "SELECT users.id, users.username, users.email_verified_at IS NOT NULL AS verified FROM sessions | |
| 371 | − | JOIN users ON users.id = sessions.user_id | |
| 372 | − | WHERE sessions.id = ? AND sessions.expires_at > unixepoch()", | |
| 373 | + | &format!( | |
| 374 | + | "SELECT users.id, users.username, users.email_verified_at IS NOT NULL AS verified | |
| 375 | + | FROM sessions JOIN users ON users.id = sessions.user_id | |
| 376 | + | WHERE sessions.id = ? AND sessions.expires_at > {SQL_NOW}" | |
| 377 | + | ), | |
| 373 | 378 | &crypto::sha256_hex(&a.session_token), | |
| 374 | 379 | ) | |
| 375 | 380 | .await | |
| 380 | 385 | return Ok(None); | |
| 381 | 386 | } | |
| 382 | 387 | self.find_user( | |
| 383 | − | "SELECT users.id, users.username, users.email_verified_at IS NOT NULL AS verified FROM access_tokens | |
| 384 | − | JOIN users ON users.id = access_tokens.user_id | |
| 385 | − | WHERE token_hash = ? | |
| 386 | − | AND (access_tokens.expires_at IS NULL OR access_tokens.expires_at > unixepoch())", | |
| 388 | + | &format!( | |
| 389 | + | "SELECT users.id, users.username, users.email_verified_at IS NOT NULL AS verified | |
| 390 | + | FROM access_tokens JOIN users ON users.id = access_tokens.user_id | |
| 391 | + | WHERE token_hash = ? | |
| 392 | + | AND (access_tokens.expires_at IS NULL OR access_tokens.expires_at > {SQL_NOW})" | |
| 393 | + | ), | |
| 387 | 394 | &crypto::sha256_hex(token), | |
| 388 | 395 | ) | |
| 389 | 396 | .await | |
| 456 | 463 | id: new_id("key", now), | |
| 457 | 464 | title, | |
| 458 | 465 | fingerprint: key.fingerprint, | |
| 459 | − | created_at: now / 1000, | |
| 466 | + | created_at: rfc3339(now), | |
| 460 | 467 | }; | |
| 461 | 468 | self.db | |
| 462 | 469 | .prepare( | |
| 469 | 476 | row.title.as_str().into(), | |
| 470 | 477 | key.public_key.into(), | |
| 471 | 478 | row.fingerprint.as_str().into(), | |
| 472 | − | (row.created_at as f64).into(), | |
| 479 | + | row.created_at.as_str().into(), | |
| 473 | 480 | ])? | |
| 474 | 481 | .run() | |
| 475 | 482 | .await?; | |
| 501 | 508 | let row = TokenRow { | |
| 502 | 509 | id: new_id("tok", now), | |
| 503 | 510 | name: name.to_owned(), | |
| 504 | − | created_at: now / 1000, | |
| 511 | + | created_at: rfc3339(now), | |
| 505 | 512 | }; | |
| 506 | 513 | self.db | |
| 507 | 514 | .prepare( | |
| 513 | 520 | a.user.id.into(), | |
| 514 | 521 | row.name.as_str().into(), | |
| 515 | 522 | crypto::sha256_hex(&token).into(), | |
| 516 | − | (row.created_at as f64).into(), | |
| 523 | + | row.created_at.as_str().into(), | |
| 517 | 524 | a.ttl_seconds | |
| 518 | − | .map_or(JsValue::NULL, |ttl| ((row.created_at + ttl) as f64).into()), | |
| 525 | + | .map_or(JsValue::NULL, |ttl| rfc3339(now + ttl * 1000).into()), | |
| 519 | 526 | ])? | |
| 520 | 527 | .run() | |
| 521 | 528 | .await?; |
| 1 | + | -- created_at becomes RFC 3339 UTC text instead of Unix seconds. | |
| 2 | + | PRAGMA defer_foreign_keys = on; | |
| 3 | + | ||
| 4 | + | -- Stored in Artifacts as "<namespace>--<name>". | |
| 5 | + | CREATE TABLE repos_new ( | |
| 6 | + | id TEXT PRIMARY KEY, | |
| 7 | + | namespace TEXT NOT NULL, | |
| 8 | + | name TEXT NOT NULL, | |
| 9 | + | description TEXT, | |
| 10 | + | is_private INTEGER NOT NULL DEFAULT 0, | |
| 11 | + | owner_id TEXT NOT NULL, | |
| 12 | + | default_branch TEXT NOT NULL DEFAULT 'main', | |
| 13 | + | -- Set on an attempt's working copy; those are hidden from listings. | |
| 14 | + | -- Refers to this table; the rename below carries the reference along. | |
| 15 | + | fork_of TEXT REFERENCES repos_new (id), | |
| 16 | + | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), | |
| 17 | + | UNIQUE (namespace, name) | |
| 18 | + | ); | |
| 19 | + | INSERT INTO repos_new | |
| 20 | + | (id, namespace, name, description, is_private, owner_id, default_branch, fork_of, created_at) | |
| 21 | + | SELECT id, namespace, name, description, is_private, owner_id, default_branch, fork_of, | |
| 22 | + | strftime('%Y-%m-%dT%H:%M:%fZ', created_at, 'unixepoch') | |
| 23 | + | FROM repos; | |
| 24 | + | ||
| 25 | + | DROP TABLE repos; | |
| 26 | + | ALTER TABLE repos_new RENAME TO repos; | |
| 27 | + | CREATE INDEX repos_owner ON repos (owner_id); |
| 13 | 13 | ||
| 14 | 14 | use g1t_contracts::events::{GitPush, NewEvent, RepoCreated, RepoForked}; | |
| 15 | 15 | use g1t_contracts::repos::*; | |
| 16 | + | use g1t_contracts::time::rfc3339; | |
| 16 | 17 | use g1t_contracts::{ | |
| 17 | 18 | FailureCode, Outcome, User, Viewer, is_valid_namespace, is_valid_repo_name, new_id, | |
| 18 | 19 | }; | |
| 194 | 195 | owner_id: a.owner.id.clone(), | |
| 195 | 196 | default_branch: "main".to_owned(), | |
| 196 | 197 | fork_of: None, | |
| 197 | − | created_at: now, | |
| 198 | + | created_at: rfc3339(now), | |
| 198 | 199 | }; | |
| 199 | 200 | self.store | |
| 200 | 201 | .create( | |
| 343 | 344 | owner_id: a.actor.id.clone(), | |
| 344 | 345 | default_branch: source.default_branch.clone(), | |
| 345 | 346 | fork_of: Some(source.id.clone()), | |
| 346 | − | created_at: now, | |
| 347 | + | created_at: rfc3339(now), | |
| 347 | 348 | }; | |
| 348 | 349 | self.store | |
| 349 | 350 | .open(&store_key(&source)) |
| 16 | 16 | owner_id: String, | |
| 17 | 17 | default_branch: String, | |
| 18 | 18 | fork_of: Option<String>, | |
| 19 | − | created_at: u64, | |
| 19 | + | created_at: String, | |
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | 22 | impl From<RepoRow> for Repo { | |
| 30 | 30 | owner_id: row.owner_id, | |
| 31 | 31 | default_branch: row.default_branch, | |
| 32 | 32 | fork_of: row.fork_of, | |
| 33 | − | created_at: row.created_at * 1000, | |
| 33 | + | created_at: row.created_at, | |
| 34 | 34 | } | |
| 35 | 35 | } | |
| 36 | 36 | } | |
| 139 | 139 | repo.owner_id.as_str().into(), | |
| 140 | 140 | repo.default_branch.as_str().into(), | |
| 141 | 141 | optional(&repo.fork_of), | |
| 142 | − | ((repo.created_at / 1000) as f64).into(), | |
| 142 | + | repo.created_at.as_str().into(), | |
| 143 | 143 | ])? | |
| 144 | 144 | .run() | |
| 145 | 145 | .await?; |
| 4 | 4 | //! [`ArtifactsStore`] is the adapter for Cloudflare Artifacts. | |
| 5 | 5 | ||
| 6 | 6 | use g1t_contracts::repos::{Commit, EntryKind, GitAccess, Signature, TreeEntry}; | |
| 7 | + | use g1t_contracts::time::rfc3339; | |
| 7 | 8 | use g1t_kit::js; | |
| 8 | 9 | use serde::Deserialize; | |
| 9 | 10 | use worker::js_sys::{Reflect, Uint8Array}; | |
| 180 | 181 | message: commit.message, | |
| 181 | 182 | author: commit.author, | |
| 182 | 183 | parents: commit.parents, | |
| 183 | − | authored_at: commit.authored_at * 1000, | |
| 184 | + | authored_at: rfc3339(commit.authored_at * 1000), | |
| 184 | 185 | }) | |
| 185 | 186 | .collect()) | |
| 186 | 187 | } |