diff --git a/go.mod b/go.mod index 227fecc..4ce412e 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index be6a2bc..ff7c424 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/webserver/response_helpers.go b/internal/webserver/response_helpers.go index 5193e22..dc4329e 100644 --- a/internal/webserver/response_helpers.go +++ b/internal/webserver/response_helpers.go @@ -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 +} diff --git a/internal/webserver/server.go b/internal/webserver/server.go index fe9e388..ccab644 100644 --- a/internal/webserver/server.go +++ b/internal/webserver/server.go @@ -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) } diff --git a/internal/webserver/server_test.go b/internal/webserver/server_test.go index 7279899..38618c9 100644 --- a/internal/webserver/server_test.go +++ b/internal/webserver/server_test.go @@ -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() } diff --git a/internal/webserver/static/icons/dotdotdot.svg b/internal/webserver/static/icons/dotdotdot.svg new file mode 100644 index 0000000..59580f2 --- /dev/null +++ b/internal/webserver/static/icons/dotdotdot.svg @@ -0,0 +1 @@ + diff --git a/internal/webserver/static/styles.css b/internal/webserver/static/styles.css index 1e58919..6feb94c 100644 --- a/internal/webserver/static/styles.css +++ b/internal/webserver/static/styles.css @@ -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; } diff --git a/internal/webserver/tpl/includes/author_info.tpl b/internal/webserver/tpl/includes/author_info.tpl index fd24f06..9c9b46a 100644 --- a/internal/webserver/tpl/includes/author_info.tpl +++ b/internal/webserver/tpl/includes/author_info.tpl @@ -1,7 +1,10 @@ {{define "author-info"}} -