| 1 | module main |
| 2 | |
| 3 | import time |
| 4 | import os |
| 5 | import veb |
| 6 | import rand |
| 7 | import validation |
| 8 | import api |
| 9 | |
| 10 | pub fn (mut app App) login(mut ctx Context) veb.Result { |
| 11 | csrf := rand.string(30) |
| 12 | ctx.set_cookie(name: 'csrf', value: csrf) |
| 13 | |
| 14 | if app.is_logged_in(mut ctx) { |
| 15 | return ctx.redirect('/' + ctx.user.username) |
| 16 | } |
| 17 | |
| 18 | return $veb.html() |
| 19 | } |
| 20 | |
| 21 | @['/login'; post] |
| 22 | pub fn (mut app App) handle_login(mut ctx Context, username string, password string) veb.Result { |
| 23 | if username == '' || password == '' { |
| 24 | return ctx.redirect_to_login() |
| 25 | } |
| 26 | user := app.get_user_by_username(username) or { return ctx.redirect_to_login() } |
| 27 | if user.is_blocked { |
| 28 | return ctx.redirect_to_login() |
| 29 | } |
| 30 | if !compare_password_with_hash(password, user.salt, user.password) { |
| 31 | app.increment_user_login_attempts(user.id) or { |
| 32 | ctx.error('There was an error while logging in') |
| 33 | return app.login(mut ctx) |
| 34 | } |
| 35 | if user.login_attempts == max_login_attempts { |
| 36 | app.warn('User ${user.username} got blocked') |
| 37 | app.block_user(user.id) or { app.info(err.str()) } |
| 38 | } |
| 39 | ctx.error('Wrong username/password') |
| 40 | return app.login(mut ctx) |
| 41 | } |
| 42 | if !user.is_registered { |
| 43 | return ctx.redirect_to_login() |
| 44 | } |
| 45 | if app.user_has_two_factor(user.id) { |
| 46 | expires := time.now().unix() + two_factor_pending_ttl |
| 47 | token := app.sign_pending_2fa(user, expires) |
| 48 | ctx.set_cookie(name: two_factor_pending_cookie, value: token, path: '/') |
| 49 | return ctx.redirect('/login/2fa') |
| 50 | } |
| 51 | app.auth_user(mut ctx, user, ctx.ip()) or { |
| 52 | ctx.error('There was an error while logging in') |
| 53 | return app.login(mut ctx) |
| 54 | } |
| 55 | app.add_security_log(user_id: user.id, kind: .logged_in) or { app.info(err.str()) } |
| 56 | return ctx.redirect('/${username}') |
| 57 | } |
| 58 | |
| 59 | @['/logout'] |
| 60 | pub fn (mut app App) handle_logout(mut ctx Context) veb.Result { |
| 61 | ctx.set_cookie(name: 'token', value: '') |
| 62 | return ctx.redirect_to_index() |
| 63 | } |
| 64 | |
| 65 | @['/:username'] |
| 66 | pub fn (mut app App) user(mut ctx Context, username string) veb.Result { |
| 67 | exists, user := app.check_username(username) |
| 68 | if !exists { |
| 69 | return ctx.not_found() |
| 70 | } |
| 71 | is_page_owner := username == ctx.user.username |
| 72 | mut repos := app.find_user_profile_repos(user.id, is_page_owner) |
| 73 | for mut repo in repos { |
| 74 | repo.lang_stats = app.find_repo_lang_stats(repo.id) |
| 75 | repo.latest_commit_at = app.find_repo_last_commit_time(repo.id) |
| 76 | } |
| 77 | activity_days := 365 |
| 78 | activity_buckets := app.get_user_daily_activity(user.id, activity_days) |
| 79 | mut activity_total := 0 |
| 80 | mut activity_max := 0 |
| 81 | for v in activity_buckets { |
| 82 | activity_total += v |
| 83 | if v > activity_max { |
| 84 | activity_max = v |
| 85 | } |
| 86 | } |
| 87 | activity_oldest := time.now().add_days(-(activity_days - 1)) |
| 88 | // Render as a 7-row grid (Mon top → Sun bottom), columns are weeks. |
| 89 | // We need to pad leading cells so the first day lands on its weekday row. |
| 90 | activity_leading := activity_oldest.day_of_week() - 1 |
| 91 | activity_start_label := activity_oldest.md() |
| 92 | activity_end_label := time.now().md() |
| 93 | activities := app.find_activities(user.id) |
| 94 | return $veb.html() |
| 95 | } |
| 96 | |
| 97 | @['/:username/settings'] |
| 98 | pub fn (mut app App) user_settings(mut ctx Context, username string) veb.Result { |
| 99 | is_users_settings := username == ctx.user.username |
| 100 | |
| 101 | if !ctx.logged_in || !is_users_settings { |
| 102 | return ctx.redirect_to_index() |
| 103 | } |
| 104 | |
| 105 | return $veb.html('templates/user/settings.html') |
| 106 | } |
| 107 | |
| 108 | @['/:username/settings'; post] |
| 109 | pub fn (mut app App) handle_update_user_settings(mut ctx Context, username string) veb.Result { |
| 110 | is_users_settings := username == ctx.user.username |
| 111 | |
| 112 | if !ctx.logged_in || !is_users_settings { |
| 113 | return ctx.redirect_to_index() |
| 114 | } |
| 115 | |
| 116 | // TODO: uneven parameters count (2) in `handle_update_user_settings`, compared to the vweb route `['/:user/settings', 'post']` (1) |
| 117 | new_username := ctx.form['name'] |
| 118 | full_name := ctx.form['full_name'] |
| 119 | |
| 120 | is_username_empty := validation.is_string_empty(new_username) |
| 121 | |
| 122 | if is_username_empty { |
| 123 | ctx.error('New name is empty') |
| 124 | |
| 125 | return app.user_settings(mut ctx, username) |
| 126 | } |
| 127 | |
| 128 | if ctx.user.namechanges_count > max_namechanges { |
| 129 | ctx.error('You can not change your username, limit reached') |
| 130 | |
| 131 | return app.user_settings(mut ctx, username) |
| 132 | } |
| 133 | |
| 134 | is_username_valid := validation.is_username_valid(new_username) |
| 135 | |
| 136 | if !is_username_valid { |
| 137 | ctx.error('New username is not valid') |
| 138 | |
| 139 | return app.user_settings(mut ctx, username) |
| 140 | } |
| 141 | |
| 142 | is_first_namechange := ctx.user.last_namechange_time == 0 |
| 143 | can_change_usernane := ctx.user.last_namechange_time + namechange_period <= time.now().unix() |
| 144 | |
| 145 | if !(is_first_namechange || can_change_usernane) { |
| 146 | ctx.error('You need to wait until you can change the name again') |
| 147 | |
| 148 | return app.user_settings(mut ctx, username) |
| 149 | } |
| 150 | |
| 151 | is_new_username := new_username != username |
| 152 | is_new_full_name := full_name != ctx.user.full_name |
| 153 | |
| 154 | if is_new_full_name { |
| 155 | app.change_full_name(ctx.user.id, full_name) or { |
| 156 | ctx.error('There was an error while updating the settings') |
| 157 | return app.user_settings(mut ctx, username) |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | if is_new_username { |
| 162 | user := app.get_user_by_username(new_username) or { User{} } |
| 163 | |
| 164 | if user.id != 0 { |
| 165 | ctx.error('Name already exists') |
| 166 | |
| 167 | return app.user_settings(mut ctx, username) |
| 168 | } |
| 169 | |
| 170 | app.change_username(ctx.user.id, new_username) or { |
| 171 | ctx.error('There was an error while updating the settings') |
| 172 | return app.user_settings(mut ctx, username) |
| 173 | } |
| 174 | app.incement_namechanges(ctx.user.id) or { |
| 175 | ctx.error('There was an error while updating the settings') |
| 176 | return app.user_settings(mut ctx, username) |
| 177 | } |
| 178 | app.rename_user_directory(username, new_username) |
| 179 | } |
| 180 | |
| 181 | return ctx.redirect('/${new_username}') |
| 182 | } |
| 183 | |
| 184 | fn (mut app App) rename_user_directory(old_name string, new_name string) { |
| 185 | os.mv('${app.config.repo_storage_path}/${old_name}', |
| 186 | '${app.config.repo_storage_path}/${new_name}') or { panic(err) } |
| 187 | } |
| 188 | |
| 189 | pub fn (mut app App) register(mut ctx Context) veb.Result { |
| 190 | if ctx.logged_in { |
| 191 | return ctx.redirect('/${ctx.user.username}') |
| 192 | } |
| 193 | user_count := app.get_users_count() or { 0 } |
| 194 | no_users := user_count == 0 |
| 195 | |
| 196 | ctx.current_path = '' |
| 197 | |
| 198 | return $veb.html() |
| 199 | } |
| 200 | |
| 201 | fn (mut app App) register_failed(mut ctx Context, no_redirect string, msg string) veb.Result { |
| 202 | if no_redirect == '1' { |
| 203 | ctx.res.set_status(.bad_request) |
| 204 | return ctx.text(msg) |
| 205 | } |
| 206 | ctx.error(msg) |
| 207 | return app.register(mut ctx) |
| 208 | } |
| 209 | |
| 210 | @['/register'; post] |
| 211 | pub fn (mut app App) handle_register(mut ctx Context, username string, email string, password string, no_redirect string) veb.Result { |
| 212 | user_count := app.get_users_count() or { |
| 213 | eprintln('[register] get_users_count failed: ${err}') |
| 214 | return app.register_failed(mut ctx, no_redirect, 'Failed to register: ${err}') |
| 215 | } |
| 216 | no_users := user_count == 0 |
| 217 | println('USERNAME=${username}') |
| 218 | |
| 219 | if username in ['login', 'register', 'new', 'new_post', 'oauth'] { |
| 220 | return app.register_failed(mut ctx, no_redirect, 'Username `${username}` is not available') |
| 221 | } |
| 222 | |
| 223 | user_chars := username.bytes() |
| 224 | |
| 225 | if user_chars.len > max_username_len { |
| 226 | return app.register_failed(mut ctx, no_redirect, |
| 227 | 'Username is too long (max. ${max_username_len})') |
| 228 | } |
| 229 | |
| 230 | if username.contains('--') { |
| 231 | return app.register_failed(mut ctx, no_redirect, 'Username cannot contain two hyphens') |
| 232 | } |
| 233 | |
| 234 | if user_chars[0] == `-` || user_chars.last() == `-` { |
| 235 | return app.register_failed(mut ctx, no_redirect, |
| 236 | 'Username cannot begin or end with a hyphen') |
| 237 | } |
| 238 | |
| 239 | for ch in user_chars { |
| 240 | if !ch.is_letter() && !ch.is_digit() && ch != `-` { |
| 241 | return app.register_failed(mut ctx, no_redirect, |
| 242 | 'Username cannot contain special characters') |
| 243 | } |
| 244 | } |
| 245 | |
| 246 | is_username_valid := validation.is_username_valid(username) |
| 247 | |
| 248 | if !is_username_valid { |
| 249 | return app.register_failed(mut ctx, no_redirect, 'Username is not valid') |
| 250 | } |
| 251 | |
| 252 | if password == '' { |
| 253 | return app.register_failed(mut ctx, no_redirect, 'Password cannot be empty') |
| 254 | } |
| 255 | |
| 256 | salt := generate_salt() |
| 257 | hashed_password := hash_password_with_salt(password, salt) |
| 258 | |
| 259 | if username == '' || email == '' { |
| 260 | return app.register_failed(mut ctx, no_redirect, 'Username or Email cannot be emtpy') |
| 261 | } |
| 262 | |
| 263 | // TODO: refactor |
| 264 | is_registered := app.register_user(username, hashed_password, salt, [email], false, no_users) or { |
| 265 | eprintln('[register] register_user failed for username=${username} email=${email}: ${err}') |
| 266 | msg := if is_unique_constraint_error(err) { |
| 267 | 'Username `${username}` or email `${email}` is already in use' |
| 268 | } else { |
| 269 | 'Failed to register: ${err.msg()}' |
| 270 | } |
| 271 | return app.register_failed(mut ctx, no_redirect, msg) |
| 272 | } |
| 273 | |
| 274 | if !is_registered { |
| 275 | eprintln('[register] register_user returned false for username=${username} email=${email} (user already exists or insertion mismatch — see prior info logs)') |
| 276 | return app.register_failed(mut ctx, no_redirect, |
| 277 | 'Failed to register: user already exists or could not be inserted') |
| 278 | } |
| 279 | |
| 280 | user := app.get_user_by_username(username) or { |
| 281 | return app.register_failed(mut ctx, no_redirect, 'User already exists') |
| 282 | } |
| 283 | |
| 284 | if no_users { |
| 285 | app.add_admin(user.id) or { app.info(err.str()) } |
| 286 | } |
| 287 | |
| 288 | client_ip := 'ip' // ctx.ip() // XTODO |
| 289 | |
| 290 | app.auth_user(mut ctx, user, client_ip) or { |
| 291 | eprintln('[register] auth_user failed for username=${username}: ${err}') |
| 292 | return app.register_failed(mut ctx, no_redirect, 'Failed to register: ${err}') |
| 293 | } |
| 294 | app.add_security_log(user_id: user.id, kind: .registered) or { app.info(err.str()) } |
| 295 | |
| 296 | if no_redirect == '1' { |
| 297 | return ctx.text('ok') |
| 298 | } |
| 299 | |
| 300 | return ctx.redirect('/' + username) |
| 301 | } |
| 302 | |
| 303 | @['/api/v1/users/avatar'; post] |
| 304 | pub fn (mut app App) handle_upload_avatar(mut ctx Context) veb.Result { |
| 305 | if !ctx.logged_in { |
| 306 | return ctx.not_found() |
| 307 | } |
| 308 | |
| 309 | avatar := ctx.files['file'].first() |
| 310 | file_content_type := avatar.content_type |
| 311 | file_content := avatar.data |
| 312 | |
| 313 | file_extension := extract_file_extension_from_mime_type(file_content_type) or { |
| 314 | response := api.ApiErrorResponse{ |
| 315 | message: err.str() |
| 316 | } |
| 317 | |
| 318 | return ctx.json(response) |
| 319 | } |
| 320 | |
| 321 | is_content_size_valid := validate_avatar_file_size(file_content) |
| 322 | |
| 323 | if !is_content_size_valid { |
| 324 | response := api.ApiErrorResponse{ |
| 325 | message: 'This file is too large to be uploaded' |
| 326 | } |
| 327 | |
| 328 | return ctx.json(response) |
| 329 | } |
| 330 | |
| 331 | username := ctx.user.username |
| 332 | avatar_filename := '${username}.${file_extension}' |
| 333 | |
| 334 | app.write_user_avatar(avatar_filename, file_content) |
| 335 | app.update_user_avatar(ctx.user.id, avatar_filename) or { |
| 336 | response := api.ApiErrorResponse{ |
| 337 | message: 'There was an error while updating the avatar' |
| 338 | } |
| 339 | |
| 340 | return ctx.json(response) |
| 341 | } |
| 342 | |
| 343 | avatar_file_path := app.build_avatar_file_path(avatar_filename) |
| 344 | avatar_file_url := app.build_avatar_file_url(avatar_filename) |
| 345 | |
| 346 | app.serve_static(avatar_file_url, avatar_file_path) or { panic(err) } |
| 347 | |
| 348 | response := api.ApiResponse{ |
| 349 | success: true |
| 350 | } |
| 351 | |
| 352 | return ctx.json(response) |
| 353 | } |
| 354 | |