tangled
alpha
login
or
join now
zzstoatzz.io
/
status
0
fork
atom
slack status without the slack
status.zzstoatzz.io/
quickslice
0
fork
atom
overview
issues
pulls
pipelines
fix warnings
zzstoatzz.io
6 months ago
32e49c12
b567348c
+179
-25
3 changed files
expand all
collapse all
unified
split
src
main.rs
templates.rs
templates
status.html
+106
src/main.rs
···
791
791
expires_in: Option<String>, // e.g., "1h", "30m", "1d", etc.
792
792
}
793
793
794
794
+
/// The post body for deleting a specific status
795
795
+
#[derive(Serialize, Deserialize)]
796
796
+
struct DeleteRequest {
797
797
+
uri: String,
798
798
+
}
799
799
+
794
800
/// Parse duration string like "1h", "30m", "1d" into chrono::Duration
795
801
fn parse_duration(duration_str: &str) -> Option<chrono::Duration> {
796
802
if duration_str.is_empty() {
···
905
911
.see_other()
906
912
.respond_to(&request)
907
913
.map_into_boxed_body()
914
914
+
}
915
915
+
}
916
916
+
}
917
917
+
918
918
+
/// Delete a specific status by URI (JSON endpoint)
919
919
+
#[post("/status/delete")]
920
920
+
async fn delete_status(
921
921
+
session: Session,
922
922
+
oauth_client: web::Data<OAuthClientType>,
923
923
+
db_pool: web::Data<Arc<Pool>>,
924
924
+
req: web::Json<DeleteRequest>,
925
925
+
) -> HttpResponse {
926
926
+
// Check if the user is logged in
927
927
+
match session.get::<String>("did").unwrap_or(None) {
928
928
+
Some(did_string) => {
929
929
+
let did = Did::new(did_string.clone()).expect("failed to parse did");
930
930
+
931
931
+
// Parse the URI to verify it belongs to this user
932
932
+
// URI format: at://did:plc:xxx/io.zzstoatzz.status.record/rkey
933
933
+
let uri_parts: Vec<&str> = req.uri.split('/').collect();
934
934
+
if uri_parts.len() < 5 {
935
935
+
return HttpResponse::BadRequest().json(serde_json::json!({
936
936
+
"error": "Invalid status URI format"
937
937
+
}));
938
938
+
}
939
939
+
940
940
+
// Extract DID from URI (at://did:plc:xxx/...)
941
941
+
let uri_did_part = uri_parts[2];
942
942
+
if uri_did_part != did_string {
943
943
+
return HttpResponse::Forbidden().json(serde_json::json!({
944
944
+
"error": "You can only delete your own statuses"
945
945
+
}));
946
946
+
}
947
947
+
948
948
+
// Extract record key
949
949
+
if let Some(rkey) = uri_parts.last() {
950
950
+
// Get OAuth session
951
951
+
match oauth_client.restore(&did).await {
952
952
+
Ok(session) => {
953
953
+
let agent = Agent::new(session);
954
954
+
955
955
+
// Delete the record from ATProto
956
956
+
let delete_request =
957
957
+
atrium_api::com::atproto::repo::delete_record::InputData {
958
958
+
collection: atrium_api::types::string::Nsid::new(
959
959
+
"io.zzstoatzz.status.record".to_string(),
960
960
+
)
961
961
+
.expect("valid nsid"),
962
962
+
repo: did.clone().into(),
963
963
+
rkey: atrium_api::types::string::RecordKey::new(
964
964
+
rkey.to_string(),
965
965
+
)
966
966
+
.expect("valid rkey"),
967
967
+
swap_commit: None,
968
968
+
swap_record: None,
969
969
+
};
970
970
+
971
971
+
match agent
972
972
+
.api
973
973
+
.com
974
974
+
.atproto
975
975
+
.repo
976
976
+
.delete_record(delete_request.into())
977
977
+
.await
978
978
+
{
979
979
+
Ok(_) => {
980
980
+
// Also remove from local database
981
981
+
let _ = StatusFromDb::delete_by_uri(&db_pool, req.uri.clone()).await;
982
982
+
983
983
+
HttpResponse::Ok().json(serde_json::json!({
984
984
+
"success": true
985
985
+
}))
986
986
+
}
987
987
+
Err(e) => {
988
988
+
log::error!("Failed to delete status from ATProto: {e}");
989
989
+
HttpResponse::InternalServerError().json(serde_json::json!({
990
990
+
"error": "Failed to delete status"
991
991
+
}))
992
992
+
}
993
993
+
}
994
994
+
}
995
995
+
Err(e) => {
996
996
+
log::error!("Failed to restore OAuth session: {e}");
997
997
+
HttpResponse::InternalServerError().json(serde_json::json!({
998
998
+
"error": "Session error"
999
999
+
}))
1000
1000
+
}
1001
1001
+
}
1002
1002
+
} else {
1003
1003
+
HttpResponse::BadRequest().json(serde_json::json!({
1004
1004
+
"error": "Invalid status URI"
1005
1005
+
}))
1006
1006
+
}
1007
1007
+
}
1008
1008
+
None => {
1009
1009
+
// Not logged in
1010
1010
+
HttpResponse::Unauthorized().json(serde_json::json!({
1011
1011
+
"error": "Not authenticated"
1012
1012
+
}))
908
1013
}
909
1014
}
910
1015
}
···
1195
1300
.service(user_status_json)
1196
1301
.service(status)
1197
1302
.service(clear_status)
1303
1303
+
.service(delete_status)
1198
1304
})
1199
1305
.bind((host.as_str(), port))?
1200
1306
.run()
+6
src/templates.rs
···
7
7
#[derive(Template)]
8
8
#[template(path = "home.html")]
9
9
pub struct HomeTemplate<'a> {
10
10
+
#[allow(dead_code)]
10
11
pub title: &'a str,
11
12
pub status_options: &'a [&'a str],
12
13
pub profile: Option<Profile>,
···
23
24
#[derive(Template)]
24
25
#[template(path = "login.html")]
25
26
pub struct LoginTemplate<'a> {
27
27
+
#[allow(dead_code)]
26
28
pub title: &'a str,
27
29
pub error: Option<&'a str>,
28
30
}
···
30
32
#[derive(Template)]
31
33
#[template(path = "error.html")]
32
34
pub struct ErrorTemplate<'a> {
35
35
+
#[allow(dead_code)]
33
36
pub title: &'a str,
34
37
pub error: &'a str,
35
38
}
···
37
40
#[derive(Template)]
38
41
#[template(path = "status.html")]
39
42
pub struct StatusTemplate<'a> {
43
43
+
#[allow(dead_code)]
40
44
pub title: &'a str,
41
45
pub handle: String,
46
46
+
#[allow(dead_code)]
42
47
pub status_options: &'a [&'a str],
43
48
pub current_status: Option<StatusFromDb>,
44
49
pub history: Vec<StatusFromDb>,
···
48
53
#[derive(Template)]
49
54
#[template(path = "feed.html")]
50
55
pub struct FeedTemplate<'a> {
56
56
+
#[allow(dead_code)]
51
57
pub title: &'a str,
52
58
pub profile: Option<Profile>,
53
59
pub statuses: Vec<StatusFromDb>,
+67
-25
templates/status.html
···
105
105
<option value="1w">1 week</option>
106
106
</select>
107
107
</form>
108
108
-
109
109
-
{% if let Some(current) = current_status %}
110
110
-
<form action="/status/clear" method="post" class="clear-form">
111
111
-
<button type="submit" class="clear-status-btn">Clear status</button>
112
112
-
</form>
113
113
-
{% endif %}
114
108
</div>
115
109
116
110
<!-- Emoji Picker (hidden by default) -->
···
174
168
{% endif %}
175
169
<span class="history-time local-time" data-timestamp="{{ status.started_at.to_rfc3339() }}" data-format="short"></span>
176
170
</div>
171
171
+
{% if is_owner %}
172
172
+
<button type="button" class="history-delete" data-uri="{{ status.uri }}" title="Delete this status">
173
173
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
174
174
+
<line x1="18" y1="6" x2="6" y2="18"></line>
175
175
+
<line x1="6" y1="6" x2="18" y2="18"></line>
176
176
+
</svg>
177
177
+
</button>
178
178
+
{% endif %}
177
179
</div>
178
180
{% endif %}
179
181
{% endfor %}
···
465
467
cursor: not-allowed;
466
468
}
467
469
468
468
-
.clear-form {
469
469
-
margin-top: 0.5rem;
470
470
-
text-align: center;
471
471
-
}
472
472
-
473
473
-
.clear-status-btn {
474
474
-
background: transparent;
475
475
-
border: none;
476
476
-
color: var(--text-tertiary);
477
477
-
font-size: 0.875rem;
478
478
-
cursor: pointer;
479
479
-
padding: 0.5rem;
480
480
-
transition: color 0.2s;
481
481
-
}
482
482
-
483
483
-
.clear-status-btn:hover {
484
484
-
color: var(--danger);
485
485
-
}
486
486
-
487
470
/* Emoji Picker */
488
471
.emoji-picker {
489
472
position: absolute;
···
687
670
align-items: center;
688
671
gap: 0.75rem;
689
672
padding: 0.5rem 0;
673
673
+
position: relative;
690
674
}
691
675
692
676
.history-emoji {
···
712
696
.history-time {
713
697
color: var(--text-tertiary);
714
698
font-size: 0.75rem;
699
699
+
}
700
700
+
701
701
+
.history-delete {
702
702
+
background: transparent;
703
703
+
border: none;
704
704
+
color: var(--text-tertiary);
705
705
+
cursor: pointer;
706
706
+
padding: 0.25rem;
707
707
+
border-radius: var(--radius-sm);
708
708
+
opacity: 0;
709
709
+
transition: all 0.2s;
710
710
+
}
711
711
+
712
712
+
.history-item:hover .history-delete {
713
713
+
opacity: 1;
714
714
+
}
715
715
+
716
716
+
.history-delete:hover {
717
717
+
background: var(--bg-tertiary);
718
718
+
color: var(--danger);
715
719
}
716
720
717
721
/* Nav Links */
···
1432
1436
checkForChanges();
1433
1437
1434
1438
{% endif %}
1439
1439
+
1440
1440
+
// Handle delete buttons for history items
1441
1441
+
document.querySelectorAll('.history-delete').forEach(btn => {
1442
1442
+
btn.addEventListener('click', async (e) => {
1443
1443
+
const uri = btn.getAttribute('data-uri');
1444
1444
+
1445
1445
+
if (confirm('Delete this status? This cannot be undone.')) {
1446
1446
+
try {
1447
1447
+
const response = await fetch('/status/delete', {
1448
1448
+
method: 'POST',
1449
1449
+
headers: {
1450
1450
+
'Content-Type': 'application/json',
1451
1451
+
},
1452
1452
+
body: JSON.stringify({ uri })
1453
1453
+
});
1454
1454
+
1455
1455
+
if (response.ok) {
1456
1456
+
// Remove the history item from the DOM
1457
1457
+
btn.closest('.history-item').remove();
1458
1458
+
1459
1459
+
// If no more history items, remove the entire history section
1460
1460
+
const historyItems = document.querySelectorAll('.history-item');
1461
1461
+
if (historyItems.length === 0) {
1462
1462
+
const historySection = document.querySelector('.history');
1463
1463
+
if (historySection) {
1464
1464
+
historySection.remove();
1465
1465
+
}
1466
1466
+
}
1467
1467
+
} else {
1468
1468
+
alert('Failed to delete status');
1469
1469
+
}
1470
1470
+
} catch (error) {
1471
1471
+
console.error('Error deleting status:', error);
1472
1472
+
alert('Failed to delete status');
1473
1473
+
}
1474
1474
+
}
1475
1475
+
});
1476
1476
+
});
1435
1477
});
1436
1478
</script>
1437
1479
{%endblock content%}