Compare commits

..

27 commits

Author SHA1 Message Date
9580711d6b feat: ability to hide tags 2025-04-02 12:46:29 +02:00
1988abe369 blep 2025-03-26 14:23:30 +01:00
01ab37b401 fix: dumb css miss 2025-03-26 13:54:42 +01:00
82b517aa86 feat: cleaner width, closes #1 (yea thats it) 2025-03-19 17:06:20 +01:00
2c886f83e7 feat: TOTP 2FA, closes #5 2025-03-19 13:19:26 +01:00
dffdc779e1 fix: now leading zeros are properly handled on the user account page 2025-03-07 21:11:05 +01:00
d41cda2243 updated main git link 2025-02-24 22:52:18 +01:00
d313d05e66 ugh fuck 2025-02-19 20:05:40 +01:00
4fc3adec86 fix: users cannot edit, delete, or restore other users' tags 2025-02-19 19:56:39 +01:00
6f3c9a6031 admin: implement single tag handover 2025-02-19 19:43:16 +01:00
77f231c913 fix: properly bound sizing of image 2025-02-08 19:47:04 +01:00
e46cfee33b dev: added validation link to the docs repo 2025-02-08 19:22:52 +01:00
91889cd949 dev: config cleanup 2025-02-08 19:05:51 +01:00
f74187843c updated the readme too 2025-02-08 18:42:22 +01:00
761e1daf23 added qrcode on the tags 2025-02-08 18:18:35 +01:00
dd26eed497 added a changelog 2025-02-08 15:19:59 +01:00
ab2bc6ad49 oh yea admins 2025-02-07 22:16:10 +01:00
8eba0fffd4 added the prod deployment instructions 2025-02-07 22:13:35 +01:00
9eb1ef335a fix: oops that format is a lot derpier than expected 2025-02-07 14:02:24 +01:00
8fde98e9d5 added social tags 2025-02-06 22:20:15 +01:00
8e68d8bb24 implemented db reseed in CLI 2025-02-05 11:02:37 +01:00
d7abcaec9f implemented about section 2025-02-04 20:26:50 +01:00
b030163fb1 fix: computing password verification no matter the username status 2025-02-01 15:04:48 +01:00
08ffe8c850 fix: <br> in profile description are now properly handled 2025-02-01 14:56:17 +01:00
746f8fb639 fix: license details 2025-02-01 14:54:47 +01:00
2ed9faad98 fix: bumped audit log level higher
this is to avoid having to display everything else that's on info level,
as i'd like to keep what's on info on info.
2025-01-30 09:48:55 +01:00
755be599ee implemented audit logs to syslog 2025-01-27 20:17:54 +01:00
73 changed files with 2869 additions and 457 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
# built binary
/target
.env
Rocket.toml

View 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"
}

View 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"
}

View file

@ -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"

View 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"
}

View file

@ -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"
}

View 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"
}

View file

@ -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"
}

View 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"
}

View 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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View 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"
}

View file

@ -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"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "truncate doll_profiles, users;",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "edc1a391382e8cc6dcdf87ed82cceee8d94039c4330601449834c44979e68e93"
}

View 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"
}

View 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"
}

View 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
View file

@ -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
View 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

File diff suppressed because it is too large Load diff

View file

@ -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"] }

View file

@ -1,5 +1,5 @@
Aphrodite
Copyright Scarlet 2021
Doll.Tags
Copyright Scarlet 2025
COOPERATIVE NON-VIOLENT PUBLIC LICENSE v6

View file

@ -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';`

View file

@ -1,4 +1,6 @@
[default]
secret_key = "8STDFCStGMYGoOq8RJf3JJXsg4p6wZVAph50R3Fbq6U="
public_url = "http://localhost:8000/"
[default.databases.dolltags]
url = "postgres://postgres:woofwoof@localhost/dolltags"
url = "postgres://%2Frun%2Fpostgresql/dolltags"

6
TODO.md Normal file
View 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

View file

@ -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;
}
@ -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;
}
}

View file

@ -0,0 +1 @@
update users set enabled = false where username = 'legacy';

11
migrations/8_totp.sql Normal file
View 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
);

View file

@ -0,0 +1,2 @@
alter table doll_profiles
add column is_public boolean not null default true;

View file

@ -1,2 +1,3 @@
pub mod otp;
pub mod pw;
pub mod session;

53
src/auth/otp.rs Normal file
View 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")))
}

View file

@ -1,10 +1,11 @@
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};
@ -66,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()))
@ -110,6 +111,52 @@ impl<'a> FromRequest<'a> for Admin {
}
}
/// 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();

72
src/cli.rs Normal file
View 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
View file

@ -0,0 +1,7 @@
use rocket::serde::Deserialize;
#[derive(Debug, Deserialize)]
#[serde(crate = "rocket::serde")]
pub struct Config {
pub public_url: String,
}

View file

@ -1,4 +1,6 @@
use super::schema::{ServiceStatus, TrxHook};
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> {
@ -19,3 +21,15 @@ pub async fn get_service_status(trx: &mut TrxHook<'_>) -> sqlx::Result<ServiceSt
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(())
}

View file

@ -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

View file

@ -1,5 +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
View 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(())
}

View 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.'
);

View 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
View 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(())
}

View file

@ -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,
@ -98,6 +100,18 @@ pub struct User {
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")]

View file

@ -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>,

View file

@ -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(())
}

View file

@ -5,6 +5,8 @@ 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;
@ -14,6 +16,8 @@ use routes::form::accounts;
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,7 +51,11 @@ 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![
@ -60,26 +68,42 @@ fn rocket() -> _ {
.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("/admin", routes![admin::index,])
.mount(
"/",
routes![
public::index,
public::about,
public::short_url,
public::show_profile,
accounts::show_register,
@ -87,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,
],
)
}

View file

@ -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, db::schema::User, 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())
});
})
}
@ -36,6 +40,8 @@ pub struct CommonTemplateState {
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 {
@ -55,11 +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(),
})
}
}
@ -67,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
@ -77,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
}

View 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())
}

View file

@ -0,0 +1,3 @@
pub mod common;
pub mod otp;
pub mod settings;

210
src/routes/account/otp.rs Normal file
View 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())
}

View file

@ -2,54 +2,41 @@ 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)]
@ -113,7 +100,7 @@ pub async fn change_settings(
user::update_info(&mut *db, &user.id, new_username, new_email).await?;
info!(
warn!(
"[audit|{}] [{}] changed username/email (from {})",
client_ip,
user.id.to_string(),
@ -189,7 +176,7 @@ 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?;
info!(
warn!(
"[audit|{}] [{}] changed password",
client_ip,
user.id.to_string()
@ -197,104 +184,3 @@ pub async fn change_password(
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,
client_ip: IpAddr,
) -> RawResult<Json<DataDump>> {
let tags = doll::list(&mut *db, &user.id).await?;
let reserved_tags = doll::list_archived(&mut *db, &user.id).await?;
info!(
"[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, "", 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,
client_ip: IpAddr,
) -> PageResult {
let mut trx = db.begin().await?;
doll::delete(&mut trx, id).await?;
trx.commit().await?;
info!(
"[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?;
info!(
"[audit|{}] [{}] deleted account",
client_ip,
user.id.to_string()
);
Ok(Redirect::to("/").into())
}

View file

@ -1,26 +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, schema::DollTagsDb},
pages::CommonTemplateState,
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) -> PageResult {
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())
}

View file

@ -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);

View file

@ -8,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)]
@ -45,7 +54,7 @@ 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<'_>,
@ -67,7 +76,7 @@ pub async fn handle_login(
let miss = (|username: String| {
move || {
info!("[audit|{}] login failure ({})", client_ip, username);
warn!("[audit|{}] login failure ({})", client_ip, username);
PageResponse::Page(Template::render(
"account/login",
context! {failure: true, meta},
@ -75,11 +84,14 @@ pub async fn handle_login(
}
})(String::from(values.username));
info!("[audit|{}] login attempt ({})", client_ip, &values.username);
warn!("[audit|{}] login attempt ({})", client_ip, &values.username);
let user_in_db = user::get(db, &values.username).await?;
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,
};
@ -88,17 +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);
info!(
"[audit|{}] [{}] login successful ({})",
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,
user.id.to_string(),
&values.username
&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())
}
}
@ -148,7 +354,7 @@ 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>,
@ -182,7 +388,7 @@ pub async fn handle_register(
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 {
@ -193,14 +399,18 @@ pub async fn handle_register(
)
.await?;
info!(
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")]

View file

@ -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
@ -207,7 +208,7 @@ 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());
}
@ -231,12 +232,13 @@ 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;
if creation_status.is_err() {
info!(
warn!(
"[audit|{}] [{}] failed to register tag {}",
client_ip,
user.id.to_string(),
@ -245,14 +247,18 @@ pub async fn handle_register(
}
creation_status?;
info!(
warn!(
"[audit|{}] [{}] registered tag {}",
client_ip,
user.id.to_string(),
tag.ident
);
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())
}
}
#[post("/edit_tag/<id>", data = "<tag>")]
@ -264,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 {
@ -298,6 +306,7 @@ pub async fn handle_edit_tag(
doll::edit(
&mut *db,
&user.id,
CreateDollProfile {
id,
microchip_id,
@ -315,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())
}
}

View file

@ -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
View 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 %}

View file

@ -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>

View file

@ -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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View file

@ -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>

View 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 %}

View file

@ -1,4 +1,5 @@
{% extends "base" %}
{% import "macros/form" as form %}
{% block title %}Admin panel - {% endblock title %}
{% block main %}
<aside>
@ -8,6 +9,12 @@
</p>
</aside>
{% if toast %}
<div class="raised">
<p>{{toast|capitalize}}</p>
</div>
{% endif %}
<section>
<h2>Service status</h2>
@ -22,4 +29,53 @@
</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 %}

View file

@ -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>

View file

@ -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>

View file

@ -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="&nbsp;" />
{% 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 %}