Add session management routes and pages ("/login", "/change-session")

- Also enable tweet fetching in TweetDetail handler if tweet is missing or not scraped
- Improve some UI stuff w/ more styles
- Enable HTMX page swapping in some places instead of full page loads
This commit is contained in:
Alessio 2023-08-10 12:36:28 -03:00
parent 4a40f35ff6
commit 02bc365add
13 changed files with 354 additions and 36 deletions

2
go.mod
View File

@ -4,12 +4,14 @@ go 1.16
require (
github.com/andybalholm/cascadia v1.3.2
github.com/go-playground/form/v4 v4.2.1
github.com/go-test/deep v1.0.7
github.com/jarcoal/httpmock v1.1.0
github.com/jmoiron/sqlx v1.3.4
github.com/mattn/go-sqlite3 v1.14.7
github.com/sirupsen/logrus v1.8.1
github.com/stretchr/testify v1.7.0
golang.org/x/net v0.9.0
golang.org/x/term v0.7.0
gopkg.in/yaml.v2 v2.4.0
)

4
go.sum
View File

@ -3,6 +3,10 @@ github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/form/v4 v4.2.1 h1:HjdRDKO0fftVMU5epjPW2SOREcZ6/wLUzEobqUGJuPw=
github.com/go-playground/form/v4 v4.2.1/go.mod h1:q1a2BY+AQUUzhl6xA/6hBetay6dEIhMHjgvJiGo6K7U=
github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=

View File

@ -2,12 +2,12 @@ package webserver
import (
"bytes"
"errors"
"fmt"
"net/http"
"runtime/debug"
"html/template"
"net/http"
"path/filepath"
"runtime/debug"
"gitlab.com/offline-twitter/twitter_offline_engine/pkg/scraper"
)
@ -55,18 +55,40 @@ func (app *Application) buffered_render(w http.ResponseWriter, tpl *template.Tem
type TweetCollection interface {
Tweet(id scraper.TweetID) scraper.Tweet
User(id scraper.UserID) scraper.User
FocusedTweetID() scraper.TweetID
}
// Creates a template from the given template file using all the available partials.
// Calls `app.buffered_render` to render the created template.
func (app *Application) buffered_render_template_for(w http.ResponseWriter, tpl_file string, data TweetCollection) {
func (app *Application) buffered_render_tweet_page(w http.ResponseWriter, tpl_file string, data TweetCollection) {
partials, err := filepath.Glob(get_filepath("tpl/includes/*.tpl"))
panic_if(err)
tweet_partials, err := filepath.Glob(get_filepath("tpl/tweet_page_includes/*.tpl"))
panic_if(err)
partials = append(partials, tweet_partials...)
tpl, err := template.New("does this matter at all? lol").Funcs(
template.FuncMap{"tweet": data.Tweet, "user": data.User},
template.FuncMap{"tweet": data.Tweet, "user": data.User, "active_user": app.get_active_user, "focused_tweet_id": data.FocusedTweetID},
).ParseFiles(append(partials, get_filepath(tpl_file))...)
panic_if(err)
app.buffered_render(w, tpl, data)
}
// Creates a template from the given template file using all the available partials.
// Calls `app.buffered_render` to render the created template.
func (app *Application) buffered_render_basic_page(w http.ResponseWriter, tpl_file string, data interface{}) {
partials, err := filepath.Glob(get_filepath("tpl/includes/*.tpl"))
panic_if(err)
tpl, err := template.New("does this matter at all? lol").Funcs(
template.FuncMap{"active_user": app.get_active_user},
).ParseFiles(append(partials, get_filepath(tpl_file))...)
panic_if(err)
app.buffered_render(w, tpl, data)
}
func (app *Application) get_active_user() scraper.User {
return app.ActiveUser
}

View File

@ -1,6 +1,7 @@
package webserver
import (
"bytes"
"crypto/tls"
"encoding/json"
"errors"
@ -15,6 +16,8 @@ import (
"strings"
"time"
"github.com/go-playground/form/v4"
"gitlab.com/offline-twitter/twitter_offline_engine/pkg/persistence"
"gitlab.com/offline-twitter/twitter_offline_engine/pkg/scraper"
)
@ -29,7 +32,9 @@ type Application struct {
Middlewares []Middleware
Profile persistence.Profile
Profile persistence.Profile
ActiveUser scraper.User
DisableScraping bool
}
func NewApp(profile persistence.Profile) Application {
@ -39,8 +44,8 @@ func NewApp(profile persistence.Profile) Application {
InfoLog: log.New(os.Stdout, "INFO\t", log.Ldate|log.Ltime),
ErrorLog: log.New(os.Stderr, "ERROR\t", log.Ldate|log.Ltime|log.Lshortfile),
Profile: profile,
// formDecoder: form.NewDecoder(),
Profile: profile,
ActiveUser: get_default_user(),
}
ret.Middlewares = []Middleware{
secureHeaders,
@ -85,6 +90,10 @@ func (app *Application) ServeHTTP(w http.ResponseWriter, r *http.Request) {
app.TweetDetail(w, r)
case "content":
http.StripPrefix("/content", http.FileServer(http.Dir(app.Profile.ProfileDir))).ServeHTTP(w, r)
case "login":
app.Login(w, r)
case "change-session":
app.ChangeSession(w, r)
default:
app.UserFeed(w, r)
}
@ -110,13 +119,7 @@ func (app *Application) Run(address string) {
func (app *Application) Home(w http.ResponseWriter, r *http.Request) {
app.traceLog.Printf("'Home' handler (path: %q)", r.URL.Path)
tpl, err := template.ParseFiles(
get_filepath("tpl/includes/base.tpl"),
get_filepath("tpl/includes/nav_sidebar.tpl"),
get_filepath("tpl/home.tpl"),
)
panic_if(err)
app.buffered_render(w, tpl, nil)
app.buffered_render_basic_page(w, "tpl/home.tpl", nil)
}
type TweetDetailData struct {
@ -135,6 +138,9 @@ func (t TweetDetailData) Tweet(id scraper.TweetID) scraper.Tweet {
func (t TweetDetailData) User(id scraper.UserID) scraper.User {
return t.Users[id]
}
func (t TweetDetailData) FocusedTweetID() scraper.TweetID {
return t.MainTweetID
}
func to_json(t interface{}) string {
js, err := json.Marshal(t)
@ -147,14 +153,43 @@ func to_json(t interface{}) string {
func (app *Application) TweetDetail(w http.ResponseWriter, r *http.Request) {
app.traceLog.Printf("'TweetDetail' handler (path: %q)", r.URL.Path)
_, tail := path.Split(r.URL.Path)
tweet_id, err := strconv.Atoi(tail)
val, err := strconv.Atoi(tail)
if err != nil {
app.error_400_with_message(w, fmt.Sprintf("Invalid tweet ID: %q", tail))
return
}
tweet_id := scraper.TweetID(val)
data := NewTweetDetailData()
data.MainTweetID = scraper.TweetID(tweet_id)
data.MainTweetID = tweet_id
// Return whether the scrape succeeded (if false, we should 404)
try_scrape_tweet := func() bool {
if app.DisableScraping {
return false
}
trove, err := scraper.GetTweetFullAPIV2(tweet_id, 50) // TODO: parameterizable
if err != nil {
app.ErrorLog.Print(err)
return false
}
app.Profile.SaveTweetTrove(trove)
return true
}
tweet, err := app.Profile.GetTweetById(tweet_id)
if err != nil {
if errors.Is(err, persistence.ErrNotInDB) {
if !try_scrape_tweet() {
app.error_404(w)
return
}
} else {
panic(err)
}
} else if !tweet.IsConversationScraped {
try_scrape_tweet() // If it fails, we can still render it (not 404)
}
trove, err := app.Profile.GetTweetDetail(data.MainTweetID)
if err != nil {
@ -168,7 +203,7 @@ func (app *Application) TweetDetail(w http.ResponseWriter, r *http.Request) {
app.InfoLog.Printf(to_json(trove))
data.TweetDetailView = trove
app.buffered_render_template_for(w, "tpl/tweet_detail.tpl", data)
app.buffered_render_tweet_page(w, "tpl/tweet_detail.tpl", data)
}
type UserProfileData struct {
@ -182,6 +217,9 @@ func (t UserProfileData) Tweet(id scraper.TweetID) scraper.Tweet {
func (t UserProfileData) User(id scraper.UserID) scraper.User {
return t.Users[id]
}
func (t UserProfileData) FocusedTweetID() scraper.TweetID {
return scraper.TweetID(0)
}
func (app *Application) UserFeed(w http.ResponseWriter, r *http.Request) {
app.traceLog.Printf("'UserFeed' handler (path: %q)", r.URL.Path)
@ -195,11 +233,123 @@ func (app *Application) UserFeed(w http.ResponseWriter, r *http.Request) {
}
feed, err := app.Profile.GetUserFeed(user.ID, 50, scraper.TimestampFromUnix(0))
if err != nil {
panic(err)
if errors.Is(err, persistence.ErrEndOfFeed) {
// TODO
} else {
panic(err)
}
}
feed.Users[user.ID] = user
data := UserProfileData{Feed: feed, UserID: user.ID}
app.InfoLog.Printf(to_json(data))
app.buffered_render_template_for(w, "tpl/user_feed.tpl", data)
app.buffered_render_tweet_page(w, "tpl/user_feed.tpl", data)
}
type FormErrors map[string]string
type LoginForm struct {
Username string `form:"username"`
Password string `form:"password"`
FormErrors
}
func (f *LoginForm) Validate() {
if f.FormErrors == nil {
f.FormErrors = make(FormErrors)
}
if len(f.Username) == 0 {
f.FormErrors["username"] = "cannot be blank"
}
if len(f.Password) == 0 {
f.FormErrors["password"] = "cannot be blank"
}
}
type LoginData struct {
LoginForm
ExistingSessions []scraper.UserHandle
}
func (app *Application) Login(w http.ResponseWriter, r *http.Request) {
app.traceLog.Printf("'Login' handler (path: %q)", r.URL.Path)
var form LoginForm
if r.Method == "POST" {
err := parse_form(r, &form)
if err != nil {
app.InfoLog.Print(err.Error())
app.error_400_with_message(w, "Invalid form data")
return
}
form.Validate()
if len(form.FormErrors) == 0 {
api := scraper.NewGuestSession()
api.LogIn(form.Username, form.Password)
scraper.InitApi(api)
app.Profile.SaveSession(api)
http.Redirect(w, r, "/login", 303)
}
}
data := LoginData{
LoginForm: form,
ExistingSessions: app.Profile.ListSessions(),
}
app.buffered_render_basic_page(w, "tpl/login.tpl", &data)
}
func get_default_user() scraper.User {
return scraper.User{ID: 0, Handle: "[nobody]", DisplayName: "[Not logged in]", ProfileImageLocalPath: path.Base(scraper.DEFAULT_PROFILE_IMAGE_URL), IsContentDownloaded: true}
}
func (app *Application) ChangeSession(w http.ResponseWriter, r *http.Request) {
app.traceLog.Printf("'change-session' handler (path: %q)", r.URL.Path)
form := struct {
AccountName string `form:"account"`
}{}
err := parse_form(r, &form)
if err != nil {
app.error_400_with_message(w, "Invalid form data")
return
}
if form.AccountName == "no account" {
// Special value that indicates to use a guest session
scraper.InitApi(scraper.NewGuestSession())
app.ActiveUser = get_default_user()
app.DisableScraping = true // API requests will fail b/c not logged in
} else {
// Activate the selected session
user, err := app.Profile.GetUserByHandle(scraper.UserHandle(form.AccountName))
if err != nil {
app.error_400_with_message(w, fmt.Sprintf("User not in database: %s", form.AccountName))
return
}
scraper.InitApi(app.Profile.LoadSession(scraper.UserHandle(form.AccountName)))
app.ActiveUser = user
app.DisableScraping = false
}
tpl, err := template.New("").Funcs(
template.FuncMap{"active_user": app.get_active_user},
).ParseFiles(
get_filepath("tpl/includes/nav_sidebar.tpl"),
get_filepath("tpl/includes/author_info.tpl"),
)
buf := new(bytes.Buffer)
err = tpl.ExecuteTemplate(buf, "nav-sidebar", nil)
panic_if(err)
_, err = buf.WriteTo(w)
panic_if(err)
}
var formDecoder = form.NewDecoder()
func parse_form(req *http.Request, result interface{}) error {
err := req.ParseForm()
if err != nil {
return err
}
return formDecoder.Decode(result, req.PostForm)
}

View File

@ -44,6 +44,7 @@ func selector(s string) cascadia.Sel {
func do_request(req *http.Request) *http.Response {
recorder := httptest.NewRecorder()
app := webserver.NewApp(profile)
app.DisableScraping = true
app.ServeHTTP(recorder, req)
return recorder.Result()
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" class="r-1nao33i r-4qtqp9 r-yyyyoo r-1xvli5t r-dnmrzs r-bnwqim r-1plcrui r-lrvibr"><g><path d="M3 12c0-1.1.9-2 2-2s2 .9 2 2-.9 2-2 2-2-.9-2-2zm9 2c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm7 0c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z"></path></g></svg>

After

Width:  |  Height:  |  Size: 337 B

View File

@ -40,10 +40,20 @@ body {
main {
padding-top: 4em;
}
input, select {
font-family: inherit;
font-size: 1em;
padding: 0.2em 0.6em;
box-sizing: border-box;
border-radius: 0.5em;
}
.tweet {
padding: 0 1.5em;
}
:not(.focused-tweet) > .tweet {
cursor: pointer;
}
.quoted-tweet {
padding: 1.3em;
@ -69,6 +79,10 @@ main {
align-items: center;
}
.author-info, .tweet-text {
cursor: default;
}
.name-and-handle {
padding: 0 0.5em;
}
@ -113,6 +127,7 @@ a.mention {
.tweet-text {
display: block;
margin-bottom: 0.4em;
margin-top: 0;
/* padding-bottom: 0.5em;*/
}
.focused-tweet .tweet-text {
@ -227,18 +242,19 @@ ul.quick-links {
align-items: flex-start;
padding: 0 2em;
}
li.quick-link {
.quick-link {
display: flex;
flex-direction: row;
align-items: center;
padding: 0.5em;
margin: 0.2em;
border-radius: 100em; /* any large amount, just don't use % because then it makes an ellipse */
cursor: pointer;
}
li.quick-link:hover {
.quick-link:hover {
background-color: var(--color-twitter-blue-light);
}
li.quick-link:active {
.quick-link:active {
transform: translate(2px, 2px);
background-color: var(--color-twitter-blue);
}
@ -310,7 +326,55 @@ svg {
}
.search-bar {
flex-grow: 1;
font-size: 1em;
font-family: "Titillium Web";
padding: 0.2em 0.5em;
}
.login {
width: 60%;
margin: 20% auto;
}
.login-form input {
width: 100%;
border-radius: 0.5em;
padding: 0.5em 0.6em;
}
.login-form .error {
color: #C0392B;
font-weight: bold;
}
.login-form .error + input {
border-color: #C0392B;
border-width: 2px;
}
.field-container {
padding: 0.5em 0;
}
.submit-container {
text-align: right;
}
input[type="submit"] {
background-color: var(--color-twitter-blue-light);
width: 10em;
padding: 1em;
margin-top: 1em;
border-radius: 1em;
font-size: 1em;
cursor: pointer;
}
.change-session-form select {
display: block;
width: 100%;
}
#logged-in-user-info {
font-size: 0.8em;
margin-top: 1em;
display: flex;
flex-direction: column;
align-items: center;
}
.quick-link .author-info {
pointer-events: none;
}

View File

@ -1,7 +1,10 @@
{{define "author-info"}}
<div class="author-info">
<div class="author-info" hx-boost="true">
<a class="unstyled-link" href="/{{.Handle}}">
<img class="profile-image" src="{{if .IsContentDownloaded}}/content/profile_images/{{.ProfileImageLocalPath}}{{else}}{{.ProfileImageUrl}}{{end}}" />
<img
class="profile-image"
src="{{if .IsContentDownloaded}}/content/profile_images/{{.ProfileImageLocalPath}}{{else}}{{.ProfileImageUrl}}{{end}}"
/>
</a>
<span class="name-and-handle">
<div class="display-name">{{.DisplayName}}</div>

View File

@ -7,6 +7,7 @@
<link rel='stylesheet' href='/static/styles.css'>
<link rel='shortcut icon' href='/static/img/favicon.ico' type='image/x-icon'>
<link rel='stylesheet' href='https://fonts.googleapis.com/css?family=Titillium+Web:400,700'>
<script src="https://unpkg.com/htmx.org@1.9.4" integrity="sha384-zUfuhFKKZCbHTY6aRR46gxiqszMk5tcHjsVFxnUo8VMus4kHGVdIYVbOYYNlKmHV" crossorigin="anonymous"></script>
</head>
<body>
<div class="top-bar">

View File

@ -1,5 +1,11 @@
{{define "nav-sidebar"}}
<div class="nav-sidebar">
<div id="logged-in-user-info">
<div class="quick-link" hx-get="/login" hx-trigger="click" hx-target="body" hx-push-url="true">
{{template "author-info" active_user}}
<img class="svg-icon" src="/static/icons/dotdotdot.svg" />
</div>
</div>
<ul class="quick-links">
<a class="unstyled-link" href="#">
<li class="quick-link">
@ -49,7 +55,7 @@
<span>Verified</span>
</li>
</a>
<a class="unstyled-link" href="#">
<a class="unstyled-link" href="/{{(active_user).Handle}}">
<li class="quick-link">
<img class="svg-icon" src="/static/icons/profile.svg" />
<span>Profile</span>

View File

@ -0,0 +1,40 @@
{{define "title"}}Login{{end}}
{{define "main"}}
<div class="login">
<form hx-post="/change-session" hx-target=".nav-sidebar" hx-swap="outerHTML">
<label for="select-account">Choose account:</label>
<select name="account" id="select-account">
{{range .ExistingSessions}}
<option value="{{.}}">@{{.}}</option>
{{end}}
<option value="no account">[no account (don't log in)]</option>
</select>
<div class="field-container submit-container">
<input type='submit' value='Use account'>
</div>
</form>
<p>Or log in</p>
<form class="login-form" hx-post="/login" hx-target="body">
<div class="field-container">
<label>Username</label>
{{with .FormErrors.username}}
<label class='error'>({{.}})</label>
{{end}}
<input name='username' value='{{.Username}}'>
</div>
<div class="field-container">
<label>Password:</label>
{{with .FormErrors.password}}
<label class='error'>({{.}})</label>
{{end}}
<input type='password' name='password'>
</div>
<div class="field-container submit-container">
<input type='submit' value='Login'>
</div>
</form>
</div>
{{end}}

View File

@ -1,10 +1,19 @@
{{define "tweet"}}
<div class="tweet">
{{$main_tweet := (tweet .)}}
{{$author := (user $main_tweet.UserID)}}
{{$main_tweet := (tweet .)}}
{{$author := (user $main_tweet.UserID)}}
<div class="tweet"
{{if (not (eq $main_tweet.ID (focused_tweet_id)))}}
hx-post="/tweet/{{$main_tweet.ID}}"
hx-trigger="click target::not(.tweet-text)"
hx-target="body"
hx-swap="outerHTML"
hx-push-url="true"
{{end}}
>
<div class="tweet-header-container">
{{template "author-info" $author}}
<div class="author-info-container" hx-trigger="click consume">
{{template "author-info" $author}}
</div>
{{if $main_tweet.ReplyMentions}}
<div class="reply-mentions-container">
<span class="replying-to-label">Replying to</span>
@ -30,9 +39,9 @@
</span>
<span class="vertical-container-1">
<div class="tweet-content">
<a href="/tweet/{{$main_tweet.ID}}" class="unstyled-link tweet-text">
<p class="tweet-text">
{{$main_tweet.Text}}
</a>
</p>
{{range $main_tweet.Images}}
<img src="/content/images/{{.LocalFilename}}" style="max-width: 45%"/>

View File

@ -5,11 +5,14 @@ import (
"fmt"
"os"
"path"
"path/filepath"
sql "github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx/reflectx"
_ "github.com/mattn/go-sqlite3"
"gopkg.in/yaml.v2"
"gitlab.com/offline-twitter/twitter_offline_engine/pkg/scraper"
)
//go:embed schema.sql
@ -152,3 +155,15 @@ func LoadProfile(profile_dir string) (Profile, error) {
return ret, err
}
func (p Profile) ListSessions() []scraper.UserHandle {
result, err := filepath.Glob(path.Join(p.ProfileDir, "*.session"))
if err != nil {
panic(err)
}
ret := []scraper.UserHandle{}
for _, filename := range result {
ret = append(ret, scraper.UserHandle(path.Base(filename[:len(filename)-len(".session")])))
}
return ret
}