Compare commits
28 commits
feat/admin
...
main
Author | SHA1 | Date | |
---|---|---|---|
9580711d6b | |||
1988abe369 | |||
01ab37b401 | |||
82b517aa86 | |||
2c886f83e7 | |||
dffdc779e1 | |||
d41cda2243 | |||
d313d05e66 | |||
4fc3adec86 | |||
6f3c9a6031 | |||
77f231c913 | |||
e46cfee33b | |||
91889cd949 | |||
f74187843c | |||
761e1daf23 | |||
dd26eed497 | |||
ab2bc6ad49 | |||
8eba0fffd4 | |||
9eb1ef335a | |||
8fde98e9d5 | |||
8e68d8bb24 | |||
d7abcaec9f | |||
b030163fb1 | |||
08ffe8c850 | |||
746f8fb639 | |||
2ed9faad98 | |||
755be599ee | |||
f7da45312a |
81 changed files with 3119 additions and 435 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,3 +1,4 @@
|
|||
# built binary
|
||||
/target
|
||||
.env
|
||||
Rocket.toml
|
||||
|
|
12
.sqlx/query-05c359711121a8dcb8c651aba779e9362a68cd150536d57a10af98bf7be533ec.json
generated
Normal file
12
.sqlx/query-05c359711121a8dcb8c651aba779e9362a68cd150536d57a10af98bf7be533ec.json
generated
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "insert into users (id, username, password, email, enabled, is_admin) values\n('00000000-0000-0000-0000-000000000000', 'legacy', '', '', false, false),\n('1f35abcd-2997-41d4-80cf-6297a8d92eae', 'mistress', '$argon2i$v=19$m=65536,t=3,p=1$qFlubpiaaQdR/X/oFyOgsQ$ZHe5bO1v9PXcexSq0wj+hHyku+W0rztpxWmG+m8TU/k', 'mistress@dolltags.pet', true, true),\n('67d6f532-f8e3-4399-a45a-525e4f5c16d7', 'pet', '$argon2i$v=19$m=65536,t=3,p=1$zv+Y4zDgzeJJbpWAzlTA4g$NmynhR2KQywWhmnTgvAtTNhoO3xSJ+/1yley3j62yts', null, true, false);\n",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "05c359711121a8dcb8c651aba779e9362a68cd150536d57a10af98bf7be533ec"
|
||||
}
|
23
.sqlx/query-0b96e996b8d7578ec8611923edadeb8bd728c3048fbcea697e9ecc967045f70b.json
generated
Normal file
23
.sqlx/query-0b96e996b8d7578ec8611923edadeb8bd728c3048fbcea697e9ecc967045f70b.json
generated
Normal file
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select otp_method from otp where user_id = $1 and otp_method = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "otp_method",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0b96e996b8d7578ec8611923edadeb8bd728c3048fbcea697e9ecc967045f70b"
|
||||
}
|
|
@ -97,6 +97,11 @@
|
|||
"ordinal": 18,
|
||||
"name": "archived_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "is_public",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
@ -123,7 +128,8 @@
|
|||
true,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "164d77651f1f1b9ec7a28343db098305486e025bc4a5e71279a62da807ecea79"
|
||||
|
|
22
.sqlx/query-2f20571389b86a52e814014631f2598074eaf642a052fc6cf1515fc8f5ffe55f.json
generated
Normal file
22
.sqlx/query-2f20571389b86a52e814014631f2598074eaf642a052fc6cf1515fc8f5ffe55f.json
generated
Normal file
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select otp_method from otp where user_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "otp_method",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "2f20571389b86a52e814014631f2598074eaf642a052fc6cf1515fc8f5ffe55f"
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\t\tupdate doll_profiles\n\t\t\tset microchip_id = $1,\n\t\t\tname = $2,\n\t\t\tpronoun_subject = $3,\n\t\t\tpronoun_object = $4,\n\t\t\tpronoun_possessive = $5,\n\t\t\thandler_name = $6,\n\t\t\thandler_link = $7,\n\t\t\tkind = $8,\n\t\t\tbreed = $9,\n\t\t\tbehaviour = $10,\n\t\t\tdescription = $11,\n\t\t\tchassis_type = $12,\n\t\t\tchassis_id = $13,\n\t\t\tchassis_color = $14,\n\t\t\tarchived_at = null,\n\t\t\tupdated_at = current_timestamp\n\t\t\twhere id = $15\n\t\t",
|
||||
"query": "\n\t\t\tupdate doll_profiles\n\t\t\tset microchip_id = $1,\n\t\t\tname = $2,\n\t\t\tpronoun_subject = $3,\n\t\t\tpronoun_object = $4,\n\t\t\tpronoun_possessive = $5,\n\t\t\thandler_name = $6,\n\t\t\thandler_link = $7,\n\t\t\tkind = $8,\n\t\t\tbreed = $9,\n\t\t\tbehaviour = $10,\n\t\t\tdescription = $11,\n\t\t\tchassis_type = $12,\n\t\t\tchassis_id = $13,\n\t\t\tchassis_color = $14,\n\t\t\tis_public = $15,\n\t\t\tarchived_at = null,\n\t\t\tupdated_at = current_timestamp\n\t\t\twhere id = $16 and bound_to_id = $17\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
|
@ -19,10 +19,12 @@
|
|||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Int4"
|
||||
"Bool",
|
||||
"Int4",
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "9d71874889dfca1db897f09f772c14898372fd6561c7f6011f3f04a279e24bdf"
|
||||
"hash": "3152a8cb3d5be0a8be3b6e75754fa1d0fdbf2fc64b7086792d776318c1cec454"
|
||||
}
|
15
.sqlx/query-32ae49b571b04578a1dc1f4affb9cf8b0e28632909a698e26bbd2399d7b02194.json
generated
Normal file
15
.sqlx/query-32ae49b571b04578a1dc1f4affb9cf8b0e28632909a698e26bbd2399d7b02194.json
generated
Normal file
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "delete from otp where user_id = $1 and otp_method = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "32ae49b571b04578a1dc1f4affb9cf8b0e28632909a698e26bbd2399d7b02194"
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\t\t\tselect * from doll_profiles where (id = $1 or microchip_id = $2) and archived_at is null\n\t\t\t",
|
||||
"query": "select * from doll_profiles where (id = $1 or microchip_id = $2)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
|
@ -97,6 +97,11 @@
|
|||
"ordinal": 18,
|
||||
"name": "archived_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "is_public",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
@ -124,8 +129,9 @@
|
|||
true,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "03800471afd396ebcabb3dfd9fb7523989c0d0af621aeea8c7480dede8ceef1b"
|
||||
"hash": "32ee0b9c347300695e7aff02c940eb851984a761e4ce64239645088588d505da"
|
||||
}
|
16
.sqlx/query-4333bf863c408091a62cf12fbaa316de5c6b6cecf7b39f76c3e5441385c99be8.json
generated
Normal file
16
.sqlx/query-4333bf863c408091a62cf12fbaa316de5c6b6cecf7b39f76c3e5441385c99be8.json
generated
Normal file
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "update otp set recovery_key = $3 where user_id = $1 and otp_method = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4333bf863c408091a62cf12fbaa316de5c6b6cecf7b39f76c3e5441385c99be8"
|
||||
}
|
47
.sqlx/query-6edfedc301cb896d9c6483bfca092b217354faa0cc8ab2e11e844cd2090c41a8.json
generated
Normal file
47
.sqlx/query-6edfedc301cb896d9c6483bfca092b217354faa0cc8ab2e11e844cd2090c41a8.json
generated
Normal file
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select * from otp where user_id = $1 and otp_method = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "user_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "otp_method",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "secret_seed",
|
||||
"type_info": "Bpchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "recovery_key",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "6edfedc301cb896d9c6483bfca092b217354faa0cc8ab2e11e844cd2090c41a8"
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select id from doll_profiles where id = $1 or microchip_id = $2",
|
||||
"query": "select id from doll_profiles where id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
|
@ -11,13 +11,12 @@
|
|||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4",
|
||||
"Text"
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "44834b8a95718d0ae8ffc96e93469c6e0b4e6ca1160f7ada141f9515a6921ec9"
|
||||
"hash": "70736384c06084a3967b2d008f4c843d239a6a36ae02b1e2fd306498a04746af"
|
||||
}
|
|
@ -37,6 +37,11 @@
|
|||
"ordinal": 6,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "is_admin",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
@ -51,7 +56,8 @@
|
|||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "7609165d94c8f1bea9d535b9b7ad727fd06592973d7f83017292d41acb203be6"
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\t\tinsert into doll_profiles\n\t\t\t(id, microchip_id, name, pronoun_subject, pronoun_object, pronoun_possessive, handler_name, handler_link, kind, breed, behaviour, description, chassis_type, chassis_id, chassis_color, bound_to_id)\n\t\t\tvalues ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)\n\t\t",
|
||||
"query": "\n\t\t\tinsert into doll_profiles\n\t\t\t(id, microchip_id, name, pronoun_subject, pronoun_object, pronoun_possessive, handler_name, handler_link, kind, breed, behaviour, description, chassis_type, chassis_id, chassis_color, bound_to_id, is_public)\n\t\t\tvalues ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
|
@ -20,10 +20,11 @@
|
|||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Uuid"
|
||||
"Uuid",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a3d6e4ddfa10505e777ccb7a184e17a57c3620cd56ae3858f33db020c6be42f2"
|
||||
"hash": "7d5fef137e77f2ed7a13958a4bd345eccbded1a4d838e7953dd906b484ff9a91"
|
||||
}
|
|
@ -37,6 +37,11 @@
|
|||
"ordinal": 6,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "is_admin",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
@ -51,7 +56,8 @@
|
|||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "9d00617966f8aeebb08de6ad981dc3b8697c65f0b23cea4684f525732d8f6706"
|
||||
|
|
20
.sqlx/query-b94df9ffa981c87c32f36a33f91db1d9feda108f88cfae3dee48e4a0a2464223.json
generated
Normal file
20
.sqlx/query-b94df9ffa981c87c32f36a33f91db1d9feda108f88cfae3dee48e4a0a2464223.json
generated
Normal file
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select count(id) from users where id != '00000000-0000-0000-0000-000000000000' and enabled is true",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "b94df9ffa981c87c32f36a33f91db1d9feda108f88cfae3dee48e4a0a2464223"
|
||||
}
|
|
@ -1,14 +1,15 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\t\tupdate doll_profiles\n\t\t\tset microchip_id = null,\n\t\t\tname = '',\n\t\t\tpronoun_subject = '',\n\t\t\tpronoun_object = '',\n\t\t\tpronoun_possessive = '',\n\t\t\thandler_name = '',\n\t\t\thandler_link = null,\n\t\t\tkind = null,\n\t\t\tbreed = null,\n\t\t\tbehaviour = null,\n\t\t\tdescription = null,\n\t\t\tchassis_type = null,\n\t\t\tchassis_id = null,\n\t\t\tchassis_color = null,\n\t\t\tupdated_at = current_timestamp,\n\t\t\tarchived_at = current_timestamp\n\t\t\twhere id = $1\n\t\t",
|
||||
"query": "\n\t\t\tupdate doll_profiles\n\t\t\tset microchip_id = null,\n\t\t\tname = '',\n\t\t\tpronoun_subject = '',\n\t\t\tpronoun_object = '',\n\t\t\tpronoun_possessive = '',\n\t\t\thandler_name = '',\n\t\t\thandler_link = null,\n\t\t\tkind = null,\n\t\t\tbreed = null,\n\t\t\tbehaviour = null,\n\t\t\tdescription = null,\n\t\t\tchassis_type = null,\n\t\t\tchassis_id = null,\n\t\t\tchassis_color = null,\n\t\t\tis_public = false,\n\t\t\tupdated_at = current_timestamp,\n\t\t\tarchived_at = current_timestamp\n\t\t\twhere id = $1 and bound_to_id = $2\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4"
|
||||
"Int4",
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "cff8deaa5dcc8b79c137cb968454e0404efe284597436a710a3377ca46a5f165"
|
||||
"hash": "baec493b4eb0998f3e5e0be8b8bb4740630a222b3bef2d7d32ac61928734d30d"
|
||||
}
|
12
.sqlx/query-cefd051704ad8d6fa2e60b6b6b0e74b16cff4a8a8942ae562f16d7e590ef0714.json
generated
Normal file
12
.sqlx/query-cefd051704ad8d6fa2e60b6b6b0e74b16cff4a8a8942ae562f16d7e590ef0714.json
generated
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "insert into doll_profiles\n(id, name, pronoun_subject, pronoun_object, pronoun_possessive, handler_name, handler_link, kind, breed, behaviour, chassis_type, chassis_id, chassis_color, bound_to_id, description)\nvalues\n(\n\t134621, 'Raven', 'it', 'it', 'its', 'Walter', 'https://armoredcore.fandom.com/wiki/Handler_Walter', 'Combat Doll', null, 'neutral', 'AC LOADER 4', 'C4-621', 'Grey', '1f35abcd-2997-41d4-80cf-6297a8d92eae',\n\t'Augmented Human C4-621, aka \"621\", is a mercenary Armored Core pilot active during the Coral War. 621 has undergone extensive biological and cybernetic operations which augmented piloting skills and limited ability to experience human emotions.\\n\"C4\" refers to the fourth generation of Augmented Humans, while \"621\" is a serial designation.'\n);\n",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "cefd051704ad8d6fa2e60b6b6b0e74b16cff4a8a8942ae562f16d7e590ef0714"
|
||||
}
|
20
.sqlx/query-d7e0e15fc33fcbc2576807014123229bc099c1fa92fef2617fb58ed65a54e480.json
generated
Normal file
20
.sqlx/query-d7e0e15fc33fcbc2576807014123229bc099c1fa92fef2617fb58ed65a54e480.json
generated
Normal file
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select count(id) from doll_profiles where archived_at is null",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "d7e0e15fc33fcbc2576807014123229bc099c1fa92fef2617fb58ed65a54e480"
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\t\t\tselect * from doll_profiles where (id = $1 or microchip_id = $2)\n\t\t\t",
|
||||
"query": "select * from doll_profiles where bound_to_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
|
@ -97,12 +97,16 @@
|
|||
"ordinal": 18,
|
||||
"name": "archived_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "is_public",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4",
|
||||
"Text"
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
|
@ -124,8 +128,9 @@
|
|||
true,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b032b09996d538f01c221d2d2b09563c1a0a9164416b3f5c08de54c2f5b19fa4"
|
||||
"hash": "e3234918965fd36a56fc48fc78956b53c8a84f08e0424d63e930f9dcfc449175"
|
||||
}
|
12
.sqlx/query-edc1a391382e8cc6dcdf87ed82cceee8d94039c4330601449834c44979e68e93.json
generated
Normal file
12
.sqlx/query-edc1a391382e8cc6dcdf87ed82cceee8d94039c4330601449834c44979e68e93.json
generated
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "truncate doll_profiles, users;",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "edc1a391382e8cc6dcdf87ed82cceee8d94039c4330601449834c44979e68e93"
|
||||
}
|
17
.sqlx/query-f1342bb493917895ddd01eab26cc6cd3f50c126d95d1008fefb20a689256ea2c.json
generated
Normal file
17
.sqlx/query-f1342bb493917895ddd01eab26cc6cd3f50c126d95d1008fefb20a689256ea2c.json
generated
Normal file
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "insert into otp (user_id, otp_method, secret_seed, recovery_key) values ($1, $2, $3, $4)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Varchar",
|
||||
"Bpchar",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f1342bb493917895ddd01eab26cc6cd3f50c126d95d1008fefb20a689256ea2c"
|
||||
}
|
137
.sqlx/query-f46606ed7e634ac9ef528348868b4f05eca9df12fda01f9042939aca4689d3f1.json
generated
Normal file
137
.sqlx/query-f46606ed7e634ac9ef528348868b4f05eca9df12fda01f9042939aca4689d3f1.json
generated
Normal file
|
@ -0,0 +1,137 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select * from doll_profiles where (id = $1 or microchip_id = $2) and archived_at is null and is_public is true",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "microchip_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "updated_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "pronoun_subject",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "pronoun_object",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "pronoun_possessive",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "handler_name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "handler_link",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "kind",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "breed",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "behaviour",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "description",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "chassis_type",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "chassis_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"name": "chassis_color",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "bound_to_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 18,
|
||||
"name": "archived_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "is_public",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f46606ed7e634ac9ef528348868b4f05eca9df12fda01f9042939aca4689d3f1"
|
||||
}
|
15
.sqlx/query-f5e08fe2f3c38631022a83e0038257905b21581564499b5d44d3c7448fb3fb50.json
generated
Normal file
15
.sqlx/query-f5e08fe2f3c38631022a83e0038257905b21581564499b5d44d3c7448fb3fb50.json
generated
Normal file
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "update doll_profiles set bound_to_id = $1 where id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f5e08fe2f3c38631022a83e0038257905b21581564499b5d44d3c7448fb3fb50"
|
||||
}
|
106
.vscode/launch.json
vendored
106
.vscode/launch.json
vendored
|
@ -1,45 +1,65 @@
|
|||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"name": "Debug executable 'dolltags'",
|
||||
"cargo": {
|
||||
"args": [
|
||||
"build",
|
||||
"--bin=dolltags",
|
||||
"--package=dolltags"
|
||||
],
|
||||
"filter": {
|
||||
"name": "dolltags",
|
||||
"kind": "bin"
|
||||
}
|
||||
},
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"name": "Debug unit tests in executable 'dolltags'",
|
||||
"cargo": {
|
||||
"args": [
|
||||
"test",
|
||||
"--no-run",
|
||||
"--bin=dolltags",
|
||||
"--package=dolltags"
|
||||
],
|
||||
"filter": {
|
||||
"name": "dolltags",
|
||||
"kind": "bin"
|
||||
}
|
||||
},
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}"
|
||||
}
|
||||
]
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"name": "Debug executable 'dolltags'",
|
||||
"cargo": {
|
||||
"args": [
|
||||
"build",
|
||||
"--bin=dolltags",
|
||||
"--package=dolltags"
|
||||
],
|
||||
"filter": {
|
||||
"name": "dolltags",
|
||||
"kind": "bin"
|
||||
}
|
||||
},
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"name": "Debug unit tests in executable 'dolltags'",
|
||||
"cargo": {
|
||||
"args": [
|
||||
"test",
|
||||
"--no-run",
|
||||
"--bin=dolltags",
|
||||
"--package=dolltags"
|
||||
],
|
||||
"filter": {
|
||||
"name": "dolltags",
|
||||
"kind": "bin"
|
||||
}
|
||||
},
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"name": "Re-seed DB",
|
||||
"cargo": {
|
||||
"args": [
|
||||
"build",
|
||||
"--bin=dolltags",
|
||||
"--package=dolltags"
|
||||
],
|
||||
"filter": {
|
||||
"name": "dolltags",
|
||||
"kind": "bin"
|
||||
}
|
||||
},
|
||||
"args": [
|
||||
"reseed-db"
|
||||
],
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
]
|
||||
}
|
44
CHANGELOG.md
Normal file
44
CHANGELOG.md
Normal file
|
@ -0,0 +1,44 @@
|
|||
# Changelog
|
||||
|
||||
This will try to keep history of all changes and document impacts in deployment / hosting.
|
||||
All versions before v1.1.0 are ignored as the tool was only ready-ish at v1.1.0
|
||||
|
||||
## v2.0.0
|
||||
|
||||
breaking change: configuration changes
|
||||
|
||||
- added*: qrcode generation of tags
|
||||
- added: admin prod deployment instructions
|
||||
|
||||
the qrcode generation of tags introduces a new required configuration key, `default.public_url`.
|
||||
This should contain the canonical base URL of your instance (e.g. `https://dolltags.pet/`).
|
||||
|
||||
Example below.
|
||||
|
||||
```toml
|
||||
[default]
|
||||
secret_key=...
|
||||
public_url="https://dolltags.pet/"
|
||||
|
||||
[...]
|
||||
```
|
||||
|
||||
## v1.3.0 - 2025-02-07
|
||||
|
||||
- added: social tags; sharing in a social network should give a small preview of the base details
|
||||
|
||||
## v1.2.0 - 2025-02-05
|
||||
|
||||
- [dev]: implemented DB reseed as a CLI tool for test environments
|
||||
- added: an about page detailing some of the project notes and documenting things such as the data export format
|
||||
- fixed: bumped the audit logs level to warn
|
||||
- fixed: a tag's description will now properly render line returns
|
||||
- fixed: making passwords computed no matter what to avoid timing attacks
|
||||
|
||||
## v1.1.0 - 2025-01-27
|
||||
|
||||
First stable version
|
||||
|
||||
- base dolltags system
|
||||
- syslog audit logs
|
||||
- base admin accounts
|
527
Cargo.lock
generated
527
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -21,3 +21,6 @@ serde_json = "1.0"
|
|||
anyhow = "1.0"
|
||||
orion = "0.17"
|
||||
uuid = { version = "1.11", features = ["serde"] }
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
qrcode-generator = "5"
|
||||
totp-rs = { version = "5.6", features = ["gen_secret", "qr"] }
|
||||
|
|
4
LICENSE
4
LICENSE
|
@ -1,5 +1,5 @@
|
|||
Aphrodite
|
||||
Copyright Scarlet 2021
|
||||
Doll.Tags
|
||||
Copyright Scarlet 2025
|
||||
|
||||
COOPERATIVE NON-VIOLENT PUBLIC LICENSE v6
|
||||
|
||||
|
|
45
README.md
45
README.md
|
@ -1,5 +1,7 @@
|
|||
# dolltags
|
||||
|
||||
> [official docs&blog repo](https://git.sr.ht/~artemis/dolltags-doc)
|
||||
|
||||
like dog tags but for dolls and entities of all kinds.
|
||||
|
||||
a db for registering dolls and their handlers, dog tags style.
|
||||
|
@ -18,9 +20,42 @@ a profile is
|
|||
- microchipped
|
||||
- and whatever else i think of
|
||||
|
||||
# TODOs
|
||||
# Setting it up for prod
|
||||
|
||||
- p2: privacy policy and GDPR notice
|
||||
- p2: saving register form as it gets filled / re-display it with partial values
|
||||
- account
|
||||
- p2: optional email for forgotten password i guess
|
||||
**The packaging is currently not up to date on NUR due to issues with NUR. it will be packaged on my own repository soon.**
|
||||
|
||||
The project is [packaged for NixOS](https://nur.nix-community.org/repos/arteneko/); if you don't use NixOS, you will need to compile it yourself with the rust toolchain.
|
||||
|
||||
This project requires rust (stable, at least v1.84.0 which is the version i use) and cargo, as well as a postgresql database.
|
||||
|
||||
```
|
||||
$ git clone https://git.deuxfleurs.fr/StardustShard/dolltags
|
||||
$ cd dolltags
|
||||
$ cargo build --release
|
||||
```
|
||||
|
||||
You will also need to bundle the `assets/` and `templates/` directory.
|
||||
|
||||
In both cases, the daemon/CLI requires a configuration file, by default named `Rocket.toml` and loaded from the CWD but which can be pointed to a custom file using the `ROCKET_CONFIG` environment variable.
|
||||
Likewise, the assets and template directories will be by default loaded from the CWD but can be pointed to a custom directory using the environment variable `ASSETS_PATH`.
|
||||
However, the template directory is expected to be in the CWD with no way to change it as-is for now (it will try to search in `$PWD/templates`).
|
||||
|
||||
The configuration file requires three keys, shown below.
|
||||
|
||||
```toml
|
||||
[default]
|
||||
secret_key = "use `openssl rand -base64 32` to generate it"
|
||||
public_url = "set the canonical url to your instance here"
|
||||
|
||||
[default.databases.dolltags]
|
||||
url = "postgres://dolltags:password@localhost/dolltags"
|
||||
```
|
||||
|
||||
- `secret_key` is required to securely handle the user sessions; changing the secret key will immediately disconnect any previously-working session
|
||||
- `public_url` is required to generate the QrCode image URLs that are used to allow users to download and use those QrCodes wherever they want to offer direct access (e.g. pet tags, ID cards, etc). Example: `public_url="https://dolltags.pet/"`
|
||||
- `url` for the database follows the PostgreSQL connection URL format and should support all its arguments and variants
|
||||
|
||||
Once you have it deployed, to get an admin account there is currently no easy way to promote a user so the current procedure is the following.
|
||||
|
||||
1. create your (to be admin) account
|
||||
2. in your server's postgresql terminal, set the `is_admin` flag to true on your user, e.g. by doing `UPDATE users SET is_admin = true WHERE username = 'your-username';`
|
||||
|
|
|
@ -1,2 +0,0 @@
|
|||
[default.databases.dolltags]
|
||||
url = "postgres://postgres:woofwoof@localhost/dolltags"
|
6
Rocket.toml.dev
Normal file
6
Rocket.toml.dev
Normal file
|
@ -0,0 +1,6 @@
|
|||
[default]
|
||||
secret_key = "8STDFCStGMYGoOq8RJf3JJXsg4p6wZVAph50R3Fbq6U="
|
||||
public_url = "http://localhost:8000/"
|
||||
|
||||
[default.databases.dolltags]
|
||||
url = "postgres://%2Frun%2Fpostgresql/dolltags"
|
6
TODO.md
Normal file
6
TODO.md
Normal file
|
@ -0,0 +1,6 @@
|
|||
1. change the tag access status filter into a struct rust-side
|
||||
2. go over all access routes currently in place and double-check / update them
|
||||
3. add a way to discern public and private tags inside the tag list (account/index)
|
||||
4. add a way to toggle that (account/index)
|
||||
5. tag create / edit form: add a "save in private" btn that saves the tag but in private mode
|
||||
6. audit: private/public switch
|
|
@ -56,11 +56,19 @@ picture.block>img {
|
|||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0 auto 2em auto;
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin: 2em 1em;
|
||||
padding-top: .5em;
|
||||
border-top: 1pt solid var(--clr-primary-a50);
|
||||
font-size: 0.9em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
ul,
|
||||
|
@ -68,6 +76,10 @@ ol {
|
|||
padding-left: 2ch;
|
||||
}
|
||||
|
||||
ol>li:not(:last-of-type) {
|
||||
margin-bottom: .5em;
|
||||
}
|
||||
|
||||
h1>a {
|
||||
text-decoration: none;
|
||||
color: var(--clr-txt-on-dark);
|
||||
|
@ -77,6 +89,10 @@ a {
|
|||
color: var(--clr-error-primary-40);
|
||||
}
|
||||
|
||||
a[target="_blank"][rel="noopener"]::after {
|
||||
content: '🗗';
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
button,
|
||||
|
@ -86,6 +102,7 @@ textarea,
|
|||
border-radius: 4pt;
|
||||
padding: 4pt 8pt;
|
||||
background-color: var(--clr-surface-tonal-a10);
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
input,
|
||||
|
@ -106,11 +123,15 @@ input:hover,
|
|||
select:hover,
|
||||
button:hover,
|
||||
textarea:hover,
|
||||
button:hover,
|
||||
.btn:hover {
|
||||
border-color: var(--clr-primary-a0);
|
||||
}
|
||||
|
||||
button:hover,
|
||||
.btn:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
font-size: 1em;
|
||||
|
@ -140,7 +161,8 @@ section {
|
|||
|
||||
p.form-error,
|
||||
div.form-error,
|
||||
a.error {
|
||||
a.error,
|
||||
button.error {
|
||||
background-color: var(--clr-error-surface);
|
||||
border: 2pt solid var(--clr-error-primary-0);
|
||||
color: var(--clr-error-primary-40);
|
||||
|
@ -148,6 +170,15 @@ a.error {
|
|||
padding: .5em 1em;
|
||||
}
|
||||
|
||||
a.error:hover,
|
||||
button.error:hover,
|
||||
a.error:focus,
|
||||
button.error:focus,
|
||||
a.error:active,
|
||||
button.error:active {
|
||||
border-color: var(--clr-error-primary-40);
|
||||
}
|
||||
|
||||
p.note {
|
||||
font-size: .8em;
|
||||
}
|
||||
|
@ -250,8 +281,14 @@ p.note {
|
|||
font-size: .9em;
|
||||
}
|
||||
|
||||
div.submit {
|
||||
margin: 2em 0;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
button.submit {
|
||||
margin: 2em auto;
|
||||
font-size: 1.2em;
|
||||
display: block;
|
||||
}
|
||||
|
@ -268,13 +305,15 @@ button.submit {
|
|||
}
|
||||
|
||||
input#ident,
|
||||
input#microchip_id {
|
||||
input#microchip_id,
|
||||
input.id {
|
||||
font-family: monospace;
|
||||
font-size: 1.6em;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
input#ident {
|
||||
input#ident,
|
||||
input.id {
|
||||
width: 6ch;
|
||||
}
|
||||
|
||||
|
@ -300,7 +339,7 @@ input#ident {
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
.profile .ident,
|
||||
.ident,
|
||||
.handler {
|
||||
text-transform: uppercase;
|
||||
font-size: .8em;
|
||||
|
@ -354,6 +393,28 @@ input#ident {
|
|||
font-size: 2em;
|
||||
}
|
||||
|
||||
.totp-qrcode>img {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
height: 160pt;
|
||||
}
|
||||
|
||||
.totp-form input {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
pre.recovery-key {
|
||||
text-align: center;
|
||||
background-color: var(--clr-surface-tonal-a10);
|
||||
border: 2pt solid var(--clr-surface-tonal-a50);
|
||||
border-radius: 4pt;
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
padding: .5em 2em;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 400px) {
|
||||
header.padded>nav {
|
||||
display: flex;
|
||||
|
@ -364,9 +425,13 @@ input#ident {
|
|||
p.subnav {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.split {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 700px) {
|
||||
@media screen and (max-width: 800px) {
|
||||
div.dual-fields {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
@ -388,4 +453,10 @@ input#ident {
|
|||
div.fields.submit {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 800px) {
|
||||
.dual-fields>*:last-child {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
2
migrations/6_admin_status.sql
Normal file
2
migrations/6_admin_status.sql
Normal file
|
@ -0,0 +1,2 @@
|
|||
alter table users
|
||||
add column is_admin boolean not null default false;
|
1
migrations/7_fix_legacy_user_perms.sql
Normal file
1
migrations/7_fix_legacy_user_perms.sql
Normal file
|
@ -0,0 +1 @@
|
|||
update users set enabled = false where username = 'legacy';
|
11
migrations/8_totp.sql
Normal file
11
migrations/8_totp.sql
Normal file
|
@ -0,0 +1,11 @@
|
|||
create table otp (
|
||||
user_id uuid not null,
|
||||
created_at timestamptz not null default current_timestamp,
|
||||
-- enum str on client side
|
||||
otp_method varchar(32) not null,
|
||||
-- 160bit base32-encoded key
|
||||
secret_seed char(32) not null,
|
||||
recovery_key varchar(512) not null,
|
||||
primary key (user_id, otp_method),
|
||||
foreign key (user_id) references users (id) on delete cascade
|
||||
);
|
2
migrations/9_private_tags.sql
Normal file
2
migrations/9_private_tags.sql
Normal file
|
@ -0,0 +1,2 @@
|
|||
alter table doll_profiles
|
||||
add column is_public boolean not null default true;
|
|
@ -1,2 +1,3 @@
|
|||
pub mod otp;
|
||||
pub mod pw;
|
||||
pub mod session;
|
||||
|
|
53
src/auth/otp.rs
Normal file
53
src/auth/otp.rs
Normal file
|
@ -0,0 +1,53 @@
|
|||
use rocket::{
|
||||
http::{Cookie, CookieJar},
|
||||
time::Duration,
|
||||
};
|
||||
use totp_rs::{Secret, TOTP};
|
||||
|
||||
const ISSUER: &'static str = "dolltags";
|
||||
|
||||
fn make_cookie<'a>(value: String) -> Cookie<'a> {
|
||||
Cookie::build(("otp_init", value))
|
||||
.max_age(Duration::minutes(5))
|
||||
.http_only(true)
|
||||
.same_site(rocket::http::SameSite::Strict)
|
||||
.path("/account/settings")
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Creates a TOTP instance using in-code defaults and user OTP settings.
|
||||
/// i use the internal user account UUID instead of their username to cover the case
|
||||
/// where a user needs to change their account username.
|
||||
pub fn make_totp(account_id: &str, secret: Vec<u8>) -> Result<TOTP, totp_rs::TotpUrlError> {
|
||||
TOTP::new(
|
||||
totp_rs::Algorithm::SHA1,
|
||||
6,
|
||||
1,
|
||||
30,
|
||||
secret,
|
||||
Some(String::from(ISSUER)),
|
||||
String::from(account_id),
|
||||
)
|
||||
}
|
||||
|
||||
/// Adds the provided OTP secret as encrypted cookie for 5 minutes
|
||||
pub fn cache_secret<'a>(cookies: &CookieJar<'a>, secret: &'a Secret) {
|
||||
if let Secret::Encoded(b32) = secret.to_encoded() {
|
||||
cookies.add_private(make_cookie(b32));
|
||||
}
|
||||
}
|
||||
|
||||
/// Tries to get a potentially existing secret from the cookies.
|
||||
/// If no cookie is set or if the cookie's value doesn't parse to
|
||||
/// a valid base32 secret this returns None
|
||||
pub fn get_secret(cookies: &CookieJar<'_>) -> Option<Secret> {
|
||||
cookies.get_private("otp_init").and_then(|cookie| {
|
||||
let val = cookie.value_trimmed();
|
||||
Secret::Encoded(String::from(val)).to_raw().ok()
|
||||
})
|
||||
}
|
||||
|
||||
/// Manually revokes the encrypted cookie
|
||||
pub fn remove_secret(cookies: &CookieJar<'_>) {
|
||||
cookies.remove_private(make_cookie(String::from("woof")))
|
||||
}
|
|
@ -1,11 +1,14 @@
|
|||
use std::str::FromStr;
|
||||
|
||||
use rocket::{
|
||||
http::{CookieJar, Status},
|
||||
http::{Cookie, CookieJar, Status},
|
||||
outcome::try_outcome,
|
||||
request::{FromRequest, Outcome},
|
||||
response::Redirect,
|
||||
time::Duration,
|
||||
Request,
|
||||
};
|
||||
use rocket_dyn_templates::{context, Template};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::db::{
|
||||
|
@ -64,11 +67,11 @@ impl<'a> FromRequest<'a> for User {
|
|||
let cookies = req.cookies();
|
||||
|
||||
if let Some(id) = check_login(&cookies) {
|
||||
let db = DollTagsDb::from_request(req)
|
||||
let mut db = DollTagsDb::from_request(req)
|
||||
.await
|
||||
.expect("User::from_request cannot get DB connection");
|
||||
|
||||
match user::get_by_id(db, &id).await {
|
||||
match user::get_by_id(&mut *db, &id).await {
|
||||
Err(err) => {
|
||||
error!("User::from_request internal error: {:?}", err);
|
||||
Outcome::Error((Status::InternalServerError, SessionInternalFailure()))
|
||||
|
@ -88,8 +91,79 @@ impl<'a> FromRequest<'a> for User {
|
|||
}
|
||||
}
|
||||
|
||||
/// A specialization of User as a [`FromRequest`] guard to only trigger when the user is logged in,
|
||||
/// checked as okay in DB, and is marked as an admin user.
|
||||
#[derive(Debug)]
|
||||
pub struct Admin(pub User);
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<'a> FromRequest<'a> for Admin {
|
||||
type Error = SessionInternalFailure;
|
||||
|
||||
async fn from_request(req: &'a Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
let user = try_outcome!(req.guard::<User>().await);
|
||||
|
||||
if user.is_admin {
|
||||
Outcome::Success(Admin(user))
|
||||
} else {
|
||||
Outcome::Forward(Status::Forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a 2FA auth session cookie with short lifespan and a bit of enforcing
|
||||
fn make_2fa_auth_cookie<'a>(value: String) -> Cookie<'a> {
|
||||
Cookie::build(("2fa_auth", value))
|
||||
.max_age(Duration::minutes(5))
|
||||
.http_only(true)
|
||||
.same_site(rocket::http::SameSite::Strict)
|
||||
.path("/login")
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Starts a 2FA auth session for a login, caching the user ID in a temp. short-lived session
|
||||
pub fn init_2fa_auth(cookies: &CookieJar<'_>, user_id: &Uuid) {
|
||||
cookies.add_private(make_2fa_auth_cookie(user_id.to_string()));
|
||||
}
|
||||
|
||||
/// Gets the user id from the currently active auth session, or nothing otherwise
|
||||
pub fn check_2fa_auth(cookies: &CookieJar<'_>) -> Option<Uuid> {
|
||||
cookies
|
||||
.get_private("2fa_auth")
|
||||
.and_then(|v| Uuid::from_str(&v.value()).ok())
|
||||
}
|
||||
|
||||
/// Clears the auth session
|
||||
pub fn clear_2fa_auth(cookies: &CookieJar<'_>) {
|
||||
cookies.remove_private(make_2fa_auth_cookie(String::new()));
|
||||
}
|
||||
|
||||
/// [`AuthSession`] is used as a temporary user ID storage during the login procedures.
|
||||
/// It retrieves its data from a private cookie from the cookie jar
|
||||
#[derive(Debug)]
|
||||
pub struct AuthSession(pub Uuid);
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<'a> FromRequest<'a> for AuthSession {
|
||||
type Error = ();
|
||||
|
||||
async fn from_request(req: &'a Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
let cookies = req.cookies();
|
||||
|
||||
match check_2fa_auth(cookies) {
|
||||
Some(v) => Outcome::Success(AuthSession(v)),
|
||||
None => Outcome::Forward(Status::Unauthorized),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[catch(401)]
|
||||
pub fn unauthorized(req: &Request) -> Redirect {
|
||||
let next = req.uri().to_string();
|
||||
Redirect::to(uri!(crate::routes::form::accounts::show_login(Some(&next))))
|
||||
}
|
||||
|
||||
#[catch(403)]
|
||||
pub fn forbidden(_: &Request) -> Template {
|
||||
Template::render("error/forbidden", context! {})
|
||||
}
|
||||
|
|
72
src/cli.rs
Normal file
72
src/cli.rs
Normal file
|
@ -0,0 +1,72 @@
|
|||
use std::io::stdin;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use clap::{Parser, Subcommand};
|
||||
use rocket::{Orbit, Rocket};
|
||||
use rocket_db_pools::Database;
|
||||
|
||||
use crate::db::{reseed::reseed_db, schema::DollTags};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(about = "dolltags.pet admin and management CLI", long_about = None)]
|
||||
pub struct AdminCli {
|
||||
#[command(subcommand)]
|
||||
command: Option<Commands>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum Commands {
|
||||
/// [DESTRUCTIVE] Re-seeds the dolltags database after deleting all its content
|
||||
ReseedDb {},
|
||||
}
|
||||
|
||||
pub async fn handle_cli<'a>(
|
||||
command: Commands,
|
||||
rocket: &'a Rocket<Orbit>,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
match command {
|
||||
Commands::ReseedDb {} => {
|
||||
println!("Reseeding the DB means deleting everything currently in it then applying the test seed data,");
|
||||
println!("are you aware of the risks and connected to the right database? (write Y or YES in caps to confirm, anything else to cancel)");
|
||||
|
||||
let mut input = String::new();
|
||||
stdin().read_line(&mut input).expect("unable to read stdin");
|
||||
input = String::from(input.trim());
|
||||
|
||||
if input == "Y" || input == "YES" {
|
||||
println!("Resetting/Reseeding the DB");
|
||||
let db = match DollTags::fetch(&rocket) {
|
||||
Some(v) => v,
|
||||
None => return Err(anyhow!("couldn't get a db hook")),
|
||||
};
|
||||
let mut trx = db.begin().await?;
|
||||
reseed_db(&mut trx).await?;
|
||||
trx.commit().await?;
|
||||
|
||||
println!("Reset and reseeded the DB");
|
||||
} else {
|
||||
println!("Cancelled the reset/reseed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn check_cli_invocation<'a>(rocket: &'a Rocket<Orbit>) -> () {
|
||||
let cli = AdminCli::parse();
|
||||
let shutdown = rocket.shutdown();
|
||||
|
||||
match cli.command {
|
||||
None => (),
|
||||
Some(cmd) => {
|
||||
if let Err(e) = handle_cli(cmd, rocket).await {
|
||||
error!("failed to handle command: {:?}", e);
|
||||
}
|
||||
|
||||
shutdown.notify();
|
||||
}
|
||||
}
|
||||
|
||||
()
|
||||
}
|
7
src/config.rs
Normal file
7
src/config.rs
Normal file
|
@ -0,0 +1,7 @@
|
|||
use rocket::serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct Config {
|
||||
pub public_url: String,
|
||||
}
|
35
src/db/admin.rs
Normal file
35
src/db/admin.rs
Normal file
|
@ -0,0 +1,35 @@
|
|||
use uuid::Uuid;
|
||||
|
||||
use super::schema::{DbHook, ServiceStatus, TrxHook};
|
||||
|
||||
/// Aggregates info on the current service status, incl. accounts and tags
|
||||
pub async fn get_service_status(trx: &mut TrxHook<'_>) -> sqlx::Result<ServiceStatus> {
|
||||
let active_accounts_count =
|
||||
sqlx::query_scalar!("select count(id) from users where id != '00000000-0000-0000-0000-000000000000' and enabled is true")
|
||||
.fetch_one(&mut **trx)
|
||||
.await?
|
||||
.unwrap_or(0);
|
||||
|
||||
let active_tags_count =
|
||||
sqlx::query_scalar!("select count(id) from doll_profiles where archived_at is null")
|
||||
.fetch_one(&mut **trx)
|
||||
.await?
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(ServiceStatus {
|
||||
active_accounts_count,
|
||||
active_tags_count,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn handover_tag(db: &mut DbHook, tag_id: i32, target_user_id: &Uuid) -> sqlx::Result<()> {
|
||||
sqlx::query!(
|
||||
"update doll_profiles set bound_to_id = $1 where id = $2",
|
||||
target_user_id,
|
||||
tag_id
|
||||
)
|
||||
.execute(&mut **db)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
107
src/db/doll.rs
107
src/db/doll.rs
|
@ -3,6 +3,7 @@ use uuid::{uuid, Uuid};
|
|||
|
||||
use super::schema::{CreateDollProfile, DbHook, TrxHook};
|
||||
|
||||
/// Lists all the unarchived tags this account has
|
||||
pub async fn list(db: &mut DbHook, from: &Uuid) -> sqlx::Result<Vec<DollProfile>> {
|
||||
sqlx::query_as!(
|
||||
DollProfile,
|
||||
|
@ -13,6 +14,7 @@ pub async fn list(db: &mut DbHook, from: &Uuid) -> sqlx::Result<Vec<DollProfile>
|
|||
.await
|
||||
}
|
||||
|
||||
/// Lists the IDs of archived tags that are bound to this account
|
||||
pub async fn list_archived(db: &mut DbHook, from: &Uuid) -> sqlx::Result<Vec<i32>> {
|
||||
sqlx::query_scalar!(
|
||||
"select id from doll_profiles where bound_to_id = $1 and archived_at is not null",
|
||||
|
@ -22,37 +24,49 @@ pub async fn list_archived(db: &mut DbHook, from: &Uuid) -> sqlx::Result<Vec<i32
|
|||
.await
|
||||
}
|
||||
|
||||
/// Lists all the user's tags
|
||||
pub async fn list_all(db: &mut DbHook, from: &Uuid) -> sqlx::Result<Vec<DollProfile>> {
|
||||
sqlx::query_as!(
|
||||
DollProfile,
|
||||
"select * from doll_profiles where bound_to_id = $1",
|
||||
from
|
||||
)
|
||||
.fetch_all(&mut **db)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Tries to get the requested tag based on the provided ID or microchip ID.
|
||||
/// Will only include public non-archived tags.
|
||||
pub async fn get_public(
|
||||
db: &mut DbHook,
|
||||
ident: i32,
|
||||
microchip_id: &str,
|
||||
) -> sqlx::Result<Option<DollProfile>> {
|
||||
sqlx::query_as!(
|
||||
DollProfile,
|
||||
r#"select * from doll_profiles where (id = $1 or microchip_id = $2) and archived_at is null and is_public is true"#,
|
||||
ident,
|
||||
microchip_id
|
||||
).fetch_optional(&mut **db)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get(
|
||||
db: &mut DbHook,
|
||||
ident: i32,
|
||||
microchip_id: &str,
|
||||
include_archived: bool,
|
||||
) -> sqlx::Result<Option<DollProfile>> {
|
||||
if include_archived {
|
||||
sqlx::query_as!(
|
||||
DollProfile,
|
||||
r#"
|
||||
select * from doll_profiles where (id = $1 or microchip_id = $2)
|
||||
"#,
|
||||
ident,
|
||||
microchip_id
|
||||
)
|
||||
.fetch_optional(&mut **db)
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as!(
|
||||
DollProfile,
|
||||
r#"
|
||||
select * from doll_profiles where (id = $1 or microchip_id = $2) and archived_at is null
|
||||
"#,
|
||||
ident,
|
||||
microchip_id
|
||||
)
|
||||
.fetch_optional(&mut **db)
|
||||
.await
|
||||
}
|
||||
sqlx::query_as!(
|
||||
DollProfile,
|
||||
r#"select * from doll_profiles where (id = $1 or microchip_id = $2)"#,
|
||||
ident,
|
||||
microchip_id
|
||||
)
|
||||
.fetch_optional(&mut **db)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Checks if some of the provided IDs are already used, returning the list of IDs that are used
|
||||
pub async fn check_ids(db: &mut DbHook, idents: &Vec<i32>) -> sqlx::Result<Vec<i32>> {
|
||||
sqlx::query_scalar!(
|
||||
"select id from doll_profiles where id in (select * from unnest($1::int[]))",
|
||||
|
@ -62,23 +76,23 @@ pub async fn check_ids(db: &mut DbHook, idents: &Vec<i32>) -> sqlx::Result<Vec<i
|
|||
.await
|
||||
}
|
||||
|
||||
pub async fn id_exists(db: &mut DbHook, ident: i32, microchip_id: &str) -> sqlx::Result<bool> {
|
||||
Ok(sqlx::query!(
|
||||
"select id from doll_profiles where id = $1 or microchip_id = $2",
|
||||
ident,
|
||||
microchip_id
|
||||
/// Checks if a tag exists with this ID (in global, collision check method)
|
||||
pub async fn id_exists(db: &mut DbHook, ident: i32) -> sqlx::Result<bool> {
|
||||
Ok(
|
||||
sqlx::query!("select id from doll_profiles where id = $1", ident)
|
||||
.fetch_optional(&mut **db)
|
||||
.await?
|
||||
.is_some(),
|
||||
)
|
||||
.fetch_optional(&mut **db)
|
||||
.await?
|
||||
.is_some())
|
||||
}
|
||||
|
||||
/// Creates a new tag using the form data from [`CreateDollProfile`]
|
||||
pub async fn create(db: &mut DbHook, doll: CreateDollProfile<'_>) -> sqlx::Result<()> {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
insert into doll_profiles
|
||||
(id, microchip_id, name, pronoun_subject, pronoun_object, pronoun_possessive, handler_name, handler_link, kind, breed, behaviour, description, chassis_type, chassis_id, chassis_color, bound_to_id)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
(id, microchip_id, name, pronoun_subject, pronoun_object, pronoun_possessive, handler_name, handler_link, kind, breed, behaviour, description, chassis_type, chassis_id, chassis_color, bound_to_id, is_public)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||
"#,
|
||||
doll.id,
|
||||
doll.microchip_id,
|
||||
|
@ -96,13 +110,19 @@ pub async fn create(db: &mut DbHook, doll: CreateDollProfile<'_>) -> sqlx::Resul
|
|||
doll.chassis_id,
|
||||
doll.chassis_color,
|
||||
doll.bound_to_id,
|
||||
doll.is_public,
|
||||
).execute(&mut **db).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Edits the given tag with the create/edit form,
|
||||
/// editing a doll_profile will also unarchive it
|
||||
pub async fn edit(db: &mut DbHook, doll: CreateDollProfile<'_>) -> sqlx::Result<()> {
|
||||
pub async fn edit(
|
||||
db: &mut DbHook,
|
||||
bound_account_id: &Uuid,
|
||||
doll: CreateDollProfile<'_>,
|
||||
) -> sqlx::Result<()> {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
update doll_profiles
|
||||
|
@ -120,9 +140,10 @@ pub async fn edit(db: &mut DbHook, doll: CreateDollProfile<'_>) -> sqlx::Result<
|
|||
chassis_type = $12,
|
||||
chassis_id = $13,
|
||||
chassis_color = $14,
|
||||
is_public = $15,
|
||||
archived_at = null,
|
||||
updated_at = current_timestamp
|
||||
where id = $15
|
||||
where id = $16 and bound_to_id = $17
|
||||
"#,
|
||||
doll.microchip_id,
|
||||
doll.name,
|
||||
|
@ -138,7 +159,9 @@ pub async fn edit(db: &mut DbHook, doll: CreateDollProfile<'_>) -> sqlx::Result<
|
|||
doll.chassis_type,
|
||||
doll.chassis_id,
|
||||
doll.chassis_color,
|
||||
doll.is_public,
|
||||
doll.id,
|
||||
bound_account_id
|
||||
)
|
||||
.execute(&mut **db)
|
||||
.await?;
|
||||
|
@ -146,7 +169,7 @@ pub async fn edit(db: &mut DbHook, doll: CreateDollProfile<'_>) -> sqlx::Result<
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// deleting a doll profile only wipes the data associated to it but retains two bits of info:
|
||||
/// deleting (or archiving) a doll profile only wipes the data associated to it but retains two bits of info:
|
||||
/// - the tag's ID
|
||||
/// - the account which created this tag
|
||||
///
|
||||
|
@ -154,7 +177,7 @@ pub async fn edit(db: &mut DbHook, doll: CreateDollProfile<'_>) -> sqlx::Result<
|
|||
/// the account holder to "re-create" one with this ID.
|
||||
///
|
||||
/// A period of time after which deleted accounts will have their IDs freed is to be set.
|
||||
pub async fn delete(trx: &mut TrxHook<'_>, id: i32) -> sqlx::Result<()> {
|
||||
pub async fn delete(trx: &mut TrxHook<'_>, id: i32, bound_account_id: &Uuid) -> sqlx::Result<()> {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
update doll_profiles
|
||||
|
@ -172,11 +195,13 @@ pub async fn delete(trx: &mut TrxHook<'_>, id: i32) -> sqlx::Result<()> {
|
|||
chassis_type = null,
|
||||
chassis_id = null,
|
||||
chassis_color = null,
|
||||
is_public = false,
|
||||
updated_at = current_timestamp,
|
||||
archived_at = current_timestamp
|
||||
where id = $1
|
||||
where id = $1 and bound_to_id = $2
|
||||
"#,
|
||||
id
|
||||
id,
|
||||
bound_account_id
|
||||
)
|
||||
.execute(&mut **trx)
|
||||
.await?;
|
||||
|
@ -196,7 +221,7 @@ pub async fn delete_all_from_account(trx: &mut TrxHook<'_>, from: &Uuid) -> sqlx
|
|||
.fetch_all(&mut **trx)
|
||||
.await?;
|
||||
for tag in tags {
|
||||
delete(trx, tag.id).await?;
|
||||
delete(trx, tag.id, from).await?;
|
||||
}
|
||||
|
||||
// 2. unlink archived tags from the account
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
pub mod admin;
|
||||
pub mod doll;
|
||||
pub mod migrate;
|
||||
pub mod otp;
|
||||
pub mod reseed;
|
||||
pub mod schema;
|
||||
pub mod user;
|
||||
|
|
88
src/db/otp.rs
Normal file
88
src/db/otp.rs
Normal file
|
@ -0,0 +1,88 @@
|
|||
use uuid::Uuid;
|
||||
|
||||
use super::schema::{DbHook, OTP};
|
||||
|
||||
pub const METHOD_TOTP: &'static str = "totp";
|
||||
|
||||
/// Checks that the provided user has the specified OTP method
|
||||
pub async fn has_otp(db: &mut DbHook, id: &Uuid, method: &str) -> sqlx::Result<bool> {
|
||||
Ok(sqlx::query!(
|
||||
"select otp_method from otp where user_id = $1 and otp_method = $2",
|
||||
id,
|
||||
method
|
||||
)
|
||||
.fetch_optional(&mut **db)
|
||||
.await?
|
||||
.is_some())
|
||||
}
|
||||
|
||||
/// Lists the OTP methods the user has enabled
|
||||
pub async fn list_enabled_methods(db: &mut DbHook, id: &Uuid) -> sqlx::Result<Vec<String>> {
|
||||
sqlx::query_scalar!("select otp_method from otp where user_id = $1", id)
|
||||
.fetch_all(&mut **db)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Gets the requested OTP method config, if set
|
||||
pub async fn get_otp_method(db: &mut DbHook, id: &Uuid, method: &str) -> sqlx::Result<Option<OTP>> {
|
||||
sqlx::query_as!(
|
||||
OTP,
|
||||
"select * from otp where user_id = $1 and otp_method = $2",
|
||||
id,
|
||||
method
|
||||
)
|
||||
.fetch_optional(&mut **db)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Adds a new otp method with the provided config; will fail without check if one is already set
|
||||
pub async fn add_otp_method(
|
||||
db: &mut DbHook,
|
||||
id: &Uuid,
|
||||
secret: &str,
|
||||
hashed_recovery_key: &str,
|
||||
) -> sqlx::Result<()> {
|
||||
sqlx::query!(
|
||||
"insert into otp (user_id, otp_method, secret_seed, recovery_key) values ($1, $2, $3, $4)",
|
||||
id,
|
||||
METHOD_TOTP,
|
||||
secret,
|
||||
hashed_recovery_key,
|
||||
)
|
||||
.execute(&mut **db)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Deletes the given OTP method's configuration for the user
|
||||
pub async fn delete_otp_method(db: &mut DbHook, id: &Uuid, method: &str) -> sqlx::Result<()> {
|
||||
sqlx::query!(
|
||||
"delete from otp where user_id = $1 and otp_method = $2",
|
||||
id,
|
||||
method
|
||||
)
|
||||
.execute(&mut **db)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Changes the recovery key for the given OTP method; if there was none defined, will do nothing
|
||||
pub async fn change_recovery_key(
|
||||
db: &mut DbHook,
|
||||
id: &Uuid,
|
||||
otp_method: &str,
|
||||
hashed_recovery_key: &str,
|
||||
) -> sqlx::Result<()> {
|
||||
sqlx::query!(
|
||||
"update otp set recovery_key = $3 where user_id = $1 and otp_method = $2",
|
||||
id,
|
||||
otp_method,
|
||||
hashed_recovery_key
|
||||
)
|
||||
.execute(&mut **db)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
7
src/db/queries/reseed-tags.sql
Normal file
7
src/db/queries/reseed-tags.sql
Normal file
|
@ -0,0 +1,7 @@
|
|||
insert into doll_profiles
|
||||
(id, name, pronoun_subject, pronoun_object, pronoun_possessive, handler_name, handler_link, kind, breed, behaviour, chassis_type, chassis_id, chassis_color, bound_to_id, description)
|
||||
values
|
||||
(
|
||||
134621, 'Raven', 'it', 'it', 'its', 'Walter', 'https://armoredcore.fandom.com/wiki/Handler_Walter', 'Combat Doll', null, 'neutral', 'AC LOADER 4', 'C4-621', 'Grey', '1f35abcd-2997-41d4-80cf-6297a8d92eae',
|
||||
'Augmented Human C4-621, aka "621", is a mercenary Armored Core pilot active during the Coral War. 621 has undergone extensive biological and cybernetic operations which augmented piloting skills and limited ability to experience human emotions.\n"C4" refers to the fourth generation of Augmented Humans, while "621" is a serial designation.'
|
||||
);
|
4
src/db/queries/reseed-users.sql
Normal file
4
src/db/queries/reseed-users.sql
Normal file
|
@ -0,0 +1,4 @@
|
|||
insert into users (id, username, password, email, enabled, is_admin) values
|
||||
('00000000-0000-0000-0000-000000000000', 'legacy', '', '', false, false),
|
||||
('1f35abcd-2997-41d4-80cf-6297a8d92eae', 'mistress', '$argon2i$v=19$m=65536,t=3,p=1$qFlubpiaaQdR/X/oFyOgsQ$ZHe5bO1v9PXcexSq0wj+hHyku+W0rztpxWmG+m8TU/k', 'mistress@dolltags.pet', true, true),
|
||||
('67d6f532-f8e3-4399-a45a-525e4f5c16d7', 'pet', '$argon2i$v=19$m=65536,t=3,p=1$zv+Y4zDgzeJJbpWAzlTA4g$NmynhR2KQywWhmnTgvAtTNhoO3xSJ+/1yley3j62yts', null, true, false);
|
17
src/db/reseed.rs
Normal file
17
src/db/reseed.rs
Normal file
|
@ -0,0 +1,17 @@
|
|||
use super::schema::TrxHook;
|
||||
|
||||
pub async fn reseed_db(trx: &mut TrxHook<'_>) -> sqlx::Result<()> {
|
||||
sqlx::query!("truncate doll_profiles, users;")
|
||||
.execute(&mut **trx)
|
||||
.await?;
|
||||
|
||||
sqlx::query_file!("src/db/queries/reseed-users.sql")
|
||||
.execute(&mut **trx)
|
||||
.await?;
|
||||
|
||||
sqlx::query_file!("src/db/queries/reseed-tags.sql")
|
||||
.execute(&mut **trx)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
|
@ -23,6 +23,7 @@ pub struct DollProfile {
|
|||
pub created_at: chrono::DateTime<Utc>,
|
||||
pub updated_at: Option<chrono::DateTime<Utc>>,
|
||||
pub archived_at: Option<chrono::DateTime<Utc>>,
|
||||
pub is_public: bool,
|
||||
|
||||
pub bound_to_id: Uuid,
|
||||
|
||||
|
@ -58,6 +59,7 @@ pub struct CreateDollProfile<'a> {
|
|||
pub id: i32,
|
||||
pub microchip_id: Option<&'a str>,
|
||||
pub bound_to_id: &'a Uuid,
|
||||
pub is_public: bool,
|
||||
|
||||
pub name: &'a str,
|
||||
pub pronoun_subject: &'a str,
|
||||
|
@ -95,4 +97,25 @@ pub struct User {
|
|||
pub email: Option<String>,
|
||||
|
||||
pub enabled: bool,
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
/// A user's OTP config for a given OTP scheme.
|
||||
/// WAT? why does it work without a serialize impl.?
|
||||
#[derive(Debug)]
|
||||
pub struct OTP {
|
||||
pub user_id: Uuid,
|
||||
pub created_at: chrono::DateTime<Utc>,
|
||||
|
||||
pub otp_method: String,
|
||||
pub secret_seed: String,
|
||||
pub recovery_key: String,
|
||||
}
|
||||
|
||||
/// The service status aggregate
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct ServiceStatus {
|
||||
pub active_accounts_count: i64,
|
||||
pub active_tags_count: i64,
|
||||
}
|
||||
|
|
|
@ -2,22 +2,22 @@ use uuid::Uuid;
|
|||
|
||||
use crate::db::schema::User;
|
||||
|
||||
use super::schema::{DbHook, DollTagsDb, TrxHook};
|
||||
use super::schema::{DbHook, TrxHook};
|
||||
|
||||
pub async fn get(mut db: DollTagsDb, username: &str) -> sqlx::Result<Option<User>> {
|
||||
pub async fn get(db: &mut DbHook, username: &str) -> sqlx::Result<Option<User>> {
|
||||
sqlx::query_as!(User, "select * from users where username = $1", username)
|
||||
.fetch_optional(&mut **db)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_by_id(mut db: DollTagsDb, id: &Uuid) -> sqlx::Result<Option<User>> {
|
||||
pub async fn get_by_id(db: &mut DbHook, id: &Uuid) -> sqlx::Result<Option<User>> {
|
||||
sqlx::query_as!(User, "select * from users where id = $1", id)
|
||||
.fetch_optional(&mut **db)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
mut db: DollTagsDb,
|
||||
db: &mut DbHook,
|
||||
username: &str,
|
||||
hashed_password: &str,
|
||||
email: Option<&str>,
|
||||
|
|
65
src/ids.rs
65
src/ids.rs
|
@ -1,14 +1,35 @@
|
|||
use rand::{distributions::Uniform, prelude::Distribution, thread_rng};
|
||||
use regex::Regex;
|
||||
use rocket::{form, request::FromParam};
|
||||
|
||||
use crate::db::{doll, schema::DollTagsDb};
|
||||
|
||||
const SYMBOLS: [char; 36] = [
|
||||
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's',
|
||||
't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
|
||||
];
|
||||
|
||||
/// Generate a random recovery key, of length 14 (16 with the two `-`)
|
||||
/// and format `xxxx-xxxxxx-xxxx` using the charset `a-z0-9`
|
||||
pub fn generate_recovery_key() -> String {
|
||||
let uniform = Uniform::new_inclusive::<usize, usize>(0, 35);
|
||||
let mut rng = thread_rng();
|
||||
|
||||
let first: String = (1..=4).map(|_| SYMBOLS[uniform.sample(&mut rng)]).collect();
|
||||
let second: String = (1..=6).map(|_| SYMBOLS[uniform.sample(&mut rng)]).collect();
|
||||
let third: String = (1..=4).map(|_| SYMBOLS[uniform.sample(&mut rng)]).collect();
|
||||
|
||||
format!("{}-{}-{}", first, second, third)
|
||||
}
|
||||
|
||||
/// Generate 10 random doll tags IDs, hoping to have at least 5 of them available
|
||||
pub fn generate_ids() -> Vec<i32> {
|
||||
let uniform = Uniform::new_inclusive::<i32, i32>(100_000, 999_999);
|
||||
let mut rng = thread_rng();
|
||||
(1..=10).map(|_| uniform.sample(&mut rng)).collect()
|
||||
}
|
||||
|
||||
/// Generates 10 random doll tags IDs and check against the DB to find up to 5 free ones
|
||||
pub async fn pick_ids(mut db: DollTagsDb) -> Result<Vec<i32>, sqlx::Error> {
|
||||
let mut ids_bundle = generate_ids();
|
||||
let occupied_ids = doll::check_ids(&mut *db, &ids_bundle).await?;
|
||||
|
@ -25,8 +46,44 @@ pub fn id_public_to_db(id: &str) -> Option<i32> {
|
|||
None
|
||||
}
|
||||
}
|
||||
pub fn id_db_to_public(id: i32) -> String {
|
||||
let first = id / 1000;
|
||||
let second = id % 1000;
|
||||
format!("{:0>3}-{:0>3}", first, second)
|
||||
/// TODO: Check if used anywhere else than template rendering
|
||||
pub fn id_db_to_public(id: i32, pretty: bool) -> String {
|
||||
if pretty {
|
||||
let first = id / 1000;
|
||||
let second = id % 1000;
|
||||
format!("{:0>3}-{:0>3}", first, second)
|
||||
} else {
|
||||
format!("{:0>6}", id)
|
||||
}
|
||||
}
|
||||
|
||||
/// A cleaner way to handle on-the-fly in-URL parameter format validation for IDs
|
||||
/// TODO: check and remove all other usages
|
||||
#[derive(UriDisplayPath)]
|
||||
pub struct PublicId(pub i32);
|
||||
|
||||
impl<'a> FromParam<'a> for PublicId {
|
||||
type Error = &'static str;
|
||||
|
||||
fn from_param(param: &'a str) -> Result<Self, Self::Error> {
|
||||
if let Some(v) = id_public_to_db(param) {
|
||||
Ok(PublicId(v))
|
||||
} else {
|
||||
Err("id not formatted properly")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Into<i32> for PublicId {
|
||||
fn into(self) -> i32 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_id<'v>(id: &str) -> form::Result<'v, ()> {
|
||||
if let None = id_public_to_db(id) {
|
||||
Err(form::Error::validation("id not formatted properly"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
55
src/main.rs
55
src/main.rs
|
@ -5,15 +5,19 @@ use std::env;
|
|||
use std::path::PathBuf;
|
||||
|
||||
use auth::session;
|
||||
use cli::check_cli_invocation;
|
||||
use config::Config;
|
||||
use db::migrate::run_migrations;
|
||||
use db::schema::DollTags;
|
||||
use rocket::fairing::AdHoc;
|
||||
use rocket::fs::{relative, FileServer};
|
||||
use rocket_db_pools::Database;
|
||||
use routes::form::accounts;
|
||||
use routes::{account, error_handlers, form, public};
|
||||
use routes::{account, admin, error_handlers, form, public};
|
||||
|
||||
pub mod auth;
|
||||
pub mod cli;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod ids;
|
||||
pub mod pages;
|
||||
|
@ -47,34 +51,59 @@ fn rocket() -> _ {
|
|||
rocket::build()
|
||||
.attach(pages::init_templates())
|
||||
.attach(DollTags::init())
|
||||
.attach(AdHoc::config::<Config>())
|
||||
.attach(AdHoc::try_on_ignite("SQLx migrations", run_migrations))
|
||||
.attach(AdHoc::on_liftoff("CLI invocation hack", |rocket| {
|
||||
Box::pin(async move { check_cli_invocation(rocket).await })
|
||||
}))
|
||||
.register(
|
||||
"/",
|
||||
catchers![error_handlers::not_found, session::unauthorized],
|
||||
catchers![
|
||||
error_handlers::not_found,
|
||||
session::unauthorized,
|
||||
session::forbidden
|
||||
],
|
||||
)
|
||||
.mount("/assets", FileServer::from(assets_path))
|
||||
.mount(
|
||||
"/account",
|
||||
routes![
|
||||
account::index,
|
||||
account::show_settings,
|
||||
account::change_settings,
|
||||
account::change_password,
|
||||
account::common::index,
|
||||
account::common::qr_profile,
|
||||
account::settings::show_settings,
|
||||
account::settings::change_settings,
|
||||
account::settings::change_password,
|
||||
form::register_tag::show_register,
|
||||
form::register_tag::handle_register,
|
||||
form::register_tag::show_edit_tag,
|
||||
form::register_tag::handle_edit_tag,
|
||||
account::ask_delete,
|
||||
account::confirm_delete,
|
||||
account::ask_terminate_account,
|
||||
account::confirm_terminate_account,
|
||||
account::export_data,
|
||||
account::common::ask_delete,
|
||||
account::common::confirm_delete,
|
||||
account::common::ask_terminate_account,
|
||||
account::common::confirm_terminate_account,
|
||||
account::common::export_data,
|
||||
account::otp::show_totp_enable_start,
|
||||
account::otp::handle_totp_enable_start,
|
||||
account::otp::show_confirm_totp_regenerate_key,
|
||||
account::otp::regenerate_key,
|
||||
account::otp::show_confirm_totp_disable,
|
||||
account::otp::handle_confirm_totp_disable,
|
||||
],
|
||||
)
|
||||
.mount(
|
||||
"/admin",
|
||||
routes![
|
||||
admin::index,
|
||||
admin::handle_in_page_forms,
|
||||
admin::show_confirm_tag_handover,
|
||||
admin::handle_tag_handover,
|
||||
],
|
||||
)
|
||||
.mount(
|
||||
"/",
|
||||
routes![
|
||||
public::index,
|
||||
public::about,
|
||||
public::short_url,
|
||||
public::show_profile,
|
||||
accounts::show_register,
|
||||
|
@ -82,6 +111,10 @@ fn rocket() -> _ {
|
|||
accounts::show_login,
|
||||
accounts::handle_login,
|
||||
accounts::logout,
|
||||
accounts::show_2fa_form,
|
||||
accounts::handle_2fa_form,
|
||||
accounts::show_2fa_recover_form,
|
||||
accounts::handle_2fa_recover_form,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
|
60
src/pages.rs
60
src/pages.rs
|
@ -2,24 +2,28 @@ use std::collections::HashMap;
|
|||
|
||||
use rocket::{
|
||||
fairing::Fairing,
|
||||
http::CookieJar,
|
||||
outcome::try_outcome,
|
||||
request::{FromRequest, Outcome},
|
||||
serde::Serialize,
|
||||
Request,
|
||||
Request, State,
|
||||
};
|
||||
use rocket_dyn_templates::{tera::try_get_value, Template};
|
||||
use serde_json::{to_value, Value};
|
||||
|
||||
use crate::{auth::session::Session, ids};
|
||||
use crate::{auth::session::Session, config::Config, db::schema::User, ids};
|
||||
|
||||
pub fn init_templates() -> impl Fairing {
|
||||
Template::custom(|engines| {
|
||||
engines
|
||||
.tera
|
||||
.register_filter("pretty_id", |v: &Value, _: &HashMap<String, Value>| {
|
||||
let value = try_get_value!("pretty_id", "value", i32, v);
|
||||
Ok(to_value(ids::id_db_to_public(value)).unwrap())
|
||||
});
|
||||
let tera = &mut engines.tera;
|
||||
tera.register_filter("pretty_id", |v: &Value, _: &HashMap<String, Value>| {
|
||||
let value = try_get_value!("pretty_id", "value", i32, v);
|
||||
Ok(to_value(ids::id_db_to_public(value, true)).unwrap())
|
||||
});
|
||||
tera.register_filter("id", |v: &Value, _: &HashMap<String, Value>| {
|
||||
let value = try_get_value!("id", "value", i32, v);
|
||||
Ok(to_value(ids::id_db_to_public(value, false)).unwrap())
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -32,8 +36,23 @@ pub fn init_templates() -> impl Fairing {
|
|||
pub struct CommonTemplateState {
|
||||
/// true if the user is logged in (doesn't check the DB, instead uses [`Session`])
|
||||
pub logged_in: bool,
|
||||
/// true if the user is an admin (defaults to false, call [`CommonTemplateState::for_user`] to add the additional data)
|
||||
pub is_admin: bool,
|
||||
/// feature flag - disables the UI for it since it's not implemeted yet.
|
||||
pub forgot_password_implemented: bool,
|
||||
/// the website's public base URL (used for canonical URLs)
|
||||
pub public_url: String,
|
||||
}
|
||||
|
||||
impl CommonTemplateState {
|
||||
/// Populates the state struct with the additional knowledge provided by the [`User`] passed in parameter,
|
||||
/// such as if the current context is an admin-privileges-enabled one (which need a DB lookup).
|
||||
pub fn for_user(self, user: &User) -> Self {
|
||||
CommonTemplateState {
|
||||
is_admin: user.is_admin,
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[rocket::async_trait]
|
||||
|
@ -42,10 +61,13 @@ impl<'r> FromRequest<'r> for CommonTemplateState {
|
|||
|
||||
async fn from_request(request: &'r Request<'_>) -> Outcome<CommonTemplateState, ()> {
|
||||
let session_state = try_outcome!(request.guard::<Session>().await);
|
||||
let config_state = try_outcome!(request.guard::<&State<Config>>().await);
|
||||
|
||||
Outcome::Success(CommonTemplateState {
|
||||
logged_in: session_state.0.is_some(),
|
||||
is_admin: false,
|
||||
forgot_password_implemented: false,
|
||||
public_url: config_state.public_url.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -53,7 +75,7 @@ impl<'r> FromRequest<'r> for CommonTemplateState {
|
|||
/// FakeContext exists to be used as a replacement for Context when reusing a form for creation and edition, to avoid repeating lots of code.
|
||||
///
|
||||
/// Note: i made this custom context thingy because i couldn't find a way to create a real context with the right populated data.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, Serialize, Default)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct FakeContext {
|
||||
/// Values are made to simulate the same structure as the real Context
|
||||
|
@ -63,3 +85,23 @@ pub struct FakeContext {
|
|||
/// NOP, used to placehold global errors
|
||||
pub form_errors: Vec<()>,
|
||||
}
|
||||
|
||||
/// writes a toast msg into an encrypted cookie
|
||||
/// (note: this overwrites any existing toast)
|
||||
pub fn write_toast(jar: &CookieJar<'_>, message: String) {
|
||||
jar.add_private(("toast", message))
|
||||
}
|
||||
|
||||
/// gets a toast from the encrypted toast cookie if any is set,
|
||||
/// then removes the cookie
|
||||
pub fn pop_toast(jar: &CookieJar<'_>) -> Option<String> {
|
||||
let toast = jar
|
||||
.get_private("toast")
|
||||
.map(|c| String::from(c.value_trimmed()));
|
||||
|
||||
if toast.is_some() {
|
||||
jar.remove_private("toast");
|
||||
}
|
||||
|
||||
toast
|
||||
}
|
||||
|
|
154
src/routes/account/common.rs
Normal file
154
src/routes/account/common.rs
Normal file
|
@ -0,0 +1,154 @@
|
|||
use std::net::IpAddr;
|
||||
|
||||
use qrcode_generator::QrCodeEcc;
|
||||
use rocket::{
|
||||
http::{uri::Absolute, CookieJar},
|
||||
response::Redirect,
|
||||
serde::{json::Json, Serialize},
|
||||
State,
|
||||
};
|
||||
use rocket_dyn_templates::{context, Template};
|
||||
use sqlx::Acquire;
|
||||
|
||||
use crate::{
|
||||
auth::session,
|
||||
config::Config,
|
||||
db::{
|
||||
doll,
|
||||
schema::{DollProfile, DollTagsDb, User},
|
||||
user,
|
||||
},
|
||||
pages::CommonTemplateState,
|
||||
routes::error_handlers::{PageResult, RawResult},
|
||||
};
|
||||
|
||||
#[get("/")]
|
||||
pub async fn index(mut db: DollTagsDb, user: User, meta: CommonTemplateState) -> PageResult {
|
||||
let tags = doll::list(&mut *db, &user.id).await?;
|
||||
let archived_tags = doll::list_archived(&mut *db, &user.id).await?;
|
||||
|
||||
Ok(Template::render(
|
||||
"account/index",
|
||||
context! {
|
||||
meta,
|
||||
user,
|
||||
tags,
|
||||
archived_tags,
|
||||
},
|
||||
)
|
||||
.into())
|
||||
}
|
||||
|
||||
#[get("/qr-png/<id>")]
|
||||
pub fn qr_profile(id: &str, _user: User, config: &State<Config>) -> PageResult {
|
||||
let public_uri = Absolute::parse(&config.public_url)?;
|
||||
let built_uri = uri!(public_uri, crate::routes::public::short_url(id));
|
||||
let image = qrcode_generator::to_png_to_vec(built_uri.to_string(), QrCodeEcc::Low, 400)?;
|
||||
|
||||
Ok(image.into())
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct DataDump {
|
||||
account: User,
|
||||
tags: Vec<DollProfile>,
|
||||
reserved_tags: Vec<i32>,
|
||||
}
|
||||
|
||||
#[get("/data_dump")]
|
||||
pub async fn export_data(
|
||||
mut db: DollTagsDb,
|
||||
user: User,
|
||||
client_ip: IpAddr,
|
||||
) -> RawResult<Json<DataDump>> {
|
||||
let tags = doll::list_all(&mut *db, &user.id).await?;
|
||||
let reserved_tags = doll::list_archived(&mut *db, &user.id).await?;
|
||||
|
||||
warn!(
|
||||
"[audit|{}] [{}] exported data",
|
||||
client_ip,
|
||||
user.id.to_string()
|
||||
);
|
||||
|
||||
Ok(Json(DataDump {
|
||||
account: user,
|
||||
tags,
|
||||
reserved_tags,
|
||||
}))
|
||||
}
|
||||
|
||||
#[get("/delete/<id>")]
|
||||
pub async fn ask_delete(
|
||||
mut db: DollTagsDb,
|
||||
id: i32,
|
||||
user: User,
|
||||
meta: CommonTemplateState,
|
||||
) -> PageResult {
|
||||
let db_tag = doll::get(&mut *db, id, "").await?;
|
||||
|
||||
if let Some(tag) = db_tag {
|
||||
if tag.bound_to_id != user.id || tag.archived_at.is_some() {
|
||||
Ok(Redirect::to(uri!("/account", index)).into())
|
||||
} else {
|
||||
Ok(Template::render(
|
||||
"tag/delete",
|
||||
context! {
|
||||
meta,
|
||||
tag,
|
||||
},
|
||||
)
|
||||
.into())
|
||||
}
|
||||
} else {
|
||||
Ok(Redirect::to(uri!("/account", index)).into())
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/yes_delete_this/<id>")]
|
||||
pub async fn confirm_delete(
|
||||
mut db: DollTagsDb,
|
||||
id: i32,
|
||||
user: User,
|
||||
client_ip: IpAddr,
|
||||
) -> PageResult {
|
||||
let mut trx = db.begin().await?;
|
||||
doll::delete(&mut trx, id, &user.id).await?;
|
||||
trx.commit().await?;
|
||||
|
||||
warn!(
|
||||
"[audit|{}] [{}] deleted tag {:0>6}",
|
||||
client_ip,
|
||||
user.id.to_string(),
|
||||
id
|
||||
);
|
||||
|
||||
Ok(Redirect::to(uri!("/account", index)).into())
|
||||
}
|
||||
|
||||
#[get("/terminate")]
|
||||
pub fn ask_terminate_account(_user: User, meta: CommonTemplateState) -> Template {
|
||||
Template::render("account/terminate", context! {meta})
|
||||
}
|
||||
|
||||
#[get("/termin@or")]
|
||||
pub async fn confirm_terminate_account(
|
||||
mut db: DollTagsDb,
|
||||
user: User,
|
||||
cookies: &CookieJar<'_>,
|
||||
client_ip: IpAddr,
|
||||
) -> PageResult {
|
||||
let mut trx = db.begin().await?;
|
||||
doll::delete_all_from_account(&mut trx, &user.id).await?;
|
||||
user::delete(&mut trx, &user.id).await?;
|
||||
session::logout(cookies);
|
||||
trx.commit().await?;
|
||||
|
||||
warn!(
|
||||
"[audit|{}] [{}] deleted account",
|
||||
client_ip,
|
||||
user.id.to_string()
|
||||
);
|
||||
|
||||
Ok(Redirect::to("/").into())
|
||||
}
|
3
src/routes/account/mod.rs
Normal file
3
src/routes/account/mod.rs
Normal file
|
@ -0,0 +1,3 @@
|
|||
pub mod common;
|
||||
pub mod otp;
|
||||
pub mod settings;
|
210
src/routes/account/otp.rs
Normal file
210
src/routes/account/otp.rs
Normal file
|
@ -0,0 +1,210 @@
|
|||
use std::net::IpAddr;
|
||||
|
||||
use rocket::{form::Form, http::CookieJar, response::Redirect, tokio::task};
|
||||
use rocket_dyn_templates::{context, Template};
|
||||
use totp_rs::Secret;
|
||||
|
||||
use crate::{
|
||||
auth::{self, pw},
|
||||
db::{
|
||||
self,
|
||||
otp::METHOD_TOTP,
|
||||
schema::{DollTagsDb, User},
|
||||
},
|
||||
ids::generate_recovery_key,
|
||||
pages::CommonTemplateState,
|
||||
routes::{self, error_handlers::PageResult},
|
||||
};
|
||||
|
||||
#[get("/settings/totp?<invalid_code>")]
|
||||
pub async fn show_totp_enable_start(
|
||||
mut db: DollTagsDb,
|
||||
invalid_code: bool,
|
||||
user: User,
|
||||
cookies: &CookieJar<'_>,
|
||||
meta: CommonTemplateState,
|
||||
) -> PageResult {
|
||||
if db::otp::has_otp(&mut *db, &user.id, METHOD_TOTP).await? {
|
||||
return Ok(Redirect::to(uri!("/account", routes::account::settings::show_settings)).into());
|
||||
}
|
||||
|
||||
// cookie recovery in case it was a form error and not a new session
|
||||
let mut totp_secret = auth::otp::get_secret(cookies);
|
||||
if totp_secret.is_none() {
|
||||
let new_secret = Secret::generate_secret();
|
||||
auth::otp::cache_secret(cookies, &new_secret);
|
||||
totp_secret = Some(new_secret);
|
||||
}
|
||||
let totp_secret = totp_secret.unwrap();
|
||||
|
||||
let totp = auth::otp::make_totp(&user.id.to_string(), totp_secret.to_bytes()?)?;
|
||||
let totp_qrcode = totp.get_qr_base64()?;
|
||||
|
||||
Ok(Template::render(
|
||||
"account/otp/start",
|
||||
context! {
|
||||
meta,
|
||||
totp_qrcode,
|
||||
invalid_code,
|
||||
secret: totp.get_secret_base32(),
|
||||
},
|
||||
)
|
||||
.into())
|
||||
}
|
||||
|
||||
#[derive(FromForm)]
|
||||
pub struct OtpEnableForm {
|
||||
#[field(validate = len(6..=6))]
|
||||
pub otp_code: String,
|
||||
}
|
||||
|
||||
#[post("/settings/totp", data = "<form>")]
|
||||
pub async fn handle_totp_enable_start(
|
||||
mut db: DollTagsDb,
|
||||
form: Form<OtpEnableForm>,
|
||||
user: User,
|
||||
cookies: &CookieJar<'_>,
|
||||
meta: CommonTemplateState,
|
||||
client_ip: IpAddr,
|
||||
) -> PageResult {
|
||||
if db::otp::has_otp(&mut *db, &user.id, METHOD_TOTP).await? {
|
||||
return Ok(Redirect::to(uri!("/account", routes::account::settings::show_settings)).into());
|
||||
}
|
||||
let secret = match auth::otp::get_secret(cookies) {
|
||||
Some(v) => v,
|
||||
None => return Ok(Redirect::to(uri!("/account", show_totp_enable_start(false))).into()),
|
||||
};
|
||||
|
||||
let totp = auth::otp::make_totp(&user.id.to_string(), secret.to_bytes()?)?;
|
||||
|
||||
if !totp.check_current(&form.otp_code)? {
|
||||
return Ok(Redirect::to(uri!("/account", show_totp_enable_start(true))).into());
|
||||
}
|
||||
|
||||
warn!(
|
||||
"[audit|{}] [{}] enabled TOTP 2FA",
|
||||
client_ip,
|
||||
user.id.to_string(),
|
||||
);
|
||||
|
||||
let recovery_key = generate_recovery_key();
|
||||
|
||||
{
|
||||
let recovery_key = recovery_key.clone();
|
||||
let hashed_recovery_key = task::spawn_blocking(move || pw::hash(&recovery_key)).await??;
|
||||
|
||||
db::otp::add_otp_method(
|
||||
&mut *db,
|
||||
&user.id,
|
||||
&totp.get_secret_base32(),
|
||||
&hashed_recovery_key,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
auth::otp::remove_secret(cookies);
|
||||
|
||||
Ok(Template::render(
|
||||
"account/otp/confirm",
|
||||
context! {
|
||||
meta,
|
||||
recovery_key,
|
||||
},
|
||||
)
|
||||
.into())
|
||||
}
|
||||
|
||||
#[get("/settings/totp/generate-key")]
|
||||
pub async fn show_confirm_totp_regenerate_key(
|
||||
mut db: DollTagsDb,
|
||||
user: User,
|
||||
meta: CommonTemplateState,
|
||||
) -> PageResult {
|
||||
if !db::otp::has_otp(&mut *db, &user.id, METHOD_TOTP).await? {
|
||||
return Ok(Redirect::to(uri!("/account", routes::account::settings::show_settings)).into());
|
||||
}
|
||||
|
||||
Ok(Template::render(
|
||||
"account/otp/confirm_regenerate_key",
|
||||
context! {
|
||||
meta,
|
||||
},
|
||||
)
|
||||
.into())
|
||||
}
|
||||
|
||||
#[post("/settings/totp/generate-key")]
|
||||
pub async fn regenerate_key(
|
||||
mut db: DollTagsDb,
|
||||
user: User,
|
||||
meta: CommonTemplateState,
|
||||
client_ip: IpAddr,
|
||||
) -> PageResult {
|
||||
if !db::otp::has_otp(&mut *db, &user.id, METHOD_TOTP).await? {
|
||||
return Ok(Redirect::to(uri!("/account", routes::account::settings::show_settings)).into());
|
||||
}
|
||||
|
||||
warn!(
|
||||
"[audit|{}] [{}] regenerated TOTP 2FA recovery key",
|
||||
client_ip,
|
||||
user.id.to_string(),
|
||||
);
|
||||
|
||||
let recovery_key = generate_recovery_key();
|
||||
|
||||
{
|
||||
let recovery_key = recovery_key.clone();
|
||||
let hashed_recovery_key = task::spawn_blocking(move || pw::hash(&recovery_key)).await??;
|
||||
|
||||
db::otp::change_recovery_key(&mut *db, &user.id, METHOD_TOTP, &hashed_recovery_key).await?;
|
||||
}
|
||||
|
||||
Ok(Template::render(
|
||||
"account/otp/regenerate_key",
|
||||
context! {
|
||||
recovery_key,
|
||||
meta,
|
||||
},
|
||||
)
|
||||
.into())
|
||||
}
|
||||
|
||||
#[get("/settings/totp/disable")]
|
||||
pub async fn show_confirm_totp_disable(
|
||||
mut db: DollTagsDb,
|
||||
user: User,
|
||||
meta: CommonTemplateState,
|
||||
) -> PageResult {
|
||||
if !db::otp::has_otp(&mut *db, &user.id, METHOD_TOTP).await? {
|
||||
return Ok(Redirect::to(uri!("/account", routes::account::settings::show_settings)).into());
|
||||
}
|
||||
|
||||
Ok(Template::render(
|
||||
"account/otp/disable",
|
||||
context! {
|
||||
meta,
|
||||
},
|
||||
)
|
||||
.into())
|
||||
}
|
||||
|
||||
#[post("/settings/totp/disable")]
|
||||
pub async fn handle_confirm_totp_disable(
|
||||
mut db: DollTagsDb,
|
||||
user: User,
|
||||
client_ip: IpAddr,
|
||||
) -> PageResult {
|
||||
if !db::otp::has_otp(&mut *db, &user.id, METHOD_TOTP).await? {
|
||||
return Ok(Redirect::to(uri!("/account", routes::account::settings::show_settings)).into());
|
||||
}
|
||||
|
||||
warn!(
|
||||
"[audit|{}] [{}] deactivated TOTP 2FA",
|
||||
client_ip,
|
||||
user.id.to_string(),
|
||||
);
|
||||
|
||||
db::otp::delete_otp_method(&mut *db, &user.id, METHOD_TOTP).await?;
|
||||
|
||||
Ok(Redirect::to(uri!("/account", routes::account::settings::show_settings)).into())
|
||||
}
|
|
@ -1,53 +1,42 @@
|
|||
use std::net::IpAddr;
|
||||
|
||||
use rocket::{
|
||||
form::{self, Contextual, Error, Form},
|
||||
http::CookieJar,
|
||||
response::Redirect,
|
||||
serde::{json::Json, Serialize},
|
||||
tokio::task,
|
||||
};
|
||||
use rocket_dyn_templates::{context, Template};
|
||||
use sqlx::Acquire;
|
||||
|
||||
use crate::{
|
||||
auth::{pw, session},
|
||||
auth::pw,
|
||||
db::{
|
||||
doll,
|
||||
schema::{DollProfile, DollTagsDb, User},
|
||||
otp,
|
||||
schema::{DollTagsDb, User},
|
||||
user,
|
||||
},
|
||||
pages::CommonTemplateState,
|
||||
routes::error_handlers::PageResult,
|
||||
};
|
||||
|
||||
use super::error_handlers::{PageResult, RawResult};
|
||||
|
||||
#[get("/")]
|
||||
pub async fn index(mut db: DollTagsDb, user: User, meta: CommonTemplateState) -> PageResult {
|
||||
let tags = doll::list(&mut *db, &user.id).await?;
|
||||
let archived_tags = doll::list_archived(&mut *db, &user.id).await?;
|
||||
#[get("/settings")]
|
||||
pub async fn show_settings(
|
||||
mut db: DollTagsDb,
|
||||
user: User,
|
||||
meta: CommonTemplateState,
|
||||
) -> PageResult {
|
||||
let enabled_otp_methods = otp::list_enabled_methods(&mut *db, &user.id).await?;
|
||||
|
||||
Ok(Template::render(
|
||||
"account/index",
|
||||
context! {
|
||||
meta,
|
||||
user,
|
||||
tags,
|
||||
archived_tags,
|
||||
},
|
||||
)
|
||||
.into())
|
||||
}
|
||||
|
||||
#[get("/settings")]
|
||||
pub fn show_settings(user: User, meta: CommonTemplateState) -> Template {
|
||||
Template::render(
|
||||
"account/settings",
|
||||
context! {
|
||||
user,
|
||||
meta,
|
||||
enabled_otp_methods,
|
||||
prev_common: form::Context::default(),
|
||||
prev_password: form::Context::default(),
|
||||
},
|
||||
)
|
||||
.into())
|
||||
}
|
||||
|
||||
#[derive(FromForm)]
|
||||
|
@ -64,6 +53,7 @@ pub async fn change_settings(
|
|||
user: User,
|
||||
meta: CommonTemplateState,
|
||||
mut form: Form<Contextual<'_, CommonSettings<'_>>>,
|
||||
client_ip: IpAddr,
|
||||
) -> PageResult {
|
||||
let values = match form.value {
|
||||
Some(ref v) => v,
|
||||
|
@ -110,6 +100,13 @@ pub async fn change_settings(
|
|||
|
||||
user::update_info(&mut *db, &user.id, new_username, new_email).await?;
|
||||
|
||||
warn!(
|
||||
"[audit|{}] [{}] changed username/email (from {})",
|
||||
client_ip,
|
||||
user.id.to_string(),
|
||||
user.username,
|
||||
);
|
||||
|
||||
Ok(Redirect::to(uri!("/account", show_settings)).into())
|
||||
}
|
||||
|
||||
|
@ -138,6 +135,7 @@ pub async fn change_password(
|
|||
user: User,
|
||||
meta: CommonTemplateState,
|
||||
mut form: Form<Contextual<'_, PasswordSettings<'_>>>,
|
||||
client_ip: IpAddr,
|
||||
) -> PageResult {
|
||||
let values = match form.value {
|
||||
Some(ref v) => v,
|
||||
|
@ -178,76 +176,11 @@ pub async fn change_password(
|
|||
let new_password_hash = task::spawn_blocking(move || pw::hash(&new_password)).await??;
|
||||
user::update_password(&mut *db, &user.id, &new_password_hash).await?;
|
||||
|
||||
warn!(
|
||||
"[audit|{}] [{}] changed password",
|
||||
client_ip,
|
||||
user.id.to_string()
|
||||
);
|
||||
|
||||
Ok(Redirect::to(uri!("/account", show_settings)).into())
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct DataDump {
|
||||
account: User,
|
||||
tags: Vec<DollProfile>,
|
||||
reserved_tags: Vec<i32>,
|
||||
}
|
||||
|
||||
#[get("/data_dump")]
|
||||
pub async fn export_data(mut db: DollTagsDb, user: User) -> RawResult<Json<DataDump>> {
|
||||
let tags = doll::list(&mut *db, &user.id).await?;
|
||||
let reserved_tags = doll::list_archived(&mut *db, &user.id).await?;
|
||||
|
||||
Ok(Json(DataDump {
|
||||
account: user,
|
||||
tags,
|
||||
reserved_tags,
|
||||
}))
|
||||
}
|
||||
|
||||
#[get("/delete/<id>")]
|
||||
pub async fn ask_delete(
|
||||
mut db: DollTagsDb,
|
||||
id: i32,
|
||||
_user: User,
|
||||
meta: CommonTemplateState,
|
||||
) -> PageResult {
|
||||
let db_tag = doll::get(&mut *db, id, "", false).await?;
|
||||
|
||||
if let Some(tag) = db_tag {
|
||||
Ok(Template::render(
|
||||
"tag/delete",
|
||||
context! {
|
||||
meta,
|
||||
tag,
|
||||
},
|
||||
)
|
||||
.into())
|
||||
} else {
|
||||
Ok(Redirect::to(uri!("/account", index)).into())
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/yes_delete_this/<id>")]
|
||||
pub async fn confirm_delete(mut db: DollTagsDb, id: i32, _user: User) -> PageResult {
|
||||
let mut trx = db.begin().await?;
|
||||
doll::delete(&mut trx, id).await?;
|
||||
trx.commit().await?;
|
||||
Ok(Redirect::to(uri!("/account", index)).into())
|
||||
}
|
||||
|
||||
#[get("/terminate")]
|
||||
pub fn ask_terminate_account(_user: User, meta: CommonTemplateState) -> Template {
|
||||
Template::render("account/terminate", context! {meta})
|
||||
}
|
||||
|
||||
#[get("/termin@or")]
|
||||
pub async fn confirm_terminate_account(
|
||||
mut db: DollTagsDb,
|
||||
user: User,
|
||||
cookies: &CookieJar<'_>,
|
||||
) -> PageResult {
|
||||
let mut trx = db.begin().await?;
|
||||
doll::delete_all_from_account(&mut trx, &user.id).await?;
|
||||
user::delete(&mut trx, &user.id).await?;
|
||||
session::logout(cookies);
|
||||
trx.commit().await?;
|
||||
|
||||
Ok(Redirect::to("/").into())
|
||||
}
|
229
src/routes/admin.rs
Normal file
229
src/routes/admin.rs
Normal file
|
@ -0,0 +1,229 @@
|
|||
use std::net::IpAddr;
|
||||
|
||||
use rocket::{
|
||||
form::{Context, Contextual, Error, Form},
|
||||
http::CookieJar,
|
||||
response::Redirect,
|
||||
serde::Serialize,
|
||||
};
|
||||
use rocket_dyn_templates::{context, Template};
|
||||
use sqlx::Acquire;
|
||||
|
||||
use crate::{
|
||||
auth::session::Admin,
|
||||
db::{admin, doll, schema::DollTagsDb, user},
|
||||
ids::{id_public_to_db, PublicId},
|
||||
pages::{pop_toast, write_toast, CommonTemplateState},
|
||||
};
|
||||
|
||||
use super::error_handlers::PageResult;
|
||||
|
||||
#[get("/")]
|
||||
pub async fn index(
|
||||
meta: CommonTemplateState,
|
||||
mut db: DollTagsDb,
|
||||
user: Admin,
|
||||
jar: &'_ CookieJar<'_>,
|
||||
) -> PageResult {
|
||||
let mut trx = db.begin().await?;
|
||||
let service_status = admin::get_service_status(&mut trx).await?;
|
||||
trx.commit().await?;
|
||||
|
||||
let toast = pop_toast(jar);
|
||||
|
||||
Ok(Template::render(
|
||||
"admin/index",
|
||||
context! {
|
||||
meta: meta.for_user(&user.0),
|
||||
service_status,
|
||||
previous: context! {
|
||||
tag_handover: Context::default(),
|
||||
},
|
||||
toast,
|
||||
},
|
||||
)
|
||||
.into())
|
||||
}
|
||||
|
||||
#[derive(Debug, FromFormField, Serialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub enum SelectedForm {
|
||||
TagHandover,
|
||||
}
|
||||
|
||||
#[derive(Debug, FromForm)]
|
||||
pub struct Forms<'a> {
|
||||
pub form: SelectedForm,
|
||||
pub tag_handover: Contextual<'a, TagHandover<'a>>,
|
||||
}
|
||||
|
||||
/// extracts one of the forms' contexts from the form group,
|
||||
/// otherwise uses the $def value (usually you'll give [`&Context::default()`][`Context::default()`])
|
||||
macro_rules! get_for_form {
|
||||
($def:expr, $curr:expr, $target:pat, $value:expr) => {
|
||||
if matches!($curr, $target) {
|
||||
$value
|
||||
} else {
|
||||
$def
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[derive(Debug, FromForm, Clone)]
|
||||
pub struct TagHandover<'a> {
|
||||
#[field(validate=crate::ids::validate_id())]
|
||||
pub tag_id: &'a str,
|
||||
#[field(validate=len(..=256))]
|
||||
pub dest_account: &'a str,
|
||||
}
|
||||
|
||||
#[post("/", data = "<form>")]
|
||||
pub async fn handle_in_page_forms(
|
||||
meta: CommonTemplateState,
|
||||
mut db: DollTagsDb,
|
||||
user: Admin,
|
||||
mut form: Form<Forms<'_>>,
|
||||
client_ip: IpAddr,
|
||||
) -> PageResult {
|
||||
match form.form {
|
||||
SelectedForm::TagHandover => {
|
||||
if let Some(values) = &form.tag_handover.value.clone() {
|
||||
let target_user = user::get(&mut *db, values.dest_account).await?;
|
||||
let user_valid = match target_user {
|
||||
Some(user) => {
|
||||
if !user.enabled {
|
||||
form.tag_handover.context.push_error(
|
||||
Error::validation("this user's account is deactivated")
|
||||
.with_name("tag_handover.dest_account"),
|
||||
);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
None => {
|
||||
form.tag_handover.context.push_error(
|
||||
Error::validation("this user doesn't exist")
|
||||
.with_name("tag_handover.dest_account"),
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
let target_tag = doll::get(
|
||||
&mut *db,
|
||||
id_public_to_db(values.tag_id)
|
||||
.expect("is form-validated so should always succeed"),
|
||||
"",
|
||||
)
|
||||
.await?;
|
||||
if target_tag.is_none() {
|
||||
form.tag_handover.context.push_error(
|
||||
Error::validation("no tag exists with this ID")
|
||||
.with_name("tag_handover.tag_id"),
|
||||
);
|
||||
}
|
||||
|
||||
if user_valid && target_tag.is_some() {
|
||||
let tag = target_tag.unwrap();
|
||||
warn!(
|
||||
"[audit|{}] [{}] beginning tag handover of tag {} to \"{}\" (previous user id: {})",
|
||||
client_ip,
|
||||
user.0.id.to_string(),
|
||||
tag.id,
|
||||
values.dest_account,
|
||||
tag.bound_to_id,
|
||||
);
|
||||
return Ok(Redirect::to(uri!(
|
||||
"/admin",
|
||||
show_confirm_tag_handover(PublicId(tag.id), values.dest_account)
|
||||
))
|
||||
.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut trx = db.begin().await?;
|
||||
let service_status = admin::get_service_status(&mut trx).await?;
|
||||
trx.commit().await?;
|
||||
|
||||
let def = Context::default();
|
||||
Ok(Template::render(
|
||||
"admin/index",
|
||||
context! {
|
||||
meta: meta.for_user(&user.0),
|
||||
service_status,
|
||||
previous: context! {
|
||||
tag_handover: get_for_form!(
|
||||
&def,
|
||||
&form.form,
|
||||
SelectedForm::TagHandover,
|
||||
&form.tag_handover.context
|
||||
),
|
||||
},
|
||||
},
|
||||
)
|
||||
.into())
|
||||
}
|
||||
|
||||
#[get("/tag-handover/<id>/<dest_username>")]
|
||||
pub fn show_confirm_tag_handover(
|
||||
meta: CommonTemplateState,
|
||||
user: Admin,
|
||||
id: PublicId,
|
||||
dest_username: &str,
|
||||
) -> Template {
|
||||
let tag_id = id.0;
|
||||
|
||||
Template::render(
|
||||
"admin/confirm_tag_handover",
|
||||
context! {
|
||||
meta: meta.for_user(&user.0),
|
||||
tag_id,
|
||||
dest_username,
|
||||
proceed_url: uri!("/admin", handle_tag_handover(id, dest_username)),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[get("/YES-GIMME/<id>/<dest_username>")]
|
||||
pub async fn handle_tag_handover<'a>(
|
||||
mut db: DollTagsDb,
|
||||
user: Admin,
|
||||
client_ip: IpAddr,
|
||||
jar: &'a CookieJar<'_>,
|
||||
id: PublicId,
|
||||
dest_username: &'a str,
|
||||
) -> PageResult {
|
||||
// note: there is currently no trace of the previous user in this handover code's audit log.
|
||||
let id = id.0;
|
||||
match user::get(&mut *db, dest_username).await? {
|
||||
Some(u) => {
|
||||
admin::handover_tag(&mut *db, id, &u.id).await?;
|
||||
warn!(
|
||||
"[audit|{}] [{}] handed over tag {} to \"{}\"",
|
||||
client_ip,
|
||||
user.0.id.to_string(),
|
||||
id,
|
||||
dest_username
|
||||
);
|
||||
write_toast(
|
||||
jar,
|
||||
format!("tag successfully handed over to \"{}\"", dest_username),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
warn!(
|
||||
"[audit|{}] [{}] tried to hand over tag {} to nonexistent user \"{}\"",
|
||||
client_ip,
|
||||
user.0.id.to_string(),
|
||||
id,
|
||||
dest_username
|
||||
);
|
||||
write_toast(jar, String::from("this username doesn't exist"));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Redirect::to("/admin").into())
|
||||
}
|
|
@ -10,6 +10,8 @@ pub async fn not_found() -> Template {
|
|||
pub enum PageResponse {
|
||||
Page(Template),
|
||||
Redirect(Redirect),
|
||||
#[response(content_type = "image/png")]
|
||||
Image(Vec<u8>),
|
||||
}
|
||||
|
||||
impl From<Template> for PageResponse {
|
||||
|
@ -24,6 +26,12 @@ impl From<Redirect> for PageResponse {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<Vec<u8>> for PageResponse {
|
||||
fn from(value: Vec<u8>) -> Self {
|
||||
PageResponse::Image(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Responder)]
|
||||
#[response(status = 500)]
|
||||
pub struct Fail(Template);
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
use std::net::IpAddr;
|
||||
|
||||
use regex::Regex;
|
||||
use rocket::{
|
||||
form::{self, Contextual, Form},
|
||||
|
@ -6,15 +8,24 @@ use rocket::{
|
|||
tokio::task,
|
||||
};
|
||||
use rocket_dyn_templates::{context, Template};
|
||||
use totp_rs::Secret;
|
||||
|
||||
use crate::{
|
||||
auth::{pw, session},
|
||||
auth::{
|
||||
otp::make_totp,
|
||||
pw,
|
||||
session::{self, AuthSession},
|
||||
},
|
||||
db::{
|
||||
otp::{self, METHOD_TOTP},
|
||||
schema::{DollTagsDb, User},
|
||||
user,
|
||||
},
|
||||
pages::CommonTemplateState,
|
||||
routes::error_handlers::{PageResponse, PageResult},
|
||||
routes::{
|
||||
self,
|
||||
error_handlers::{PageResponse, PageResult},
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, FromForm)]
|
||||
|
@ -43,33 +54,44 @@ pub fn show_login(
|
|||
|
||||
#[post("/login?<next>", data = "<form>")]
|
||||
pub async fn handle_login(
|
||||
db: DollTagsDb,
|
||||
mut db: DollTagsDb,
|
||||
next: Option<&str>,
|
||||
form: Form<Contextual<'_, AuthForm<'_>>>,
|
||||
cookies: &CookieJar<'_>,
|
||||
maybe_loggedin: Option<User>,
|
||||
meta: CommonTemplateState,
|
||||
client_ip: IpAddr,
|
||||
) -> PageResult {
|
||||
if maybe_loggedin.is_some() {
|
||||
let next = String::from(next.unwrap_or("/"));
|
||||
return Ok(Redirect::to(next).into());
|
||||
}
|
||||
|
||||
let miss = || {
|
||||
PageResponse::Page(Template::render(
|
||||
"account/login",
|
||||
context! {failure: true, meta},
|
||||
))
|
||||
};
|
||||
|
||||
let values = match &form.value {
|
||||
None => return Ok(miss()),
|
||||
None => {
|
||||
return Ok(Template::render("account/login", context! {failure: true, meta}).into())
|
||||
}
|
||||
Some(v) => v,
|
||||
};
|
||||
|
||||
let user_in_db = user::get(db, &values.username).await?;
|
||||
let miss = (|username: String| {
|
||||
move || {
|
||||
warn!("[audit|{}] login failure ({})", client_ip, username);
|
||||
PageResponse::Page(Template::render(
|
||||
"account/login",
|
||||
context! {failure: true, meta},
|
||||
))
|
||||
}
|
||||
})(String::from(values.username));
|
||||
|
||||
warn!("[audit|{}] login attempt ({})", client_ip, &values.username);
|
||||
|
||||
let user_in_db = user::get(&mut *db, &values.username).await?;
|
||||
let user = match user_in_db {
|
||||
None => return Ok(miss()),
|
||||
None => {
|
||||
task::spawn_blocking(move || pw::verify("meow", "$argon2i$v=19$m=65536,t=3,p=1$fJ+f67UGHB+EIjGIDEwbSQ$V/nZPHmdyqHq8fTBTdt3sEmTyr0W7i/F98EIxaaJJt0")).await??;
|
||||
return Ok(miss());
|
||||
}
|
||||
Some(v) => v,
|
||||
};
|
||||
|
||||
|
@ -78,11 +100,211 @@ pub async fn handle_login(
|
|||
task::spawn_blocking(move || pw::verify(&password, &user.password)).await??;
|
||||
|
||||
if right_password && user.enabled {
|
||||
session::login(cookies, &user.id);
|
||||
let enabled_methods = otp::list_enabled_methods(&mut *db, &user.id).await?;
|
||||
|
||||
if enabled_methods.len() == 0 {
|
||||
session::login(cookies, &user.id);
|
||||
warn!(
|
||||
"[audit|{}] [{}] login successful ({})",
|
||||
client_ip,
|
||||
user.id.to_string(),
|
||||
&values.username
|
||||
);
|
||||
let next_url = String::from(next.unwrap_or("/"));
|
||||
Ok(Redirect::to(next_url).into())
|
||||
} else {
|
||||
warn!(
|
||||
"[audit|{}] [{}] login user credentials validated; 2FA needed ({})",
|
||||
client_ip,
|
||||
user.id.to_string(),
|
||||
&values.username
|
||||
);
|
||||
|
||||
// only one impl. for now; flow and UI may change if/when more are added
|
||||
if enabled_methods[0].contains(METHOD_TOTP) {
|
||||
session::init_2fa_auth(cookies, &user.id);
|
||||
|
||||
Ok(Redirect::to(uri!(show_2fa_form(next))).into())
|
||||
} else {
|
||||
panic!(
|
||||
"login - 2FA redirect - {:?} not implemented",
|
||||
enabled_methods
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Ok(miss())
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/login/2fa?<next>")]
|
||||
pub fn show_2fa_form(
|
||||
maybe_loggedin: Option<User>,
|
||||
_auth_session: AuthSession,
|
||||
cookies: &CookieJar<'_>,
|
||||
next: Option<&str>,
|
||||
meta: CommonTemplateState,
|
||||
) -> PageResult {
|
||||
if maybe_loggedin.is_some() {
|
||||
let next = String::from(next.unwrap_or("/"));
|
||||
session::clear_2fa_auth(cookies);
|
||||
Ok(Redirect::to(next).into())
|
||||
} else {
|
||||
Ok(Template::render(
|
||||
"account/otp/login",
|
||||
context! {
|
||||
recovery_url: uri!(show_2fa_recover_form(next)),
|
||||
meta,
|
||||
},
|
||||
)
|
||||
.into())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, FromForm)]
|
||||
pub struct TOTP2FAForm<'a> {
|
||||
#[field(validate=len(6..=6))]
|
||||
pub otp_code: &'a str,
|
||||
}
|
||||
|
||||
#[post("/login/2fa?<next>", data = "<form>")]
|
||||
pub async fn handle_2fa_form(
|
||||
mut db: DollTagsDb,
|
||||
maybe_loggedin: Option<User>,
|
||||
auth_session: AuthSession,
|
||||
next: Option<&str>,
|
||||
cookies: &CookieJar<'_>,
|
||||
form: Form<Contextual<'_, TOTP2FAForm<'_>>>,
|
||||
meta: CommonTemplateState,
|
||||
client_ip: IpAddr,
|
||||
) -> PageResult {
|
||||
if maybe_loggedin.is_some() {
|
||||
let next = String::from(next.unwrap_or("/"));
|
||||
return Ok(Redirect::to(next).into());
|
||||
}
|
||||
|
||||
let otp_config = otp::get_otp_method(&mut *db, &auth_session.0, METHOD_TOTP).await?;
|
||||
|
||||
let (form_data, otp_config) = match (&form.value, otp_config) {
|
||||
(Some(form), Some(otp)) => (form, otp),
|
||||
_ => {
|
||||
return Ok(Template::render(
|
||||
"account/otp/login",
|
||||
context! {
|
||||
recovery_url: uri!(show_2fa_recover_form(next)),
|
||||
failure: true,
|
||||
meta,
|
||||
},
|
||||
)
|
||||
.into())
|
||||
}
|
||||
};
|
||||
|
||||
let totp = make_totp(
|
||||
&auth_session.0.to_string(),
|
||||
Secret::Encoded(otp_config.secret_seed)
|
||||
.to_raw()?
|
||||
.to_bytes()?,
|
||||
)?;
|
||||
|
||||
if totp.check_current(form_data.otp_code)? {
|
||||
session::clear_2fa_auth(cookies);
|
||||
session::login(cookies, &auth_session.0);
|
||||
|
||||
warn!(
|
||||
"[audit|{}] [{}] TOTP 2FA login successful",
|
||||
client_ip,
|
||||
&auth_session.0.to_string()
|
||||
);
|
||||
let next_url = String::from(next.unwrap_or("/"));
|
||||
Ok(Redirect::to(next_url).into())
|
||||
} else {
|
||||
Ok(miss())
|
||||
Ok(Template::render(
|
||||
"account/otp/login",
|
||||
context! {
|
||||
recovery_url: uri!(show_2fa_recover_form(next)),
|
||||
failure: true,
|
||||
meta,
|
||||
},
|
||||
)
|
||||
.into())
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/login/2fa/recover?<next>")]
|
||||
pub fn show_2fa_recover_form(
|
||||
maybe_loggedin: Option<User>,
|
||||
_auth_session: AuthSession,
|
||||
next: Option<&str>,
|
||||
meta: CommonTemplateState,
|
||||
) -> PageResult {
|
||||
if maybe_loggedin.is_some() {
|
||||
let next = String::from(next.unwrap_or("/"));
|
||||
return Ok(Redirect::to(next).into());
|
||||
}
|
||||
|
||||
Ok(Template::render("account/otp/recovery_login", context! {meta}).into())
|
||||
}
|
||||
|
||||
#[derive(Debug, FromForm)]
|
||||
pub struct TOTP2FARecoverForm<'a> {
|
||||
#[field(validate=len(16..=16))]
|
||||
pub recovery_key: &'a str,
|
||||
}
|
||||
|
||||
#[post("/login/2fa/recover?<next>", data = "<form>")]
|
||||
pub async fn handle_2fa_recover_form(
|
||||
mut db: DollTagsDb,
|
||||
maybe_loggedin: Option<User>,
|
||||
auth_session: AuthSession,
|
||||
cookies: &CookieJar<'_>,
|
||||
form: Form<Contextual<'_, TOTP2FARecoverForm<'_>>>,
|
||||
next: Option<&str>,
|
||||
meta: CommonTemplateState,
|
||||
client_ip: IpAddr,
|
||||
) -> PageResult {
|
||||
if maybe_loggedin.is_some() {
|
||||
let next = String::from(next.unwrap_or("/"));
|
||||
return Ok(Redirect::to(next).into());
|
||||
}
|
||||
|
||||
let otp_config = otp::get_otp_method(&mut *db, &auth_session.0, METHOD_TOTP).await?;
|
||||
|
||||
let (form_data, otp_config) = match (&form.value, otp_config) {
|
||||
(Some(form), Some(otp)) => (form, otp),
|
||||
_ => {
|
||||
return Ok(Template::render(
|
||||
"account/otp/recovery_login",
|
||||
context! {failure: true, meta},
|
||||
)
|
||||
.into())
|
||||
}
|
||||
};
|
||||
|
||||
let submitted_key = String::from(form_data.recovery_key);
|
||||
let hashed_key = otp_config.recovery_key;
|
||||
let right_recovery_key =
|
||||
task::spawn_blocking(move || pw::verify(&submitted_key, &hashed_key)).await??;
|
||||
|
||||
if right_recovery_key {
|
||||
warn!(
|
||||
"[audit|{}] [{}] TOTP 2FA recovery key used - disabling TOTP 2FA",
|
||||
client_ip,
|
||||
&auth_session.0.to_string()
|
||||
);
|
||||
|
||||
otp::delete_otp_method(&mut *db, &auth_session.0, METHOD_TOTP).await?;
|
||||
session::clear_2fa_auth(cookies);
|
||||
session::login(cookies, &auth_session.0);
|
||||
|
||||
Ok(Redirect::to(uri!(
|
||||
"/account",
|
||||
routes::account::settings::show_settings,
|
||||
"#otp"
|
||||
))
|
||||
.into())
|
||||
} else {
|
||||
Ok(Template::render("account/otp/recovery_login", context! {failure: true, meta}).into())
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -132,11 +354,12 @@ fn validate_email<'v>(email: &str) -> form::Result<'v, ()> {
|
|||
|
||||
#[post("/register", data = "<form>")]
|
||||
pub async fn handle_register(
|
||||
db: DollTagsDb,
|
||||
mut db: DollTagsDb,
|
||||
form: Form<Contextual<'_, RegisterForm<'_>>>,
|
||||
cookies: &CookieJar<'_>,
|
||||
maybe_loggedin: Option<User>,
|
||||
meta: CommonTemplateState,
|
||||
client_ip: IpAddr,
|
||||
) -> PageResult {
|
||||
if maybe_loggedin.is_some() {
|
||||
return Ok(Redirect::to("/").into());
|
||||
|
@ -161,16 +384,11 @@ pub async fn handle_register(
|
|||
}
|
||||
};
|
||||
|
||||
debug!(
|
||||
"registering account {} ({:?})",
|
||||
values.username, values.email
|
||||
);
|
||||
|
||||
let password = String::from(values.password);
|
||||
let hashed_password = task::spawn_blocking(move || pw::hash(&password)).await??;
|
||||
|
||||
let account_id = user::create(
|
||||
db,
|
||||
&mut *db,
|
||||
values.username,
|
||||
&hashed_password,
|
||||
if values.email.len() != 0 {
|
||||
|
@ -181,9 +399,18 @@ pub async fn handle_register(
|
|||
)
|
||||
.await?;
|
||||
|
||||
warn!(
|
||||
"[audit|{}] [{}] account creation",
|
||||
client_ip,
|
||||
&account_id.to_string()
|
||||
);
|
||||
session::login(cookies, &account_id);
|
||||
|
||||
Ok(Redirect::to(uri!("/")).into())
|
||||
Ok(Redirect::to(uri!(
|
||||
"/account",
|
||||
crate::routes::form::register_tag::show_register
|
||||
))
|
||||
.into())
|
||||
}
|
||||
|
||||
#[get("/logout")]
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
use std::collections::HashMap;
|
||||
use std::{collections::HashMap, net::IpAddr};
|
||||
|
||||
use rocket::{
|
||||
form::{self, Contextual, Form},
|
||||
|
@ -41,6 +41,7 @@ impl From<DollProfile> for FakeContext {
|
|||
"microchip_id",
|
||||
vec![tag.microchip_id.unwrap_or(String::from(""))],
|
||||
),
|
||||
("is_public", vec![tag.is_public.to_string()]),
|
||||
("name", vec![tag.name]),
|
||||
("pronoun_subject", vec![tag.pronoun_subject]),
|
||||
("pronoun_object", vec![tag.pronoun_object]),
|
||||
|
@ -80,23 +81,29 @@ impl From<DollProfile> for FakeContext {
|
|||
pub async fn show_edit_tag(
|
||||
mut db: DollTagsDb,
|
||||
id: &str,
|
||||
_user: User,
|
||||
user: User,
|
||||
meta: CommonTemplateState,
|
||||
) -> PageResult {
|
||||
let normalized_id = match id_public_to_db(id) {
|
||||
Some(v) => v,
|
||||
None => return Ok(Redirect::to(uri!("/account", account::index)).into()),
|
||||
None => return Ok(Redirect::to(uri!("/account", account::common::index)).into()),
|
||||
};
|
||||
let tag = match doll::get(&mut *db, normalized_id, "", true).await? {
|
||||
Some(v) => v,
|
||||
None => return Ok(Redirect::to(uri!("/account", account::index)).into()),
|
||||
let tag = match doll::get(&mut *db, normalized_id, "").await? {
|
||||
Some(v) => {
|
||||
if v.bound_to_id != user.id {
|
||||
return Ok(Redirect::to(uri!("/account", account::common::index)).into());
|
||||
}
|
||||
|
||||
v
|
||||
}
|
||||
None => return Ok(Redirect::to(uri!("/account", account::common::index)).into()),
|
||||
};
|
||||
|
||||
Ok(Template::render(
|
||||
"register_tag",
|
||||
context! {
|
||||
mode: "edit",
|
||||
id,
|
||||
id: normalized_id,
|
||||
previous: FakeContext::from(tag),
|
||||
meta,
|
||||
},
|
||||
|
@ -106,11 +113,13 @@ pub async fn show_edit_tag(
|
|||
|
||||
#[derive(Debug, FromForm)]
|
||||
pub struct TagForm<'a> {
|
||||
#[field(validate=validate_id())]
|
||||
#[field(validate=crate::ids::validate_id())]
|
||||
pub ident: &'a str,
|
||||
#[field(validate=len(..32))]
|
||||
pub microchip_id: &'a str,
|
||||
|
||||
pub is_public: bool,
|
||||
|
||||
#[field(validate=len(1..=256))]
|
||||
pub name: &'a str,
|
||||
#[field(validate=len(1..=32))]
|
||||
|
@ -142,14 +151,6 @@ pub struct TagForm<'a> {
|
|||
pub chassis_color: &'a str,
|
||||
}
|
||||
|
||||
fn validate_id<'v>(id: &str) -> form::Result<'v, ()> {
|
||||
if let None = id_public_to_db(id) {
|
||||
Err(form::Error::validation("id not formatted properly"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_chassis<'v>(a: &str, b: &str, c: &str, field: &str) -> form::Result<'v, ()> {
|
||||
let all_empty = a.len() == 0 && b.len() == 0 && c.len() == 0;
|
||||
let all_full = a.len() != 0
|
||||
|
@ -172,6 +173,7 @@ pub async fn handle_register(
|
|||
tag: Form<Contextual<'_, TagForm<'_>>>,
|
||||
user: User,
|
||||
meta: CommonTemplateState,
|
||||
client_ip: IpAddr,
|
||||
) -> PageResult {
|
||||
let tag = match tag.value {
|
||||
Some(ref values) => values,
|
||||
|
@ -194,7 +196,6 @@ pub async fn handle_register(
|
|||
}
|
||||
};
|
||||
|
||||
debug!("registering tag: {:?}", tag);
|
||||
fn normalize_opt(opt: &str) -> Option<&str> {
|
||||
if opt.len() != 0 {
|
||||
Some(opt)
|
||||
|
@ -207,11 +208,12 @@ pub async fn handle_register(
|
|||
let normalized_microchip_id = tag.microchip_id.to_lowercase();
|
||||
let microchip_id = normalize_opt(&normalized_microchip_id);
|
||||
|
||||
if doll::id_exists(&mut *db, id, microchip_id.unwrap_or("")).await? {
|
||||
if doll::id_exists(&mut *db, id).await? {
|
||||
// TODO: that's weird... what was i expecting to do here?
|
||||
return Ok(Redirect::found(uri!("/account", show_register)).into());
|
||||
}
|
||||
|
||||
doll::create(
|
||||
let creation_status = doll::create(
|
||||
&mut *db,
|
||||
CreateDollProfile {
|
||||
id,
|
||||
|
@ -230,11 +232,33 @@ pub async fn handle_register(
|
|||
chassis_id: normalize_opt(tag.chassis_id),
|
||||
chassis_color: normalize_opt(tag.chassis_color),
|
||||
bound_to_id: &user.id,
|
||||
is_public: tag.is_public,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
.await;
|
||||
|
||||
Ok(Redirect::to(uri!(public::show_profile(Some(tag.ident), microchip_id))).into())
|
||||
if creation_status.is_err() {
|
||||
warn!(
|
||||
"[audit|{}] [{}] failed to register tag {}",
|
||||
client_ip,
|
||||
user.id.to_string(),
|
||||
tag.ident
|
||||
);
|
||||
}
|
||||
creation_status?;
|
||||
|
||||
warn!(
|
||||
"[audit|{}] [{}] registered tag {}",
|
||||
client_ip,
|
||||
user.id.to_string(),
|
||||
tag.ident
|
||||
);
|
||||
|
||||
if tag.is_public {
|
||||
Ok(Redirect::to(uri!(public::show_profile(Some(tag.ident), microchip_id))).into())
|
||||
} else {
|
||||
Ok(Redirect::to(uri!(account::common::index)).into())
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/edit_tag/<id>", data = "<tag>")]
|
||||
|
@ -246,7 +270,9 @@ pub async fn handle_edit_tag(
|
|||
meta: CommonTemplateState,
|
||||
) -> PageResult {
|
||||
let id = match id_public_to_db(id) {
|
||||
None => return Ok(Redirect::to(uri!("/account", crate::routes::account::index)).into()),
|
||||
None => {
|
||||
return Ok(Redirect::to(uri!("/account", crate::routes::account::common::index)).into())
|
||||
}
|
||||
Some(v) => v,
|
||||
};
|
||||
let tag = match tag.value {
|
||||
|
@ -267,7 +293,6 @@ pub async fn handle_edit_tag(
|
|||
}
|
||||
};
|
||||
|
||||
debug!("editing tag: {:?}", tag);
|
||||
fn normalize_opt(opt: &str) -> Option<&str> {
|
||||
if opt.len() != 0 {
|
||||
Some(opt)
|
||||
|
@ -281,6 +306,7 @@ pub async fn handle_edit_tag(
|
|||
|
||||
doll::edit(
|
||||
&mut *db,
|
||||
&user.id,
|
||||
CreateDollProfile {
|
||||
id,
|
||||
microchip_id,
|
||||
|
@ -298,9 +324,14 @@ pub async fn handle_edit_tag(
|
|||
chassis_id: normalize_opt(tag.chassis_id),
|
||||
chassis_color: normalize_opt(tag.chassis_color),
|
||||
bound_to_id: &user.id,
|
||||
is_public: tag.is_public,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Redirect::to(uri!(public::show_profile(Some(tag.ident), microchip_id))).into())
|
||||
if tag.is_public {
|
||||
Ok(Redirect::to(uri!(public::show_profile(Some(tag.ident), microchip_id))).into())
|
||||
} else {
|
||||
Ok(Redirect::to(uri!(account::common::index)).into())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
pub mod account;
|
||||
pub mod admin;
|
||||
pub mod error_handlers;
|
||||
pub mod form;
|
||||
pub mod public;
|
||||
|
|
|
@ -27,6 +27,11 @@ pub fn index(
|
|||
)
|
||||
}
|
||||
|
||||
#[get("/about")]
|
||||
pub fn about(meta: CommonTemplateState) -> Template {
|
||||
Template::render("about", context! {meta})
|
||||
}
|
||||
|
||||
#[get("/profile/<id>")]
|
||||
pub fn short_url(id: &str) -> Redirect {
|
||||
Redirect::to(uri!(show_profile(Some(id), Some(""))))
|
||||
|
@ -42,7 +47,7 @@ pub async fn show_profile(
|
|||
let internal_id = ident.and_then(|v| id_public_to_db(v)).unwrap_or(0);
|
||||
let microchip_id = microchip_id.unwrap_or("");
|
||||
|
||||
let profile = match doll::get(&mut *db, internal_id, microchip_id, false).await? {
|
||||
let profile = match doll::get_public(&mut *db, internal_id, microchip_id).await? {
|
||||
Some(p) => p,
|
||||
None => return Ok(Redirect::to(uri!(index(Some(true), ident, Some(microchip_id)))).into()),
|
||||
};
|
||||
|
|
144
templates/about.html.tera
Normal file
144
templates/about.html.tera
Normal file
|
@ -0,0 +1,144 @@
|
|||
{% extends "base" %}
|
||||
{% block title %}About Doll.Tags - {% endblock title %}
|
||||
{% block main %}
|
||||
|
||||
<p>
|
||||
Doll.Tags allows you to create and have your own pet profile pages, including stuff like identifying information,
|
||||
handler contact, etc
|
||||
(<a href="https://dolltags.pet/profile?ident=134621" target="_blank" rel="noopener">here's an example</a>)
|
||||
</p>
|
||||
|
||||
<section id="howto" class="raised">
|
||||
<p>How to create a tag?</p>
|
||||
|
||||
<ol>
|
||||
<li>Go to <a href="/login">the log-in page</a>, which also allows you to register, and click on the register
|
||||
link</li>
|
||||
<li>Fill out your username and password and check the verification checkbox (the e-mail is optional)</li>
|
||||
<li>Once your account is created, you'll land on the tag registration form</li>
|
||||
<li>Fill it out, and you now have a tag!</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<p>
|
||||
See it as the equivalent for those ID services you have for your pets where you can put a little QrCode or NFC chip
|
||||
on their collar and it would forward any passerby who may have found it roaming around to a page where they can know
|
||||
how to contact you,
|
||||
except it's for your more kinky relationships who may enjoy having it!
|
||||
</p>
|
||||
|
||||
<section id="feedback">
|
||||
<h2>Questions or feedback? Want to add some fields?</h2>
|
||||
|
||||
<p>i'm always open to your feedback and ideas on how to expand Doll.Tags, so feel free to send me a message at
|
||||
<code>feedback [@] dolltags.pet</code> and i'll try to respond quickly.
|
||||
</p>
|
||||
|
||||
<p>If you experience any issue with the service, or would like to have your account changed deleted but cannot do so
|
||||
through
|
||||
<a href="/account/settings#account-delete">the account settings page</a>, you can of course send an e-mail for
|
||||
that too.
|
||||
</p>
|
||||
|
||||
<!-- TODO: contact form here too once SMTP is implemented -->
|
||||
</section>
|
||||
|
||||
<section id="export-format">
|
||||
<h2>About the account data export format</h2>
|
||||
|
||||
<p>
|
||||
You'll note that in <a href="/account/settings#data-export">the accounts settings page</a>, there is an "Export
|
||||
all my data" button.<br />
|
||||
This generates a JSON file containing all your data and all data related to your account.
|
||||
</p>
|
||||
<p>Here's the format description of this document.</p>
|
||||
|
||||
<section class="raised">
|
||||
<details>
|
||||
<summary>Data export format description</summary>
|
||||
|
||||
<p>
|
||||
In the descriptions, "entity" refers to the tag holder (alternative words could be doll, pet, etc;
|
||||
entity is used as generic).<br />
|
||||
All timestamps are with time zone.
|
||||
</p>
|
||||
|
||||
<article>
|
||||
<ul>
|
||||
<li>
|
||||
<code>account</code> (object): your account
|
||||
<ul>
|
||||
<li><code>id</code> (UUID): the account's internal ID</li>
|
||||
<li><code>created_at</code> (timestamp): when the account was created</li>
|
||||
<li><code>updated_at</code> (null or timestamp): when the account was last updated; null if
|
||||
it never was</li>
|
||||
|
||||
<li><code>username</code> (string): the account's username</li>
|
||||
<li><code>password</code> (string): the account's password, in hashed form</li>
|
||||
<li><code>email</code> (null or string): the account's e-mail, if there's one</li>
|
||||
|
||||
<li><code>enabled</code> (boolean): true if the account is enabled</li>
|
||||
<li><code>is_admin</code> (boolean): true if the account is an admin account</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<code>tags</code> (list of objects): non-archived tags
|
||||
<ul>
|
||||
<li><code>id</code> (int): the tag's 6-digit ID</li>
|
||||
<li><code>microchip_id</code> (null or string): the tag's microchip id, if there's one</li>
|
||||
<li><code>created_at</code> (timestamp): when the tag was created</li>
|
||||
<li><code>updated_at</code> (null or timestamp): when the tag was last updated; null if it
|
||||
never was</li>
|
||||
<li><code>archived_at</code> (null or timestamp): null if the tag isn't archived, otherwise
|
||||
notes when the tag was archived</li>
|
||||
|
||||
<li><code>bound_to_id</code> (UUID): the account this tag belongs to; should be the same as
|
||||
<code>account.id</code>
|
||||
</li>
|
||||
|
||||
<li><code>name</code> (string): the pet name of the entity this tag is assigned to</li>
|
||||
<li>
|
||||
<code>pronoun_subject</code>, <code>pronoun_object</code>,
|
||||
<code>pronoun_possessive</code>
|
||||
(string): the three parts of an entity's pronouns (e.g. <code>she / her / hers</code>)
|
||||
</li>
|
||||
<li><code>handler_name</code> (string): the name of the entity's handler</li>
|
||||
<li><code>handler_link</code> (null or string): the link to contact the handler</li>
|
||||
|
||||
<li><code>kind</code> (null or string): what kind of being the entity is</li>
|
||||
<li><code>breed</code> (null or string): what breed the entity is</li>
|
||||
<li>
|
||||
<code>behaviour</code> (null or string): what behaviour is generally shown by the entity
|
||||
</li>
|
||||
<li><code>description</code> (null or string): a brief description about the entity</li>
|
||||
<li>
|
||||
<code>chassis_type</code>, <code>chassis_id</code>, <code>chassis_color</code> (null or
|
||||
string): if the entity has a chassis, some info about that chassis
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<code>reserved_tags</code> (list of int): the list of all tags you created then archived
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
</details>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section id="technical">
|
||||
<h2>About the project and its hosting</h2>
|
||||
|
||||
<p>
|
||||
The project is open-source, a more global page about it and its tech info is available
|
||||
<a href="https://www.aphrodite.dev/~notebook/projects/dolltags.html">on my garden</a>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
It's hosted in France, backups are encrypted and hosted in France (AWS, private hosting) and the US
|
||||
(backblaze).<br />
|
||||
No other service is being used, no data leaves the dolltags server, and no data is used for anything else but
|
||||
dolltags.
|
||||
</p>
|
||||
</section>
|
||||
{% endblock main %}
|
|
@ -5,6 +5,9 @@
|
|||
<p class="subnav">
|
||||
<a href="/account/new_tag">New tag</a>
|
||||
<a href="/account/settings">Account settings</a>
|
||||
{% if user.is_admin %}
|
||||
<a href="/admin">Admin panel</a>
|
||||
{% endif %}
|
||||
<a href="/logout">Log out</a>
|
||||
</p>
|
||||
</aside>
|
||||
|
@ -31,10 +34,18 @@
|
|||
</div>
|
||||
<h3>{{profile.name}}</h3>
|
||||
<div class="subnav">
|
||||
<a href="/profile/{{profile.id}}">Public page</a>
|
||||
<a href="/account/edit_tag/{{profile.id}}">Edit</a>
|
||||
<a href="/account/delete/{{profile.id}}">Delete</a>
|
||||
<a href="/profile/{{profile.id | id}}">Public page</a>
|
||||
<a href="/account/edit_tag/{{profile.id | id}}">Edit</a>
|
||||
<a href="/account/delete/{{profile.id | id}}">Delete</a>
|
||||
</div>
|
||||
<details>
|
||||
<summary>show the QR code</summary>
|
||||
|
||||
<picture class="block">
|
||||
<img loading="lazy" src="/account/qr-png/{{profile.id | id}}"
|
||||
alt="A QrCode containing a direct link to the profile" />
|
||||
</picture>
|
||||
</details>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
@ -47,7 +58,7 @@
|
|||
|
||||
<ul>
|
||||
{% for id in archived_tags %}
|
||||
<li><a href="/account/edit_tag/{{id}}">{{id}}</a></li>
|
||||
<li><a href="/account/edit_tag/{{id | id}}">{{id | pretty_id}}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
|
|
|
@ -26,6 +26,8 @@
|
|||
|
||||
<input type="checkbox" name="anti_bot" style="display: none">
|
||||
|
||||
<button type="submit" class="submit">Log in</button>
|
||||
<div class="submit">
|
||||
<button type="submit" class="submit">Log in</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock main %}
|
15
templates/account/otp/confirm.html.tera
Normal file
15
templates/account/otp/confirm.html.tera
Normal file
|
@ -0,0 +1,15 @@
|
|||
{% extends "base" %}
|
||||
{% import "macros/form" as form %}
|
||||
{% block title %}TOTP enabled - {% endblock title %}
|
||||
{% block main %}
|
||||
<p>TOTP 2FA was successfully enabled.</p>
|
||||
|
||||
<p>
|
||||
Before moving on, it's strongly recommended that you save this key somewhere safe as it will be
|
||||
needed to disable 2FA should you lose access to your 2FA codes.
|
||||
</p>
|
||||
|
||||
<pre class="recovery-key"><code>{{recovery_key}}</code></pre>
|
||||
|
||||
<a href="/account/settings" class="btn">Finish and go to settings</a>
|
||||
{% endblock main %}
|
19
templates/account/otp/confirm_regenerate_key.html.tera
Normal file
19
templates/account/otp/confirm_regenerate_key.html.tera
Normal file
|
@ -0,0 +1,19 @@
|
|||
{% extends "base" %}
|
||||
{% import "macros/form" as form %}
|
||||
{% block title %}Regenerate your recovery key - {% endblock title %}
|
||||
{% block main %}
|
||||
<p>You're about to regenerate a recovery key.</p>
|
||||
|
||||
<p>
|
||||
This will invalidate the current 2FA recovery key and generate a new one
|
||||
which you'll have to store safely.
|
||||
</p>
|
||||
|
||||
<section class="split">
|
||||
<form method="post">
|
||||
<button type="submit" class="error">Regenerate a new key</button>
|
||||
</form>
|
||||
|
||||
<p><a href="/account/settings" class="btn">Or go back to the settings</a></p>
|
||||
</section>
|
||||
{% endblock main %}
|
24
templates/account/otp/disable.html.tera
Normal file
24
templates/account/otp/disable.html.tera
Normal file
|
@ -0,0 +1,24 @@
|
|||
{% extends "base" %}
|
||||
{% import "macros/form" as form %}
|
||||
{% block title %}Disable TOTP 2FA - {% endblock title %}
|
||||
{% block main %}
|
||||
<p>You're about to disable TOTP 2FA.</p>
|
||||
|
||||
<p>
|
||||
TOTP 2FA is an important additional security measure that comes into play when
|
||||
your password gets compromised by someone.
|
||||
</p>
|
||||
<p>
|
||||
If you're about to disable it due to having lost your recovery key, know that
|
||||
you can instead choose to <a href="/account/settings/totp/generate-key">generate a new key</a>.
|
||||
</p>
|
||||
<p>If you still want to disable TOTP 2FA on your account, click on the red button below.</p>
|
||||
|
||||
<section class="split">
|
||||
<form method="post">
|
||||
<button type="submit" class="error">Disable TOTP 2FA</button>
|
||||
</form>
|
||||
|
||||
<p><a href="/account/settings" class="btn">Or go back to the settings</a></p>
|
||||
</section>
|
||||
{% endblock main %}
|
17
templates/account/otp/login.html.tera
Normal file
17
templates/account/otp/login.html.tera
Normal file
|
@ -0,0 +1,17 @@
|
|||
{% extends "base" %}
|
||||
{% block title %}Second authentication factor - {% endblock title %}
|
||||
{% block main %}
|
||||
<form method="post">
|
||||
<h2><label for="otp_code">Enter your TOTP code</label></h2>
|
||||
|
||||
{% if failure %}
|
||||
<p class="form-error">Couldn't validate this code.</p>
|
||||
{% endif %}
|
||||
|
||||
<input type="text" id="otp_code" name="otp_code" minlength="6" maxlength="6" placeholder="000000" required />
|
||||
|
||||
<button type="submit">Log in</button>
|
||||
|
||||
<p><a href="{{recovery_url}}">Use your recovery key instead</a></p>
|
||||
</form>
|
||||
{% endblock main %}
|
19
templates/account/otp/recovery_login.html.tera
Normal file
19
templates/account/otp/recovery_login.html.tera
Normal file
|
@ -0,0 +1,19 @@
|
|||
{% extends "base" %}
|
||||
{% block title %}Second authentication factor - {% endblock title %}
|
||||
{% block main %}
|
||||
<form method="post">
|
||||
<h2><label for="recovery_key">Enter your recovery key</label></h2>
|
||||
|
||||
<p>Entering your recovery key will log you in and will disable 2FA.</p>
|
||||
<p>You will be brought to the settings page in case you'd want to reconfigure it.</p>
|
||||
|
||||
{% if failure %}
|
||||
<p class="form-error">Couldn't validate this key.</p>
|
||||
{% endif %}
|
||||
|
||||
<input type="text" id="recovery_key" name="recovery_key" minlength="16" maxlength="16"
|
||||
placeholder="xxxx-xxxxxx-xxxx" required />
|
||||
|
||||
<button type="submit">Log in</button>
|
||||
</form>
|
||||
{% endblock main %}
|
11
templates/account/otp/regenerate_key.html.tera
Normal file
11
templates/account/otp/regenerate_key.html.tera
Normal file
|
@ -0,0 +1,11 @@
|
|||
{% extends "base" %}
|
||||
{% import "macros/form" as form %}
|
||||
{% block title %}Regenerate your recovery key - {% endblock title %}
|
||||
{% block main %}
|
||||
<p>Here's your new recovery key.</p>
|
||||
|
||||
<pre class="recovery-key"><code>{{recovery_key}}</code></pre>
|
||||
|
||||
<p>Make sure to save it somewhere safe, it may come in handy.</p>
|
||||
<p><a href="/account/settings" class="btn">Finish and go back to settings</a></p>
|
||||
{% endblock main %}
|
29
templates/account/otp/start.html.tera
Normal file
29
templates/account/otp/start.html.tera
Normal file
|
@ -0,0 +1,29 @@
|
|||
{% extends "base" %}
|
||||
{% import "macros/form" as form %}
|
||||
{% block title %}Adding a one-time password - {% endblock title %}
|
||||
{% block main %}
|
||||
<p>To add a one-time password provider, scan this QrCode with your authenticator app.</p>
|
||||
|
||||
<section class="split">
|
||||
<div class="totp-qrcode">
|
||||
<img src="data:image/png;base64, {{totp_qrcode}}" alt="{{secret}}" />
|
||||
<p class="note">Alternatively, you can copy/paste this code: <code>{{secret}}</code></p>
|
||||
</div>
|
||||
|
||||
<div class="totp-form">
|
||||
<p><label for="otp_code">
|
||||
Once it's added on your authenticator app,
|
||||
enter the generated 6-digit code.
|
||||
</label></p>
|
||||
|
||||
<form method="post">
|
||||
<input type="text" name="otp_code" id="otp_code" minlength="6" maxlength="6" required />
|
||||
{% if invalid_code %}
|
||||
<p class="field-error">The OTP code you sent was invalid, please retry.</p>
|
||||
{% endif %}
|
||||
|
||||
<button type="submit" class="submit">Enable 2FA</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock main %}
|
|
@ -66,7 +66,30 @@
|
|||
</form>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<section id="otp">
|
||||
<h3>Two-factor authentication</h3>
|
||||
|
||||
{% if "totp" in enabled_otp_methods %}
|
||||
<p>You have enabled time-based 2FA, which will prompt you for a code on each login.</p>
|
||||
|
||||
<p>
|
||||
You may <a href="/account/settings/totp/generate-key">regenerate a recovery key</a>
|
||||
in case you lost your current one,
|
||||
or <a href="/account/settings/totp/disable">disable TOTP 2FA altogether</a>.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if enabled_otp_methods|length == 0 %}
|
||||
<p>
|
||||
You don't have two-factor auth enabled.<br />
|
||||
You can add one using your authenticator app of choice by clicking below.
|
||||
</p>
|
||||
|
||||
<a href="/account/settings/totp" class="btn">Enable 2FA with an authenticator</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section id="data-export">
|
||||
<h3>Exporting your data</h3>
|
||||
|
||||
<p>You can export all your account's data using this button.</p>
|
||||
|
@ -75,7 +98,7 @@
|
|||
<a href="/account/data_dump" class="btn">Export all my data</a>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<section id="account-delete">
|
||||
<h3>Deleting your account</h3>
|
||||
|
||||
<p>You can delete your account by clicking on the button below.</p>
|
||||
|
|
18
templates/admin/confirm_tag_handover.html.tera
Normal file
18
templates/admin/confirm_tag_handover.html.tera
Normal file
|
@ -0,0 +1,18 @@
|
|||
{% extends "base" %}
|
||||
{% block title %}Confirm tag handover? - {% endblock title %}
|
||||
{% block main %}
|
||||
<section>
|
||||
<h2>Confirm tag handover?</h2>
|
||||
<p>
|
||||
You are about to hand over the tag <code>{{tag_id|pretty_id}}</code> to the user named "{{dest_username}}".<br />
|
||||
It will give full control of the tag to them and will not let you edit it anymore as it is a complete handover.
|
||||
</p>
|
||||
|
||||
<p>Do you wish to proceed?</p>
|
||||
|
||||
<div>
|
||||
<a href="{{proceed_url}}" class="btn">Yes, hand it over to them</a>
|
||||
<a href="/admin" class="btn">No, cancel the handover</a>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock main %}
|
81
templates/admin/index.html.tera
Normal file
81
templates/admin/index.html.tera
Normal file
|
@ -0,0 +1,81 @@
|
|||
{% extends "base" %}
|
||||
{% import "macros/form" as form %}
|
||||
{% block title %}Admin panel - {% endblock title %}
|
||||
{% block main %}
|
||||
<aside>
|
||||
<p class="subnav">
|
||||
<a href="/account">Back to account</a>
|
||||
<a href="/logout">Log out</a>
|
||||
</p>
|
||||
</aside>
|
||||
|
||||
{% if toast %}
|
||||
<div class="raised">
|
||||
<p>{{toast|capitalize}}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<section>
|
||||
<h2>Service status</h2>
|
||||
|
||||
<div class="dual-fields raised center">
|
||||
<div>
|
||||
<p class="ident">Active accounts</p>
|
||||
<p class="b">{{service_status.active_accounts_count}}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="ident">Active tag profiles</p>
|
||||
<p class="b">{{service_status.active_tags_count}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
{% set ctx = previous.tag_handover %}
|
||||
<h2>Tag handover</h2>
|
||||
<p>
|
||||
To begin handover of a tag, enter the tag's ID and destination account nickname below.<br />
|
||||
It will let you confirm the tag's account holder and the destination account before executing the handover.
|
||||
</p>
|
||||
|
||||
{% if ctx.form_errors | length > 0 %}
|
||||
<div class="form-error">
|
||||
<h3>Some errors were encountered...</h3>
|
||||
|
||||
<ul>
|
||||
{% for err in ctx.form_errors %}
|
||||
<li>{{err}}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post">
|
||||
<input type="hidden" name="form" value="TagHandover" />
|
||||
<div class="fields raised">
|
||||
<div class="dual-fields">
|
||||
<div>
|
||||
<p class="ident center">ID of the tag to hand over</p>
|
||||
<input type="text" name="tag_handover.tag_id" id="tag_id" placeholder="000000" minlength="6"
|
||||
maxlength="6" required {{form::value(ctx=ctx, name="tag_handover.tag_id" )}} />
|
||||
|
||||
{{form::error(ctx=ctx, name="tag_handover.tag_id")}}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="ident center">Destination account username</p>
|
||||
<input type="text" name="tag_handover.dest_account" id="dest_account" autocomplete="off" required
|
||||
{{form::value(ctx=ctx, name="tag_handover.dest_account" )}} />
|
||||
|
||||
{{form::error(ctx=ctx, name="tag_handover.dest_account")}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="block">
|
||||
<button type="submit">Begin tag handover</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</section>
|
||||
{% endblock main %}
|
|
@ -5,6 +5,7 @@
|
|||
<link rel=stylesheet href="/assets/site.css" />
|
||||
<title>{% block title %}{% endblock title %}Doll.Tags</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
{% block meta %}{% endblock meta %}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
@ -24,6 +25,10 @@
|
|||
{% block main %}
|
||||
{% endblock main %}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
Doll.Tags - <a href="/about">About & Contact</a>
|
||||
</footer>
|
||||
</body>
|
||||
|
||||
</html>
|
6
templates/error/forbidden.html.tera
Normal file
6
templates/error/forbidden.html.tera
Normal file
|
@ -0,0 +1,6 @@
|
|||
{% extends "base" %}
|
||||
{% block title %}Admin page access forbidden - {% endblock title %}
|
||||
{% block main %}
|
||||
<h1>This page can only be accessed by an admin, which you don't seem to be, sorry.</h1>
|
||||
<p>You can go <a href="/account">back to your account</a> or <a href="/logout">log out</a> if you wish.</p>
|
||||
{% endblock main %}
|
|
@ -1,10 +1,11 @@
|
|||
{% extends "base" %}
|
||||
{% import "macros/form" as form %}
|
||||
|
||||
{% block title %}{% if mode == "register" %}Register a new tag{% else %}Edit {{id}}{% endif %} - {% endblock title
|
||||
{% block title %}{% if mode == "register" %}Register a new tag{% else %}Edit {{id|pretty_id}}{% endif %} - {% endblock
|
||||
title
|
||||
%}
|
||||
{% block main %}
|
||||
<h2>{% if mode == "register" %}Register a new tag{% else %}Edit {{id}}{% endif %}</h2>
|
||||
<h2>{% if mode == "register" %}Register a new tag{% else %}Edit {{id|pretty_id}}{% endif %}</h2>
|
||||
|
||||
<aside>
|
||||
<h3>A foreword</h3>
|
||||
|
@ -205,13 +206,30 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<button class="submit" type="submit">
|
||||
{% if mode == "register" %}
|
||||
Register this tag!
|
||||
{% else %}
|
||||
Save your changes
|
||||
{% endif %}
|
||||
</button>
|
||||
<div class="submit">
|
||||
<div>
|
||||
{% set tag_vis = previous.values | get(key="is_public", default=[]) %}
|
||||
{% set is_public = tag_vis | length > 0 and tag_vis | first == "true" %}
|
||||
|
||||
<label for="is_public">Who can see the tag?</label>
|
||||
<select name="is_public" id="is_public">
|
||||
<option value="true">Everyone, it's public</option>
|
||||
<option value="false" {% if is_public==false %}selected{% endif %}>No one, keep it private for now
|
||||
</option>
|
||||
</select>
|
||||
{{form::error(ctx=previous, name="is_public")}}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button class="submit" type="submit">
|
||||
{% if mode == "register" %}
|
||||
Register this tag!
|
||||
{% else %}
|
||||
Save your changes
|
||||
{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
|
|
|
@ -2,6 +2,11 @@
|
|||
{% import "macros/display" as macros %}
|
||||
|
||||
{% block title %}{{ profile.name }} - {% endblock title %}
|
||||
{% block meta %}
|
||||
<meta name="og:title" content="{{profile.name}} ({{profile.pronoun_subject}}/{{profile.pronoun_object}}/{{profile.pronoun_possessive}}) - {{profile.id | pretty_id}}" />
|
||||
<meta name="og:site_name" content="Doll.Tags" />
|
||||
<meta name="og:description" content=" " />
|
||||
{% endblock meta %}
|
||||
{% block main %}
|
||||
<section class="raised profile">
|
||||
<header>
|
||||
|
@ -58,10 +63,10 @@
|
|||
<div>
|
||||
<h2>Description</h3>
|
||||
|
||||
<p>{{ profile.description | linebreaksbr }}</p>
|
||||
<p>{{ profile.description | linebreaksbr | safe }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock main %}
|
||||
{% endblock main %}
|
||||
|
|
Loading…
Add table
Reference in a new issue