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:
parent
4a40f35ff6
commit
02bc365add
2
go.mod
2
go.mod
@ -4,12 +4,14 @@ go 1.16
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/andybalholm/cascadia v1.3.2
|
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/go-test/deep v1.0.7
|
||||||
github.com/jarcoal/httpmock v1.1.0
|
github.com/jarcoal/httpmock v1.1.0
|
||||||
github.com/jmoiron/sqlx v1.3.4
|
github.com/jmoiron/sqlx v1.3.4
|
||||||
github.com/mattn/go-sqlite3 v1.14.7
|
github.com/mattn/go-sqlite3 v1.14.7
|
||||||
github.com/sirupsen/logrus v1.8.1
|
github.com/sirupsen/logrus v1.8.1
|
||||||
github.com/stretchr/testify v1.7.0
|
github.com/stretchr/testify v1.7.0
|
||||||
|
golang.org/x/net v0.9.0
|
||||||
golang.org/x/term v0.7.0
|
golang.org/x/term v0.7.0
|
||||||
gopkg.in/yaml.v2 v2.4.0
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
)
|
)
|
||||||
|
4
go.sum
4
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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
|
||||||
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
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=
|
github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
|
||||||
|
@ -2,12 +2,12 @@ package webserver
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"runtime/debug"
|
|
||||||
|
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime/debug"
|
||||||
|
|
||||||
"gitlab.com/offline-twitter/twitter_offline_engine/pkg/scraper"
|
"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 {
|
type TweetCollection interface {
|
||||||
Tweet(id scraper.TweetID) scraper.Tweet
|
Tweet(id scraper.TweetID) scraper.Tweet
|
||||||
User(id scraper.UserID) scraper.User
|
User(id scraper.UserID) scraper.User
|
||||||
|
FocusedTweetID() scraper.TweetID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Creates a template from the given template file using all the available partials.
|
// Creates a template from the given template file using all the available partials.
|
||||||
// Calls `app.buffered_render` to render the created template.
|
// 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"))
|
partials, err := filepath.Glob(get_filepath("tpl/includes/*.tpl"))
|
||||||
panic_if(err)
|
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(
|
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))...)
|
).ParseFiles(append(partials, get_filepath(tpl_file))...)
|
||||||
panic_if(err)
|
panic_if(err)
|
||||||
|
|
||||||
app.buffered_render(w, tpl, data)
|
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
|
||||||
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package webserver
|
package webserver
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@ -15,6 +16,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-playground/form/v4"
|
||||||
|
|
||||||
"gitlab.com/offline-twitter/twitter_offline_engine/pkg/persistence"
|
"gitlab.com/offline-twitter/twitter_offline_engine/pkg/persistence"
|
||||||
"gitlab.com/offline-twitter/twitter_offline_engine/pkg/scraper"
|
"gitlab.com/offline-twitter/twitter_offline_engine/pkg/scraper"
|
||||||
)
|
)
|
||||||
@ -30,6 +33,8 @@ type Application struct {
|
|||||||
Middlewares []Middleware
|
Middlewares []Middleware
|
||||||
|
|
||||||
Profile persistence.Profile
|
Profile persistence.Profile
|
||||||
|
ActiveUser scraper.User
|
||||||
|
DisableScraping bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewApp(profile persistence.Profile) Application {
|
func NewApp(profile persistence.Profile) Application {
|
||||||
@ -40,7 +45,7 @@ func NewApp(profile persistence.Profile) Application {
|
|||||||
ErrorLog: log.New(os.Stderr, "ERROR\t", log.Ldate|log.Ltime|log.Lshortfile),
|
ErrorLog: log.New(os.Stderr, "ERROR\t", log.Ldate|log.Ltime|log.Lshortfile),
|
||||||
|
|
||||||
Profile: profile,
|
Profile: profile,
|
||||||
// formDecoder: form.NewDecoder(),
|
ActiveUser: get_default_user(),
|
||||||
}
|
}
|
||||||
ret.Middlewares = []Middleware{
|
ret.Middlewares = []Middleware{
|
||||||
secureHeaders,
|
secureHeaders,
|
||||||
@ -85,6 +90,10 @@ func (app *Application) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
app.TweetDetail(w, r)
|
app.TweetDetail(w, r)
|
||||||
case "content":
|
case "content":
|
||||||
http.StripPrefix("/content", http.FileServer(http.Dir(app.Profile.ProfileDir))).ServeHTTP(w, r)
|
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:
|
default:
|
||||||
app.UserFeed(w, r)
|
app.UserFeed(w, r)
|
||||||
}
|
}
|
||||||
@ -110,13 +119,7 @@ func (app *Application) Run(address string) {
|
|||||||
|
|
||||||
func (app *Application) Home(w http.ResponseWriter, r *http.Request) {
|
func (app *Application) Home(w http.ResponseWriter, r *http.Request) {
|
||||||
app.traceLog.Printf("'Home' handler (path: %q)", r.URL.Path)
|
app.traceLog.Printf("'Home' handler (path: %q)", r.URL.Path)
|
||||||
tpl, err := template.ParseFiles(
|
app.buffered_render_basic_page(w, "tpl/home.tpl", nil)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type TweetDetailData struct {
|
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 {
|
func (t TweetDetailData) User(id scraper.UserID) scraper.User {
|
||||||
return t.Users[id]
|
return t.Users[id]
|
||||||
}
|
}
|
||||||
|
func (t TweetDetailData) FocusedTweetID() scraper.TweetID {
|
||||||
|
return t.MainTweetID
|
||||||
|
}
|
||||||
|
|
||||||
func to_json(t interface{}) string {
|
func to_json(t interface{}) string {
|
||||||
js, err := json.Marshal(t)
|
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) {
|
func (app *Application) TweetDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
app.traceLog.Printf("'TweetDetail' handler (path: %q)", r.URL.Path)
|
app.traceLog.Printf("'TweetDetail' handler (path: %q)", r.URL.Path)
|
||||||
_, tail := path.Split(r.URL.Path)
|
_, tail := path.Split(r.URL.Path)
|
||||||
tweet_id, err := strconv.Atoi(tail)
|
val, err := strconv.Atoi(tail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
app.error_400_with_message(w, fmt.Sprintf("Invalid tweet ID: %q", tail))
|
app.error_400_with_message(w, fmt.Sprintf("Invalid tweet ID: %q", tail))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
tweet_id := scraper.TweetID(val)
|
||||||
|
|
||||||
data := NewTweetDetailData()
|
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)
|
trove, err := app.Profile.GetTweetDetail(data.MainTweetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -168,7 +203,7 @@ func (app *Application) TweetDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
app.InfoLog.Printf(to_json(trove))
|
app.InfoLog.Printf(to_json(trove))
|
||||||
data.TweetDetailView = 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 {
|
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 {
|
func (t UserProfileData) User(id scraper.UserID) scraper.User {
|
||||||
return t.Users[id]
|
return t.Users[id]
|
||||||
}
|
}
|
||||||
|
func (t UserProfileData) FocusedTweetID() scraper.TweetID {
|
||||||
|
return scraper.TweetID(0)
|
||||||
|
}
|
||||||
|
|
||||||
func (app *Application) UserFeed(w http.ResponseWriter, r *http.Request) {
|
func (app *Application) UserFeed(w http.ResponseWriter, r *http.Request) {
|
||||||
app.traceLog.Printf("'UserFeed' handler (path: %q)", r.URL.Path)
|
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))
|
feed, err := app.Profile.GetUserFeed(user.ID, 50, scraper.TimestampFromUnix(0))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, persistence.ErrEndOfFeed) {
|
||||||
|
// TODO
|
||||||
|
} else {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
feed.Users[user.ID] = user
|
||||||
|
|
||||||
data := UserProfileData{Feed: feed, UserID: user.ID}
|
data := UserProfileData{Feed: feed, UserID: user.ID}
|
||||||
app.InfoLog.Printf(to_json(data))
|
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)
|
||||||
}
|
}
|
||||||
|
@ -44,6 +44,7 @@ func selector(s string) cascadia.Sel {
|
|||||||
func do_request(req *http.Request) *http.Response {
|
func do_request(req *http.Request) *http.Response {
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
app := webserver.NewApp(profile)
|
app := webserver.NewApp(profile)
|
||||||
|
app.DisableScraping = true
|
||||||
app.ServeHTTP(recorder, req)
|
app.ServeHTTP(recorder, req)
|
||||||
return recorder.Result()
|
return recorder.Result()
|
||||||
}
|
}
|
||||||
|
1
internal/webserver/static/icons/dotdotdot.svg
Normal file
1
internal/webserver/static/icons/dotdotdot.svg
Normal 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 |
@ -40,10 +40,20 @@ body {
|
|||||||
main {
|
main {
|
||||||
padding-top: 4em;
|
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 {
|
.tweet {
|
||||||
padding: 0 1.5em;
|
padding: 0 1.5em;
|
||||||
}
|
}
|
||||||
|
:not(.focused-tweet) > .tweet {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.quoted-tweet {
|
.quoted-tweet {
|
||||||
padding: 1.3em;
|
padding: 1.3em;
|
||||||
@ -69,6 +79,10 @@ main {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.author-info, .tweet-text {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.name-and-handle {
|
.name-and-handle {
|
||||||
padding: 0 0.5em;
|
padding: 0 0.5em;
|
||||||
}
|
}
|
||||||
@ -113,6 +127,7 @@ a.mention {
|
|||||||
.tweet-text {
|
.tweet-text {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 0.4em;
|
margin-bottom: 0.4em;
|
||||||
|
margin-top: 0;
|
||||||
/* padding-bottom: 0.5em;*/
|
/* padding-bottom: 0.5em;*/
|
||||||
}
|
}
|
||||||
.focused-tweet .tweet-text {
|
.focused-tweet .tweet-text {
|
||||||
@ -227,18 +242,19 @@ ul.quick-links {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
padding: 0 2em;
|
padding: 0 2em;
|
||||||
}
|
}
|
||||||
li.quick-link {
|
.quick-link {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.5em;
|
padding: 0.5em;
|
||||||
margin: 0.2em;
|
margin: 0.2em;
|
||||||
border-radius: 100em; /* any large amount, just don't use % because then it makes an ellipse */
|
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);
|
background-color: var(--color-twitter-blue-light);
|
||||||
}
|
}
|
||||||
li.quick-link:active {
|
.quick-link:active {
|
||||||
transform: translate(2px, 2px);
|
transform: translate(2px, 2px);
|
||||||
background-color: var(--color-twitter-blue);
|
background-color: var(--color-twitter-blue);
|
||||||
}
|
}
|
||||||
@ -310,7 +326,55 @@ svg {
|
|||||||
}
|
}
|
||||||
.search-bar {
|
.search-bar {
|
||||||
flex-grow: 1;
|
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;
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
{{define "author-info"}}
|
{{define "author-info"}}
|
||||||
<div class="author-info">
|
<div class="author-info" hx-boost="true">
|
||||||
<a class="unstyled-link" href="/{{.Handle}}">
|
<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>
|
</a>
|
||||||
<span class="name-and-handle">
|
<span class="name-and-handle">
|
||||||
<div class="display-name">{{.DisplayName}}</div>
|
<div class="display-name">{{.DisplayName}}</div>
|
||||||
|
@ -7,6 +7,7 @@
|
|||||||
<link rel='stylesheet' href='/static/styles.css'>
|
<link rel='stylesheet' href='/static/styles.css'>
|
||||||
<link rel='shortcut icon' href='/static/img/favicon.ico' type='image/x-icon'>
|
<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'>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="top-bar">
|
<div class="top-bar">
|
||||||
|
@ -1,5 +1,11 @@
|
|||||||
{{define "nav-sidebar"}}
|
{{define "nav-sidebar"}}
|
||||||
<div class="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">
|
<ul class="quick-links">
|
||||||
<a class="unstyled-link" href="#">
|
<a class="unstyled-link" href="#">
|
||||||
<li class="quick-link">
|
<li class="quick-link">
|
||||||
@ -49,7 +55,7 @@
|
|||||||
<span>Verified</span>
|
<span>Verified</span>
|
||||||
</li>
|
</li>
|
||||||
</a>
|
</a>
|
||||||
<a class="unstyled-link" href="#">
|
<a class="unstyled-link" href="/{{(active_user).Handle}}">
|
||||||
<li class="quick-link">
|
<li class="quick-link">
|
||||||
<img class="svg-icon" src="/static/icons/profile.svg" />
|
<img class="svg-icon" src="/static/icons/profile.svg" />
|
||||||
<span>Profile</span>
|
<span>Profile</span>
|
||||||
|
40
internal/webserver/tpl/login.tpl
Normal file
40
internal/webserver/tpl/login.tpl
Normal 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}}
|
@ -1,10 +1,19 @@
|
|||||||
{{define "tweet"}}
|
{{define "tweet"}}
|
||||||
<div class="tweet">
|
{{$main_tweet := (tweet .)}}
|
||||||
{{$main_tweet := (tweet .)}}
|
{{$author := (user $main_tweet.UserID)}}
|
||||||
{{$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">
|
<div class="tweet-header-container">
|
||||||
|
<div class="author-info-container" hx-trigger="click consume">
|
||||||
{{template "author-info" $author}}
|
{{template "author-info" $author}}
|
||||||
|
</div>
|
||||||
{{if $main_tweet.ReplyMentions}}
|
{{if $main_tweet.ReplyMentions}}
|
||||||
<div class="reply-mentions-container">
|
<div class="reply-mentions-container">
|
||||||
<span class="replying-to-label">Replying to</span>
|
<span class="replying-to-label">Replying to</span>
|
||||||
@ -30,9 +39,9 @@
|
|||||||
</span>
|
</span>
|
||||||
<span class="vertical-container-1">
|
<span class="vertical-container-1">
|
||||||
<div class="tweet-content">
|
<div class="tweet-content">
|
||||||
<a href="/tweet/{{$main_tweet.ID}}" class="unstyled-link tweet-text">
|
<p class="tweet-text">
|
||||||
{{$main_tweet.Text}}
|
{{$main_tweet.Text}}
|
||||||
</a>
|
</p>
|
||||||
|
|
||||||
{{range $main_tweet.Images}}
|
{{range $main_tweet.Images}}
|
||||||
<img src="/content/images/{{.LocalFilename}}" style="max-width: 45%"/>
|
<img src="/content/images/{{.LocalFilename}}" style="max-width: 45%"/>
|
@ -5,11 +5,14 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
sql "github.com/jmoiron/sqlx"
|
sql "github.com/jmoiron/sqlx"
|
||||||
"github.com/jmoiron/sqlx/reflectx"
|
"github.com/jmoiron/sqlx/reflectx"
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
"gopkg.in/yaml.v2"
|
"gopkg.in/yaml.v2"
|
||||||
|
|
||||||
|
"gitlab.com/offline-twitter/twitter_offline_engine/pkg/scraper"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed schema.sql
|
//go:embed schema.sql
|
||||||
@ -152,3 +155,15 @@ func LoadProfile(profile_dir string) (Profile, error) {
|
|||||||
|
|
||||||
return ret, err
|
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
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user