Compare commits

..
Author SHA1 Message Date
Brandon Thomas fec1118a02 update format 2026-02-14 08:03:09 +07:00
Brandon Thomas 444340624a more logging 2026-02-14 07:51:44 +07:00
Brandon Thomas 32a6a70005 report runtime 2026-02-14 07:32:04 +07:00
Brandon Thomas ffa6e1a61e query, timestamps 2026-02-14 07:29:11 +07:00
Brandon Thomas 3c5b7cc9cb 2 spaces 2026-02-14 07:16:44 +07:00
Brandon Thomas f383519d16 1-shot script 2026-02-14 07:09:40 +07:00
Brandon Thomas ac0e95c265 fix reset password link 2026-02-13 10:15:08 +07:00
Brandon Thomas 885fc0a983 website password reset redirect 2026-02-13 10:11:16 +07:00
Brandon Thomas 97215d3a3e bump artcraft to 0.6.0 2026-02-13 09:47:57 +07:00
Brandon Thomas 68912622d9 write artcraft desktop artifacts to same package 2026-02-13 09:45:49 +07:00
Brandon Thomas 03ab6b8f11 inactive ci workflows 2026-02-13 09:41:55 +07:00
Brandon Thomas 314dd37ce8 codegen db 2026-02-13 09:25:08 +07:00
Brandon Thomas 74571c9f47 fix password reset (backend) (#1037) 2026-02-13 09:14:55 +07:00
bflatastic e70df9a72f password update flow 2026-02-13 07:53:13 +07:00
bflatastic 9982d49f99 password reset api 2026-02-13 07:01:40 +07:00
24 changed files with 670 additions and 67 deletions
+2 -2
View File
@@ -233,8 +233,8 @@ jobs:
with:
projectPath: ./crates/desktop/artcraft
tagName: artcraft-v__VERSION__ # the action automatically replaces __VERSION__ with the app version.
releaseName: "Artcraft v__VERSION__"
releaseBody: "See the assets to download this version and install."
releaseName: "ArtCraft v__VERSION__"
releaseBody: "See the assets below to download this version and install."
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
@@ -99,9 +99,9 @@ jobs:
SQLX_OFFLINE: true
with:
projectPath: ./crates/desktop/artcraft
tagName: artcraft-windows-v__VERSION__ # the action automatically replaces __VERSION__ with the app version.
releaseName: "Artcraft Windows v__VERSION__"
releaseBody: "Windows build - See the assets to download this version and install."
tagName: artcraft-v__VERSION__ # the action automatically replaces __VERSION__ with the app version.
releaseName: "ArtCraft v__VERSION__"
releaseBody: "Production build of ArtCraft. See the assets below to download and install."
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
+3
View File
@@ -32,6 +32,9 @@ script/production_database_secret_backup.sh
script/production_database_secret_connect.sh
script/socat_dev_database_connect.sh
# Python virtual envs (named by convention)
venv/
# Scratch data
scratch/
test_data/temp/
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE users\nSET\n email_confirmed = true,\n email_is_synthetic = false,\n is_without_password = false,\n is_temporary = false,\n password_hash = ?,\n ip_address_last_update = ?,\n password_version = password_version + 1,\n version = version + 1\nWHERE\n token = ?\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "9cd3a581f6857e5c1db68a103f47eba9714ee4b5eefc8ba02fc49a4b3a19310e"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nUPDATE users\nSET\n email_confirmed = true,\n password_hash = ?,\n ip_address_last_update = ?,\n is_without_password = false,\n password_version = password_version + 1,\n version = version + 1\nWHERE\n token = ?\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "b8795466b4251d869bdcc644324c6a873791ad1ef0d6d43f77ad2569accec3a4"
}
Generated
+1 -1
View File
@@ -692,7 +692,7 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "artcraft"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"anyhow",
"artcraft_api_defs",
+7
View File
@@ -86,6 +86,13 @@ diesel migration run
You might get a scary message about `"Encountered unknown type for Mysql: enum"` -- you can safely ignore this
error if you see it in isolation. It doesn't impact the migrations whatsoever.
Finally, you'll need the sqlx CLI tool to run codegen.
You likely won't need this now, but if you change any queries, this will be necessary:
```bash
cargo install sqlx-cli --features rustls,mysql,sqlite
```
### Install Redis (Mac)
```bash
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "artcraft"
version = "0.5.0"
version = "0.6.0"
description = "Artcraft"
authors = ["you"]
license = ""
+1 -1
View File
@@ -1,4 +1,4 @@
/// The version of the ArtCraft desktop app reported in the UI and via Analytics.
/// This should match the CI builds.
pub const ARTCRAFT_VERSION: &str = "0.5.0";
pub const ARTCRAFT_VERSION: &str = "0.6.0";
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.5.0",
"version": "0.6.0",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.5.0",
"version": "0.6.0",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",
@@ -14,7 +14,7 @@ pub enum PasswordResetLocation {
pub fn get_password_reset_url(reset_token: &str, location: PasswordResetLocation) -> String {
let url = match location {
PasswordResetLocation::ArtCraftProduction => "https://getartcraft.com/password-reset/verify",
PasswordResetLocation::ArtCraftProduction => "https://getartcraft.com/forgot-password/verify",
PasswordResetLocation::FakeYouProduction => "https://fakeyou.com/password-reset/verify",
PasswordResetLocation::ArtCraftDevelopment |
PasswordResetLocation::FakeYouDevelopment => {
@@ -56,6 +56,14 @@
/signup/ /index.html 200
/signup/* /index.html 200
/forgot-password /index.html 200
/forgot-password/ /index.html 200
/forgot-password/* /index.html 200
/welcome /index.html 200
/welcome/ /index.html 200
/welcome/* /index.html 200
/password-reset /index.html 200
/password-reset/ /index.html 200
/password-reset/* /index.html 200
@@ -13,7 +13,7 @@ import NewsPost from "../pages/news/news-post";
import Pricing from "../pages/pricing";
import Login from "../pages/login";
import Signup from "../pages/signup";
import ForgotPassword from "../pages/forgot-password";
import ForgotPassword, { VerifyReset } from "../pages/forgot-password";
import Welcome from "../pages/welcome";
import Onboarding from "../pages/onboarding";
import { CheckoutSuccess, CheckoutCancel } from "../pages/checkout";
@@ -39,6 +39,7 @@ export function App() {
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/forgot-password/verify" element={<VerifyReset />} />
<Route path="/welcome" element={<Welcome />} />
<Route path="/onboarding" element={<Onboarding />} />
<Route path="/checkout/success" element={<CheckoutSuccess />} />
@@ -204,26 +204,45 @@ export default function Navbar() {
>
Pricing
</Link>
<Button
as="link"
href="/signup"
className="bg-white text-black hover:bg-white/90 text-sm font-semibold px-4 py-2 h-auto rounded-lg shadow-md"
>
Sign up
</Button>
<div className="flex gap-2">
<Link to="/login">
<Button
variant="primary"
className="bg-white/80 text-black hover:bg-white/90 text-sm font-semibold px-4 py-2 rounded-lg shadow-md"
>
Login
</Button>
</Link>
<Link to="/signup">
<Button
variant="primary"
className="text-sm font-semibold px-4 py-2 rounded-lg shadow-md"
>
Sign up
</Button>
</Link>
</div>
</div>
)}
{/* Mobile Menu Button - simplified for now */}
<div className="-ml-2 flex items-center md:hidden gap-3">
{!user && (
<Button
as="link"
href="/signup"
className="bg-white text-black hover:bg-white/90 text-xs font-semibold px-3 py-1.5 h-auto rounded-lg"
>
Sign up
</Button>
<div className="flex gap-2">
<Link to="/login">
<Button className="bg-white/60 text-black hover:bg-white/90 text-xs font-semibold px-3 py-1.5 h-auto rounded-lg">
Login
</Button>
</Link>
<Link to="/signup">
<Button
variant="primary"
className="text-xs font-semibold px-3 py-1.5 h-auto"
>
Sign up
</Button>
</Link>
</div>
)}
{user && (
<img
@@ -1,15 +1,55 @@
import { faArrowLeft, faEnvelope } from "@fortawesome/pro-solid-svg-icons";
import {
faArrowLeft,
faEnvelope,
faSpinner,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import { useState } from "react";
import { Link } from "react-router-dom";
import { PasswordResetApi } from "@storyteller/api";
import Seo from "../../components/seo";
const ForgotPassword = () => {
const [email, setEmail] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [submitted, setSubmitted] = useState(false);
const handleRequestReset = async () => {
setError(null);
if (!email.trim()) {
setError("Please enter your email or username.");
return;
}
setIsLoading(true);
const api = new PasswordResetApi();
const response = await api.RequestPasswordReset({
usernameOrEmail: email.trim(),
});
setIsLoading(false);
if (response.success) {
setSubmitted(true);
} else {
setError(
response.errorMessage ||
"Failed to send reset email. Please try again.",
);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleRequestReset();
}
};
return (
<div className="relative min-h-screen bg-[#101014] text-white bg-dots flex flex-col">
<Seo
@@ -20,45 +60,66 @@ const ForgotPassword = () => {
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-3xl p-8 shadow-2xl">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold mb-2">Reset Password</h1>
<p className="text-white/60 text-sm">
Enter your email to receive reset instructions
</p>
</div>
{!submitted ? (
<div className="space-y-4">
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
/>
<>
<div className="text-center mb-8">
<h1 className="text-2xl font-bold mb-2">Reset Password</h1>
<p className="text-white/60 text-sm">
Enter your email to receive reset instructions
</p>
</div>
<Button
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12 mt-2"
onClick={() => setSubmitted(true)}
>
Send Reset Link
</Button>
</div>
<div className="space-y-4">
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
{error}
</div>
)}
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
Email or Username
</label>
<input
id="reset-email"
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="you@example.com"
className="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
/>
</div>
<Button
id="send-reset-btn"
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12 mt-2"
onClick={handleRequestReset}
disabled={isLoading}
>
{isLoading ? (
<FontAwesomeIcon icon={faSpinner} spin />
) : (
"Send Reset Code"
)}
</Button>
</div>
</>
) : (
<div className="text-center py-8">
<div className="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4 text-green-500">
<FontAwesomeIcon icon={faEnvelope} className="text-2xl" />
</div>
<h3 className="text-xl font-bold mb-2">Check your email</h3>
<p className="text-white/60 text-sm mb-8">
We've sent a password reset link to <br />
<p className="text-white/60 text-sm mb-6">
We've sent a password reset code to <br />
<span className="text-white font-medium">{email}</span>
</p>
<Link to="/forgot-password/verify">
<Button className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12 mb-3">
Enter Verification Code
</Button>
</Link>
<Button
className="w-full bg-white/10 hover:bg-white/20 text-white border-none justify-center font-bold h-12"
onClick={() => setSubmitted(false)}
@@ -1,2 +1,4 @@
import ForgotPassword from "./forgot-password";
import VerifyReset from "./verify-reset";
export default ForgotPassword;
export { VerifyReset };
@@ -0,0 +1,278 @@
import {
faArrowLeft,
faLock,
faEye,
faEyeSlash,
faSpinner,
faKey,
faCheckCircle,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import { useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { PasswordResetApi } from "@storyteller/api";
import Seo from "../../components/seo";
const VerifyReset = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// Pre-fill token from URL query param if present
const tokenFromUrl = searchParams.get("token") || "";
const [verificationCode, setVerificationCode] = useState(tokenFromUrl);
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showNewPassword, setShowNewPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [success, setSuccess] = useState(false);
const handleRedeemReset = async () => {
setError(null);
setFieldErrors({});
// Client-side validation
const errors: Record<string, string> = {};
if (!verificationCode.trim()) {
errors.verificationCode = "Verification code is required.";
}
if (!newPassword) {
errors.newPassword = "New password is required.";
} else if (newPassword.length < 8) {
errors.newPassword = "Password must be at least 8 characters.";
}
if (!confirmPassword) {
errors.confirmPassword = "Please confirm your new password.";
} else if (newPassword !== confirmPassword) {
errors.confirmPassword = "Passwords do not match.";
}
if (Object.keys(errors).length > 0) {
setFieldErrors(errors);
return;
}
setIsLoading(true);
const api = new PasswordResetApi();
const response = await api.RedeemPasswordReset({
resetToken: verificationCode.trim(),
newPassword: newPassword,
newPasswordValidation: confirmPassword,
});
setIsLoading(false);
if (response.success) {
setSuccess(true);
} else {
setError(
response.errorMessage ||
"Failed to reset password. Please check your code and try again.",
);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleRedeemReset();
}
};
return (
<div className="relative min-h-screen bg-[#101014] text-white bg-dots flex flex-col">
<Seo
title="Verify Password Reset - ArtCraft"
description="Enter your verification code and new password."
/>
<div className="dotted-pattern absolute inset-0 z-[0] opacity-30" />
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-3xl p-8 shadow-2xl">
{!success ? (
<>
<div className="text-center mb-8">
<h1 className="text-2xl font-bold mb-2">
Password Reset Verification
</h1>
<p className="text-white/60 text-sm">
Enter the code sent to your email address.
</p>
</div>
<div className="space-y-5">
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
{error}
</div>
)}
{/* Verification Code */}
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
Verification Code
</label>
<div className="relative">
<FontAwesomeIcon
icon={faKey}
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/20"
/>
<input
id="verification-code"
type="text"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
placeholder="Enter verification code"
className={`w-full bg-black/20 border ${
fieldErrors.verificationCode
? "border-red-500/50"
: "border-white/10"
} focus:border-primary/50 rounded-xl pl-11 pr-4 py-3 text-white placeholder-white/20 outline-none transition-colors`}
/>
</div>
{fieldErrors.verificationCode && (
<p className="text-red-400 text-xs ml-1 mt-1">
{fieldErrors.verificationCode}
</p>
)}
</div>
{/* New Password */}
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
New Password
</label>
<div className="relative">
<FontAwesomeIcon
icon={faLock}
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/20"
/>
<input
id="new-password"
type={showNewPassword ? "text" : "password"}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Enter new password"
className={`w-full bg-black/20 border ${
fieldErrors.newPassword
? "border-red-500/50"
: "border-white/10"
} focus:border-primary/50 rounded-xl pl-11 pr-12 py-3 text-white placeholder-white/20 outline-none transition-colors`}
/>
<button
type="button"
onClick={() => setShowNewPassword(!showNewPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
>
<FontAwesomeIcon
icon={showNewPassword ? faEyeSlash : faEye}
/>
</button>
</div>
{fieldErrors.newPassword && (
<p className="text-red-400 text-xs ml-1 mt-1">
{fieldErrors.newPassword}
</p>
)}
</div>
{/* Verify New Password */}
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
Verify New Password
</label>
<div className="relative">
<FontAwesomeIcon
icon={faLock}
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/20"
/>
<input
id="confirm-password"
type={showConfirmPassword ? "text" : "password"}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter new password again"
className={`w-full bg-black/20 border ${
fieldErrors.confirmPassword
? "border-red-500/50"
: "border-white/10"
} focus:border-primary/50 rounded-xl pl-11 pr-12 py-3 text-white placeholder-white/20 outline-none transition-colors`}
/>
<button
type="button"
onClick={() =>
setShowConfirmPassword(!showConfirmPassword)
}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
>
<FontAwesomeIcon
icon={showConfirmPassword ? faEyeSlash : faEye}
/>
</button>
</div>
{fieldErrors.confirmPassword && (
<p className="text-red-400 text-xs ml-1 mt-1">
{fieldErrors.confirmPassword}
</p>
)}
</div>
<Button
id="change-password-btn"
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12 mt-2"
onClick={handleRedeemReset}
disabled={isLoading}
>
{isLoading ? (
<FontAwesomeIcon icon={faSpinner} spin />
) : (
"Change Password"
)}
</Button>
</div>
<div className="mt-8 text-center text-sm">
<Link
to="/forgot-password"
className="text-white/40 hover:text-white transition-colors flex items-center justify-center gap-2"
>
<FontAwesomeIcon icon={faArrowLeft} /> Request a new code
</Link>
</div>
</>
) : (
<>
<div className="text-center py-8">
<div className="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4 text-green-500">
<FontAwesomeIcon icon={faCheckCircle} className="text-2xl" />
</div>
<h3 className="text-xl font-bold mb-2">
Password Reset Successfully
</h3>
<p className="text-white/60 text-sm mb-8">
Your password has been changed. You can now log in with your
new password.
</p>
<Button
id="go-to-login-btn"
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12"
onClick={() => navigate("/login")}
>
Go to Login
</Button>
</div>
</>
)}
</div>
</main>
</div>
);
};
export default VerifyReset;
+1
View File
@@ -11,6 +11,7 @@ export * from "./lib/LocalApi.js";
export * from "./lib/PromptsApi.js";
export * from "./lib/TtsApi.js";
export * from "./lib/UserBookmarksApi.js";
export * from "./lib/PasswordResetApi.js";
export * from "./lib/UsersApi.js";
export * from "./lib/VideoApi.js";
export * from "./lib/WeightsApi.js";
@@ -0,0 +1,93 @@
import { ApiManager, ApiResponse } from "./ApiManager.js";
export class PasswordResetApi extends ApiManager {
public async RequestPasswordReset({
usernameOrEmail,
}: {
usernameOrEmail: string;
}): Promise<ApiResponse<null>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/password_reset/request`;
const body = {
username_or_email: usernameOrEmail,
};
try {
const response = await this.post<
{ username_or_email: string },
{
success: boolean;
error_type?: string;
error_fields?: Record<string, string>;
error_message?: string;
}
>({
endpoint: endpoint,
body: body,
});
return {
success: response.success,
errorMessage:
response.error_message ||
(response.error_fields
? Object.values(response.error_fields).join(", ")
: undefined),
};
} catch (err: any) {
return {
success: false,
errorMessage: err.message,
};
}
}
public async RedeemPasswordReset({
resetToken,
newPassword,
newPasswordValidation,
}: {
resetToken: string;
newPassword: string;
newPasswordValidation: string;
}): Promise<ApiResponse<null>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/password_reset/redeem`;
const body = {
reset_token: resetToken,
new_password: newPassword,
new_password_validation: newPasswordValidation,
};
try {
const response = await this.post<
{
reset_token: string;
new_password: string;
new_password_validation: string;
},
{
success: boolean;
error_type?: string;
error_fields?: Record<string, string>;
error_message?: string;
}
>({
endpoint: endpoint,
body: body,
});
return {
success: response.success,
errorMessage:
response.error_message ||
(response.error_fields
? Object.values(response.error_fields).join(", ")
: undefined),
};
} catch (err: any) {
return {
success: false,
errorMessage: err.message,
};
}
}
}
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""
Database migration script for running queries in a loop with failure fallback.
Reads MySQL credentials from a TOML secrets file.
Requirements:
pip install pymysql
Secrets file (script/database/secrets.toml):
[database]
url = "mysql://user:password@host:3306/dbname"
"""
import sys
import time
import tomllib
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse
try:
import pymysql
except ImportError:
print("pymysql is required: pip install pymysql")
sys.exit(1)
SECRETS_PATH = "secrets.toml"
# -- Migration query to run in batches --
# Tweak this as needed. Use LIMIT to control batch size.
QUERY = """
delete
from media_files
where maybe_creator_user_token is null
limit 10000
"""
BATCH_PAUSE_SECONDS = 0.5
MAX_CONSECUTIVE_FAILURES = 5
FAILURE_BACKOFF_SECONDS = 5
def load_config():
with open(SECRETS_PATH, "rb") as f:
config = tomllib.load(f)
url = config["database"]["url"]
parsed = urlparse(url)
return {
"host": parsed.hostname,
"port": parsed.port or 3306,
"user": parsed.username,
"password": parsed.password,
"database": parsed.path.lstrip("/"),
}
def connect(config):
return pymysql.connect(
host=config["host"],
port=config["port"],
user=config["user"],
password=config["password"],
database=config["database"],
connect_timeout=10,
read_timeout=30,
write_timeout=30,
autocommit=True,
)
def run_migration():
config = load_config()
conn = connect(config)
total_affected = 0
failures = 0
start_time = time.monotonic()
print(f"Connected to {config['host']}/{config['database']}")
print(f"Running query:\n{QUERY.strip()}\n")
try:
while True:
try:
batch_start = time.monotonic()
with conn.cursor() as cursor:
cursor.execute(QUERY)
affected = cursor.rowcount
total_affected += affected
failures = 0
batch_time = int(time.monotonic() - batch_start)
elapsed = int(time.monotonic() - start_time)
now = datetime.now().strftime("%H:%M:%S")
print(f" Batch: {affected} rows {batch_time}s | Total: {total_affected} rows {elapsed}s | Clock: {now}")
if affected == 0:
print("No more rows to process. Done.")
break
time.sleep(BATCH_PAUSE_SECONDS)
except (pymysql.OperationalError, pymysql.InterfaceError) as e:
failures += 1
print(f" Error ({failures}/{MAX_CONSECUTIVE_FAILURES}): {e}")
if failures >= MAX_CONSECUTIVE_FAILURES:
print("Too many consecutive failures. Aborting.")
sys.exit(1)
print(f" Reconnecting in {FAILURE_BACKOFF_SECONDS}s...")
time.sleep(FAILURE_BACKOFF_SECONDS)
try:
conn.close()
except Exception:
pass
conn = connect(config)
except KeyboardInterrupt:
print(f"\nInterrupted. Total rows affected: {total_affected}")
finally:
conn.close()
print(f"Finished. Total rows affected: {total_affected}")
if __name__ == "__main__":
run_migration()
+2
View File
@@ -0,0 +1,2 @@
[database]
url = "mysql://user:password@host:3306/dbname"