Signed and Authenticated Static Photo URLs
* Add support for authenticated static photo URLs, leveraging the NGINX module ngx_http_auth_request. The README is updated with an example NGINX config how to set this up on the proxy side. * In settings.json a new SignedPhoto section is added: not enabled by default. * PhotoURL will append a ?jwt= token to the /static/photos/ path for the current user, which expires after 30 seconds. * When SignedPhoto is enabled, it will enforce that the JWT token is valid and matches the username of the current logged-in user, or else will return with a 403 Forbidden error.
This commit is contained in:
parent
295183559d
commit
7869ff83ba
104
README.md
104
README.md
|
@ -20,20 +20,6 @@ The website can also run out of a local SQLite database which is convenient
|
||||||
for local development. The production server runs on PostgreSQL and the
|
for local development. The production server runs on PostgreSQL and the
|
||||||
web app is primarily designed for that.
|
web app is primarily designed for that.
|
||||||
|
|
||||||
### PostGIS Extension for PostgreSQL
|
|
||||||
|
|
||||||
For the "Who's Nearby" feature to work you will need a PostgreSQL
|
|
||||||
database with the PostGIS geospatial extension installed. Usually
|
|
||||||
it might be a matter of `dnf install postgis` and activating the
|
|
||||||
extension on your nonshy database as your superuser (postgres):
|
|
||||||
|
|
||||||
```psql
|
|
||||||
create extension postgis;
|
|
||||||
```
|
|
||||||
|
|
||||||
If you get errors like "Type geography not found" from Postgres when
|
|
||||||
running distance based searches, this is the likely culprit.
|
|
||||||
|
|
||||||
## Building the App
|
## Building the App
|
||||||
|
|
||||||
This app is written in Go: [go.dev](https://go.dev). You can probably
|
This app is written in Go: [go.dev](https://go.dev). You can probably
|
||||||
|
@ -61,6 +47,96 @@ a database.
|
||||||
For simple local development, just set `"UseSQLite": true` and the
|
For simple local development, just set `"UseSQLite": true` and the
|
||||||
app will run with a SQLite database.
|
app will run with a SQLite database.
|
||||||
|
|
||||||
|
### Postgres is Highly Recommended
|
||||||
|
|
||||||
|
This website is intended to run under PostgreSQL and some of its
|
||||||
|
features leverage Postgres specific extensions. For quick local
|
||||||
|
development, SQLite will work fine but some website features will
|
||||||
|
be disabled and error messages given. These include:
|
||||||
|
|
||||||
|
* Location features such as "Who's Nearby" (PostGIS extension)
|
||||||
|
* "Newest" tab on the forums: to deduplicate comments by most recent
|
||||||
|
thread depends on Postgres, SQLite will always show all latest
|
||||||
|
comments without deduplication.
|
||||||
|
|
||||||
|
### PostGIS Extension for PostgreSQL
|
||||||
|
|
||||||
|
For the "Who's Nearby" feature to work you will need a PostgreSQL
|
||||||
|
database with the PostGIS geospatial extension installed. Usually
|
||||||
|
it might be a matter of `dnf install postgis` and activating the
|
||||||
|
extension on your nonshy database as your superuser (postgres):
|
||||||
|
|
||||||
|
```psql
|
||||||
|
create extension postgis;
|
||||||
|
```
|
||||||
|
|
||||||
|
If you get errors like "Type geography not found" from Postgres when
|
||||||
|
running distance based searches, this is the likely culprit.
|
||||||
|
|
||||||
|
### Signed Photo URLs (NGINX)
|
||||||
|
|
||||||
|
The website supports "signed photo" URLs that can help protect the direct
|
||||||
|
links to user photos (their /static/photos/*.jpg paths) to ensure only
|
||||||
|
logged-in and authorized users are able to access those links.
|
||||||
|
|
||||||
|
This feature is not enabled (enforcing) by default, as it relies on
|
||||||
|
cooperation with the NGINX reverse proxy server
|
||||||
|
(module ngx_http_auth_request).
|
||||||
|
|
||||||
|
In your NGINX config, set your /static/ path to leverage NGINX auth_request
|
||||||
|
like so:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
# your boilerplate server info (SSL, etc.) - not relevant to this example.
|
||||||
|
listen 80 default_server;
|
||||||
|
listen [::]:80 default_server;
|
||||||
|
|
||||||
|
# Relevant: setting the /static/ URL on NGINX to be an alias to your local
|
||||||
|
# nonshy static folder on disk. In this example, the git clone for the
|
||||||
|
# website was at /home/www-user/git/nonshy/website, so that ./web/static/
|
||||||
|
# is the local path where static files (e.g., photos) are uploaded.
|
||||||
|
location /static/ {
|
||||||
|
# Important: auth_request tells NGINX to do subrequest authentication
|
||||||
|
# on requests into the /static/ URI of your website.
|
||||||
|
auth_request /static-auth;
|
||||||
|
|
||||||
|
# standard NGINX alias commands.
|
||||||
|
alias /home/www-user/git/nonshy/website/web/static/;
|
||||||
|
autoindex off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Configure the internal subrequest auth path.
|
||||||
|
# Note: the path "/static-auth" can be anything you want.
|
||||||
|
location = /static-auth {
|
||||||
|
internal; # this is an internal route for NGINX only, not public
|
||||||
|
|
||||||
|
# Proxy to the /v1/auth/static URL on the web app.
|
||||||
|
# This line assumes the website runs on localhost:8080.
|
||||||
|
proxy_pass http://localhost:8080/v1/auth/static;
|
||||||
|
proxy_pass_request_body off;
|
||||||
|
proxy_set_header Content-Length "";
|
||||||
|
|
||||||
|
# Important: the X-Original-URI header tells the web app what the
|
||||||
|
# original path (e.g. /static/photos/*) was, so the web app knows
|
||||||
|
# which sub-URL to enforce authentication on.
|
||||||
|
proxy_set_header X-Original-URI $request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When your NGINX config is set up like the above, you can edit the
|
||||||
|
settings.json to mark SignedPhoto/Enabled=true, and restart the
|
||||||
|
website. Be sure to test it!
|
||||||
|
|
||||||
|
On a photo gallery view, all image URLs under /static/photos/ should
|
||||||
|
come with a ?jwt= parameter, and the image should load for the current
|
||||||
|
user. The JWT token is valid for 30 seconds after which the direct link
|
||||||
|
to the image should expire and give a 403 Forbidden response.
|
||||||
|
|
||||||
|
When this feature is NOT enabled/not enforcing: the jwt= parameter is
|
||||||
|
still generated on photo URLs but is not enforced by the web app.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
The `nonshy` binary has sub-commands to either run the web server
|
The `nonshy` binary has sub-commands to either run the web server
|
||||||
|
|
|
@ -38,6 +38,9 @@ const (
|
||||||
|
|
||||||
TwoFactorBackupCodeCount = 12
|
TwoFactorBackupCodeCount = 12
|
||||||
TwoFactorBackupCodeLength = 8 // characters a-z0-9
|
TwoFactorBackupCodeLength = 8 // characters a-z0-9
|
||||||
|
|
||||||
|
// Signed URLs for static photo authentication.
|
||||||
|
SignedPhotoJWTExpires = 30 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
// Authentication
|
// Authentication
|
||||||
|
|
|
@ -15,7 +15,7 @@ import (
|
||||||
|
|
||||||
// Version of the config format - when new fields are added, it will attempt
|
// Version of the config format - when new fields are added, it will attempt
|
||||||
// to write the settings.toml to disk so new defaults populate.
|
// to write the settings.toml to disk so new defaults populate.
|
||||||
var currentVersion = 4
|
var currentVersion = 5
|
||||||
|
|
||||||
// Current loaded settings.json
|
// Current loaded settings.json
|
||||||
var Current = DefaultVariable()
|
var Current = DefaultVariable()
|
||||||
|
@ -32,6 +32,7 @@ type Variable struct {
|
||||||
BareRTC BareRTC
|
BareRTC BareRTC
|
||||||
Maintenance Maintenance
|
Maintenance Maintenance
|
||||||
Encryption Encryption
|
Encryption Encryption
|
||||||
|
SignedPhoto SignedPhoto
|
||||||
WebPush WebPush
|
WebPush WebPush
|
||||||
Turnstile Turnstile
|
Turnstile Turnstile
|
||||||
UseXForwardedFor bool
|
UseXForwardedFor bool
|
||||||
|
@ -126,6 +127,12 @@ func LoadSettings() {
|
||||||
writeSettings = true
|
writeSettings = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize JWT token for SignedPhoto feature.
|
||||||
|
if Current.SignedPhoto.JWTSecret == "" {
|
||||||
|
Current.SignedPhoto.JWTSecret = uuid.New().String()
|
||||||
|
writeSettings = true
|
||||||
|
}
|
||||||
|
|
||||||
// Have we added new config fields? Save the settings.json.
|
// Have we added new config fields? Save the settings.json.
|
||||||
if Current.Version != currentVersion || writeSettings {
|
if Current.Version != currentVersion || writeSettings {
|
||||||
log.Warn("New options are available for your settings.json file. Your settings will be re-saved now.")
|
log.Warn("New options are available for your settings.json file. Your settings will be re-saved now.")
|
||||||
|
@ -196,6 +203,12 @@ type Encryption struct {
|
||||||
ColdStorageRSAPublicKey []byte
|
ColdStorageRSAPublicKey []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SignedPhoto settings.
|
||||||
|
type SignedPhoto struct {
|
||||||
|
Enabled bool
|
||||||
|
JWTSecret string
|
||||||
|
}
|
||||||
|
|
||||||
// WebPush settings.
|
// WebPush settings.
|
||||||
type WebPush struct {
|
type WebPush struct {
|
||||||
VAPIDPublicKey string
|
VAPIDPublicKey string
|
||||||
|
|
84
pkg/controller/api/static_auth.go
Normal file
84
pkg/controller/api/static_auth.go
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/encryption"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/session"
|
||||||
|
"github.com/golang-jwt/jwt/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StaticAuth API protects paths like /static/photos/ to authenticated user requests only.
|
||||||
|
func StaticAuth() http.HandlerFunc {
|
||||||
|
type Response struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Error string `json:",omitempty"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// We only protect the /static/photos subpath.
|
||||||
|
// And check if the SignedPhoto feature is enabled and enforcing.
|
||||||
|
var originalURI = r.Header.Get("X-Original-URI")
|
||||||
|
if !config.Current.SignedPhoto.Enabled || !strings.HasPrefix(originalURI, config.PhotoWebPath) {
|
||||||
|
SendJSON(w, http.StatusOK, Response{
|
||||||
|
Success: true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the JWT token parameter from the original URL.
|
||||||
|
var token string
|
||||||
|
if path, err := url.Parse(originalURI); err == nil {
|
||||||
|
query := path.Query()
|
||||||
|
token = query.Get("jwt")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The token is required from here.
|
||||||
|
if token == "" {
|
||||||
|
SendJSON(w, http.StatusForbidden, Response{
|
||||||
|
Error: "JWT token is required",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're logged in.
|
||||||
|
currentUser, err := session.CurrentUser(r)
|
||||||
|
if err != nil {
|
||||||
|
SendJSON(w, http.StatusForbidden, Response{
|
||||||
|
Error: "Login Required",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the JWT token.
|
||||||
|
claims, ok, err := encryption.ValidateClaims(
|
||||||
|
token,
|
||||||
|
[]byte(config.Current.SignedPhoto.JWTSecret),
|
||||||
|
&jwt.RegisteredClaims{},
|
||||||
|
)
|
||||||
|
if !ok || err != nil {
|
||||||
|
SendJSON(w, http.StatusForbidden, Response{
|
||||||
|
Error: fmt.Sprintf("JWT claims: %v", err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanity check that the username in these claims is the same as the viewer.
|
||||||
|
if c, ok := claims.(*jwt.RegisteredClaims); !ok || c.Subject != currentUser.Username {
|
||||||
|
SendJSON(w, http.StatusForbidden, Response{
|
||||||
|
Error: "That photo was not for you",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SendJSON(w, http.StatusOK, Response{
|
||||||
|
Success: true,
|
||||||
|
Username: currentUser.Username,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
|
@ -3,7 +3,6 @@ package chat
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
|
@ -11,6 +10,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/encryption"
|
||||||
"code.nonshy.com/nonshy/website/pkg/geoip"
|
"code.nonshy.com/nonshy/website/pkg/geoip"
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
"code.nonshy.com/nonshy/website/pkg/middleware"
|
"code.nonshy.com/nonshy/website/pkg/middleware"
|
||||||
|
@ -22,7 +22,7 @@ import (
|
||||||
"github.com/golang-jwt/jwt/v4"
|
"github.com/golang-jwt/jwt/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
// JWT claims.
|
// Claims are the JWT claims for the BareRTC chat room.
|
||||||
type Claims struct {
|
type Claims struct {
|
||||||
// Custom claims.
|
// Custom claims.
|
||||||
IsAdmin bool `json:"op,omitempty"`
|
IsAdmin bool `json:"op,omitempty"`
|
||||||
|
@ -123,25 +123,17 @@ func Landing() http.HandlerFunc {
|
||||||
|
|
||||||
// Create the JWT claims.
|
// Create the JWT claims.
|
||||||
claims := Claims{
|
claims := Claims{
|
||||||
IsAdmin: currentUser.HasAdminScope(config.ScopeChatModerator),
|
IsAdmin: currentUser.HasAdminScope(config.ScopeChatModerator),
|
||||||
Avatar: avatar,
|
Avatar: avatar,
|
||||||
ProfileURL: "/u/" + currentUser.Username,
|
ProfileURL: "/u/" + currentUser.Username,
|
||||||
Nickname: currentUser.NameOrUsername(),
|
Nickname: currentUser.NameOrUsername(),
|
||||||
Emoji: emoji,
|
Emoji: emoji,
|
||||||
Gender: Gender(currentUser),
|
Gender: Gender(currentUser),
|
||||||
VIP: isShy, // "shy accounts" use the "VIP" status for special icon in chat
|
VIP: isShy, // "shy accounts" use the "VIP" status for special icon in chat
|
||||||
Rules: rules,
|
Rules: rules,
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: encryption.StandardClaims(currentUser.ID, currentUser.Username, 5*time.Minute),
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)),
|
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
||||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
|
||||||
Issuer: config.Title,
|
|
||||||
Subject: currentUser.Username,
|
|
||||||
ID: fmt.Sprintf("%d", currentUser.ID),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
token, err := encryption.SignClaims(claims, []byte(config.Current.BareRTC.JWTSecret))
|
||||||
ss, err := token.SignedString(secret)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
session.FlashError(w, r, "Couldn't sign you into the chat: %s", err)
|
session.FlashError(w, r, "Couldn't sign you into the chat: %s", err)
|
||||||
templates.Redirect(w, r.URL.Path)
|
templates.Redirect(w, r.URL.Path)
|
||||||
|
@ -165,7 +157,7 @@ func Landing() http.HandlerFunc {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Redirect them to the chat room.
|
// Redirect them to the chat room.
|
||||||
templates.Redirect(w, strings.TrimSuffix(chatURL, "/")+"/?jwt="+ss)
|
templates.Redirect(w, strings.TrimSuffix(chatURL, "/")+"/?jwt="+token)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
71
pkg/encryption/jwt.go
Normal file
71
pkg/encryption/jwt.go
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
package encryption
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
|
"github.com/golang-jwt/jwt/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StandardClaims returns a standard JWT claim for a username.
|
||||||
|
//
|
||||||
|
// It will include values for Subject (username), Issuer (site title), ExpiresAt, IssuedAt, NotBefore.
|
||||||
|
//
|
||||||
|
// If the userID is >0, the ID field is included.
|
||||||
|
func StandardClaims(userID uint64, username string, expires time.Duration) jwt.RegisteredClaims {
|
||||||
|
claim := jwt.RegisteredClaims{
|
||||||
|
Subject: username,
|
||||||
|
Issuer: config.Title,
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expires)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||||
|
}
|
||||||
|
if userID > 0 {
|
||||||
|
claim.ID = fmt.Sprintf("%d", userID)
|
||||||
|
}
|
||||||
|
return claim
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignClaims creates and returns a signed JWT token.
|
||||||
|
func SignClaims(claims jwt.Claims, secret []byte) (string, error) {
|
||||||
|
// Get our Chat JWT secret.
|
||||||
|
if len(secret) == 0 {
|
||||||
|
return "", errors.New("JWT secret key is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
ss, err := token.SignedString(secret)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return ss, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateClaims checks a JWT token is signed by the site key and returns the claims.
|
||||||
|
func ValidateClaims(tokenStr string, secret []byte, v jwt.Claims) (jwt.Claims, bool, error) {
|
||||||
|
// Handle a JWT authentication token.
|
||||||
|
var (
|
||||||
|
claims jwt.Claims
|
||||||
|
authOK bool
|
||||||
|
)
|
||||||
|
if tokenStr != "" {
|
||||||
|
token, err := jwt.ParseWithClaims(tokenStr, v, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return secret, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !token.Valid {
|
||||||
|
return nil, false, errors.New("token was not valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
claims = token.Claims
|
||||||
|
authOK = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, authOK, nil
|
||||||
|
}
|
|
@ -112,6 +112,7 @@ func New() http.Handler {
|
||||||
|
|
||||||
// JSON API endpoints.
|
// JSON API endpoints.
|
||||||
mux.HandleFunc("GET /v1/version", api.Version())
|
mux.HandleFunc("GET /v1/version", api.Version())
|
||||||
|
mux.HandleFunc("GET /v1/auth/static", api.StaticAuth())
|
||||||
mux.HandleFunc("GET /v1/users/me", api.LoginOK())
|
mux.HandleFunc("GET /v1/users/me", api.LoginOK())
|
||||||
mux.HandleFunc("POST /v1/users/check-username", api.UsernameCheck())
|
mux.HandleFunc("POST /v1/users/check-username", api.UsernameCheck())
|
||||||
mux.HandleFunc("GET /v1/web-push/vapid-public-key", webpush.VAPIDPublicKey)
|
mux.HandleFunc("GET /v1/web-push/vapid-public-key", webpush.VAPIDPublicKey)
|
||||||
|
|
|
@ -12,6 +12,8 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/encryption"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
"code.nonshy.com/nonshy/website/pkg/markdown"
|
"code.nonshy.com/nonshy/website/pkg/markdown"
|
||||||
"code.nonshy.com/nonshy/website/pkg/models"
|
"code.nonshy.com/nonshy/website/pkg/models"
|
||||||
"code.nonshy.com/nonshy/website/pkg/photo"
|
"code.nonshy.com/nonshy/website/pkg/photo"
|
||||||
|
@ -38,7 +40,7 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
|
||||||
"ToMarkdown": ToMarkdown,
|
"ToMarkdown": ToMarkdown,
|
||||||
"ToJSON": ToJSON,
|
"ToJSON": ToJSON,
|
||||||
"ToHTML": ToHTML,
|
"ToHTML": ToHTML,
|
||||||
"PhotoURL": photo.URLPath,
|
"PhotoURL": PhotoURL(r),
|
||||||
"Now": time.Now,
|
"Now": time.Now,
|
||||||
"RunTime": RunTime,
|
"RunTime": RunTime,
|
||||||
"PrettyTitle": func() template.HTML {
|
"PrettyTitle": func() template.HTML {
|
||||||
|
@ -98,6 +100,28 @@ func RunTime(r *http.Request) string {
|
||||||
return "ERROR"
|
return "ERROR"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PhotoURL returns a URL path to photos.
|
||||||
|
func PhotoURL(r *http.Request) func(filename string) string {
|
||||||
|
return func(filename string) string {
|
||||||
|
// Get the current user to sign a JWT token.
|
||||||
|
var token string
|
||||||
|
if currentUser, err := session.CurrentUser(r); err == nil {
|
||||||
|
claims := encryption.StandardClaims(currentUser.ID, currentUser.Username, config.SignedPhotoJWTExpires)
|
||||||
|
token, err = encryption.SignClaims(claims, []byte(config.Current.SignedPhoto.JWTSecret))
|
||||||
|
if err != nil {
|
||||||
|
log.Error("PhotoURL: SignClaims: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWT query string to append?
|
||||||
|
if token != "" {
|
||||||
|
token = "?jwt=" + token
|
||||||
|
}
|
||||||
|
|
||||||
|
return photo.URLPath(filename) + token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// BlurExplicit returns true if the current user has the blur_explicit setting on and the given Photo is Explicit.
|
// BlurExplicit returns true if the current user has the blur_explicit setting on and the given Photo is Explicit.
|
||||||
func BlurExplicit(r *http.Request) func(*models.Photo) bool {
|
func BlurExplicit(r *http.Request) func(*models.Photo) bool {
|
||||||
return func(photo *models.Photo) bool {
|
return func(photo *models.Photo) bool {
|
||||||
|
|
Loading…
Reference in New Issue
Block a user