backart.go
package main
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"unicode/utf8"
"wisal/backend/db"
)
func create(w http.ResponseWriter, r *http.Request) {
currentuser, ok := currentuser(r)
if !ok {
return
}
files, ok := readbody(w, r, MaxRequestSize)
if !ok || len(files) > MaxMediaFiles {
return
}
content := strings.TrimSpace(r.FormValue("content"))
if utf8.RuneCountInString(content) > MaxPostRunes {
return
}
media := make([]string, 0, len(files))
mediasize := make(map[string]map[string]int, len(files))
for i, file := range files {
path, width, height := storeupload(file)
if path == "" {
return
}
storeclientthumbs(r, strconv.Itoa(i), path)
media = append(media, path)
mediasize[path] = map[string]int{"w": width, "h": height}
}
if content == "" && len(media) == 0 {
return
}
mediaJSON, _ := json.Marshal(media)
sizeJSON, _ := json.Marshal(mediasize)
support := ""
if s := r.FormValue("supporturl"); len(s) <= 500 && isweburl(s) {
support = s
}
mediaStr, sizeStr := string(mediaJSON), string(sizeJSON)
if err := queries.CreatePost(r.Context(), db.CreatePostParams{
Username: currentuser.Username,
Content: nstr(content),
Image: nstr(mediaStr),
Mediasize: nstr(sizeStr),
Supporturl: nstr(support),
Createtime: tostamp(time.Now().UTC()),
}); err != nil {
return
}
done(w)
}
func nzom(w http.ResponseWriter, r *http.Request) {
u, postID, ok := postactor(w, r)
if !ok {
return
}
res, err := sqlDB.ExecContext(r.Context(),
`INSERT INTO stars (username, postid) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
u.Username, postID)
if err != nil {
return
}
if affected, _ := res.RowsAffected(); affected == 0 {
return
}
_, _ = sqlDB.ExecContext(r.Context(),
`UPDATE posts SET starco = starco + 1 WHERE id = $1`, postID)
}
func like(w http.ResponseWriter, r *http.Request) {
u, postID, ok := postactor(w, r)
if !ok {
return
}
res, err := sqlDB.ExecContext(r.Context(),
`INSERT INTO likes (username, postid) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
u.Username, postID)
if err != nil {
return
}
affected, _ := res.RowsAffected()
if affected == 0 {
return
}
_, _ = sqlDB.ExecContext(r.Context(),
`UPDATE posts SET likeco = likeco + 1 WHERE id = $1`, postID)
}
func view(w http.ResponseWriter, r *http.Request) {
u, ok := currentuser(r)
if !ok {
reply(w, map[string]any{"counts": map[string]int{}})
return
}
ids := make([]int64, 0, 32)
for _, raw := range strings.Split(r.FormValue("post_id"), ",") {
if id := int64(touint(raw)); id != 0 {
ids = append(ids, id)
}
if len(ids) >= 64 {
break
}
}
if len(ids) == 0 {
reply(w, map[string]any{"counts": map[string]int{}})
return
}
if _, err := sqlDB.ExecContext(r.Context(),
`WITH ins AS (
INSERT INTO post_views (post_id, user_id)
SELECT x, $2 FROM unnest($1::bigint[]) AS x
ON CONFLICT DO NOTHING
RETURNING post_id)
UPDATE posts SET viewsco = viewsco + 1
WHERE id IN (SELECT post_id FROM ins)`, ids, u.Userid); err != nil {
reply(w, map[string]any{"counts": map[string]int{}})
return
}
rows, err := sqlDB.QueryContext(r.Context(),
`SELECT id, viewsco FROM posts WHERE id = ANY($1::bigint[])`, ids)
if err != nil {
reply(w, map[string]any{"counts": map[string]int{}})
return
}
defer rows.Close()
counts := make(map[string]int, len(ids))
for rows.Next() {
var id int64
var views int
if rows.Scan(&id, &views) == nil {
counts[strconv.FormatInt(id, 10)] = views
}
}
reply(w, map[string]any{"counts": counts})
}
func addcomment(w http.ResponseWriter, r *http.Request) {
u, postID, ok := postactor(w, r)
if !ok {
return
}
r.Body = http.MaxBytesReader(w, r.Body, MaxFormSize)
if r.ParseForm() != nil {
return
}
content := strings.TrimSpace(r.FormValue("content"))
if content == "" || utf8.RuneCountInString(content) > MaxCommentRunes {
return
}
if cmtspam(u.Userid, uint(postID), content) {
return
}
tx, err := sqlDB.BeginTx(r.Context(), nil)
if err != nil {
return
}
defer tx.Rollback()
var exists int64
if err := tx.QueryRowContext(r.Context(), `SELECT id FROM posts WHERE id = $1 FOR UPDATE`, postID).Scan(&exists); err != nil {
return
}
created := time.Now().UTC()
var id int64
if err := tx.QueryRowContext(r.Context(),
`INSERT INTO comments (postid, username, content, createtime) VALUES ($1, $2, $3, $4) RETURNING id`,
postID, u.Username, content, tostamp(created)).Scan(&id); err != nil {
return
}
if err := queries.WithTx(tx).IncrementComment(r.Context(), postID); err != nil {
return
}
if err := tx.Commit(); err != nil {
return
}
cmtclear(uint(postID))
if r.FormValue("response") == "comment" {
reply(w, commentView{ID: uint(id), Postid: uint(postID), Username: u.Username,
Avatar: text(u.Avatar), Content: content, Time: created})
} else {
done(w)
}
}
func daystart() string { return time.Now().UTC().Format("2006-01-02") + " 00:00:00.000000000" }
auth.go
package main
import (
"net/http"
"strings"
"time"
"unicode/utf8"
"wisal/backend/db"
)
func setsession(w http.ResponseWriter, r *http.Request, value string, maxAge int) {
secure := requestscheme(r) == "https"
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: value, Path: "/",
MaxAge: maxAge, HttpOnly: true, Secure: secure, SameSite: http.SameSiteStrictMode,
Expires: time.Now().Add(time.Duration(maxAge) * time.Second)})
}
func signup(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, MaxFormSize)
if strings.TrimSpace(r.FormValue("tms")) == "1" {
signtms(w, r)
return
}
username := strings.TrimSpace(r.FormValue("username"))
if rc := utf8.RuneCountInString(username); rc < MinUsernameRunes || rc > MaxUsernameRunes || username == "username" {
return
}
userID := randomid()
token, err := newsession(r.Context(), userID)
if err != nil {
return
}
now := time.Now().UTC()
white := "#ffffff"
tab1, tab2, editProfile := "منشوراتي", "حبييييت <3", "تخصيص"
if err := queries.CreateUser(r.Context(), db.CreateUserParams{
Username: username,
Displayname: nstr(username),
Userid: userID,
Namecolor: nstr(white),
Joincolor: nstr(white),
Tab1name: nstr(tab1),
Tab2name: nstr(tab2),
Editprofiletext: nstr(editProfile),
Editprofilecolor: nstr(white),
Tabcolor: nstr(white),
Postcolor: nstr(white),
Createtime: tostamp(now),
Lastseen: tostamp(now),
}); err != nil {
deletesession(r.Context(), token)
return
}
setsession(w, r, token, int(sessionTTL.Seconds()))
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte(userID))
}
func signin(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, MaxFormSize)
id := strings.TrimSpace(r.FormValue("id"))
if len(id) != 32 {
return
}
u, err := queries.GetUserByUserID(r.Context(), id)
if err != nil {
return
}
deletesession(r.Context(), sessionid(r))
token, err := newsession(r.Context(), u.Userid)
if err != nil {
return
}
setsession(w, r, token, int(sessionTTL.Seconds()))
_ = queries.UpdateUserLastSeen(r.Context(), db.UpdateUserLastSeenParams{
Lastseen: tostamp(time.Now().UTC()), ID: u.ID})
_, _ = w.Write([]byte(u.Username))
}
func logout(w http.ResponseWriter, r *http.Request) {
deletesession(r.Context(), sessionid(r))
setsession(w, r, "", -1)
}
func delaccount(w http.ResponseWriter, r *http.Request) {
currentuser, ok := currentuser(r)
if !ok {
return
}
removefiles(r, currentuser.Username)
tx, err := sqlDB.Begin()
if err != nil {
return
}
defer func() { _ = tx.Rollback() }()
qtx := queries.WithTx(tx)
username := currentuser.Username
affected := map[int64]bool{}
rows, err := tx.QueryContext(r.Context(),
`SELECT postid FROM stars WHERE username = $1
UNION SELECT postid FROM likes WHERE username = $1
UNION SELECT postid FROM comments WHERE username = $1
UNION SELECT post_id FROM post_views WHERE user_id = $2`,
username, currentuser.Userid)
if err == nil {
for rows.Next() {
var id int64
if rows.Scan(&id) == nil {
affected[id] = true
}
}
_ = rows.Close()
}
steps := []func() error{
func() error { return qtx.DeletePostsByUser(r.Context(), username) },
func() error { return qtx.DeleteCommentsByUser(r.Context(), username) },
func() error { return qtx.DeleteStarsByUser(r.Context(), username) },
func() error { return qtx.DeleteLikesByUser(r.Context(), username) },
func() error {
_, e := tx.ExecContext(r.Context(),
`DELETE FROM post_views WHERE user_id = $1`, currentuser.Userid)
return e
},
func() error {
_, e := tx.ExecContext(r.Context(),
`DELETE FROM post_views WHERE post_id NOT IN (SELECT id FROM posts)`)
return e
},
func() error {
for id := range affected {
if _, e := tx.ExecContext(r.Context(),
`UPDATE posts SET starco = (SELECT COUNT(*) FROM stars WHERE postid = $1),
likeco = (SELECT COUNT(*) FROM likes WHERE postid = $1),
commentco = (SELECT COUNT(*) FROM comments WHERE postid = $1),
viewsco = (SELECT COUNT(*) FROM post_views WHERE post_id = $1)
WHERE id = $1`, id); e != nil {
return e
}
}
return nil
},
func() error { return qtx.DeleteUser(r.Context(), currentuser.ID) },
}
for _, step := range steps {
if step() != nil {
return
}
}
_ = tx.Commit()
purgeorphans(sqlDB)
deleteusersessions(r.Context(), currentuser.Userid)
cmtclearall()
setsession(w, r, "", -1)
}
func savedob(w http.ResponseWriter, r *http.Request) {
currentuser, ok := currentuser(r)
if !ok {
return
}
r.Body = http.MaxBytesReader(w, r.Body, MaxFormSize)
dob := strings.TrimSpace(r.FormValue("dob"))
if utf8.RuneCountInString(dob) <= MaxDOBRunes {
_ = queries.UpdateUserDOB(r.Context(), db.UpdateUserDOBParams{Dob: nstr(dob),
ID: currentuser.ID})
}
}
func signtms(w http.ResponseWriter, r *http.Request) {
currentuser, ok := currentuser(r)
if !ok {
return
}
_ = queries.UpdateUserTMS(r.Context(), currentuser.ID)
_, _ = w.Write([]byte("احفظ id الحساب لو ضيعت سلملي على حسابك مابيرجع " + currentuser.Userid))
}
backup.go
package main
import (
"archive/zip"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"io"
"mime/multipart"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
func dataDir() string {
dir := filepath.Join("data")
_ = os.MkdirAll(dir, 0700)
return dir
}
func backupkey() string {
if k := strings.TrimSpace(os.Getenv("BACKUP_KEY")); k != "" {
return k
}
data, err := os.ReadFile(filepath.Join(dataDir(), "backup_all"))
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
func newbackupkey() string {
b := make([]byte, 32)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
func rotatebackupkey() {
keyFile := filepath.Join(dataDir(), "backup_all")
if st, err := os.Stat(keyFile); err != nil || time.Since(st.ModTime()) > 7*24*time.Hour {
_ = os.WriteFile(keyFile, []byte(newbackupkey()), 0600)
}
}
func backupsecret(r *http.Request) bool {
key := backupkey()
if key == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(r.FormValue("key")), []byte(key)) == 1
}
func addzipfile(zw *zip.Writer, name, path string) {
src, err := os.Open(path)
if err != nil {
return
}
defer src.Close()
dst, err := zw.Create(name)
if err != nil {
return
}
_, _ = io.Copy(dst, src)
}
func pgdump(zw *zip.Writer) {
dst, err := zw.Create("wisal.sql")
if err != nil {
return
}
cmd := exec.Command("pg_dump", "--clean", "--if-exists", "--no-owner", "--no-acl",
"--dbname="+dburl())
cmd.Stdout = dst
cmd.Stderr = io.Discard
_ = cmd.Run()
}
func downloadbackup(w http.ResponseWriter, r *http.Request) {
rotatebackupkey()
if !backupsecret(r) {
return
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", `attachment; filename="wisal-backup.zip"`)
zw := zip.NewWriter(w)
pgdump(zw)
entries, _ := os.ReadDir(uploadDir)
for _, e := range entries {
if e.IsDir() || e.Name() == "index.html" {
continue
}
addzipfile(zw, "uploads/"+e.Name(), filepath.Join(uploadDir, e.Name()))
}
_ = zw.Close()
}
func uploadbackup(w http.ResponseWriter, r *http.Request) {
rotatebackupkey()
if !backupsecret(r) {
return
}
r.Body = http.MaxBytesReader(w, r.Body, MaxBackupSize)
if r.ParseMultipartForm(MaxUploadRAM) != nil {
return
}
if _, hdr, err := r.FormFile("file"); err == nil && hdr != nil {
if zr := unpack(w, r, hdr); zr != nil {
defer zr.Close()
restore(zr)
}
}
}
func unpack(w http.ResponseWriter, r *http.Request, hdr *multipart.FileHeader) *zip.ReadCloser {
src, err := hdr.Open()
if err != nil {
return nil
}
defer src.Close()
tmp, err := os.CreateTemp("", "restore-*.zip")
if err != nil {
return nil
}
defer os.Remove(tmp.Name())
if _, err := io.Copy(tmp, io.LimitReader(src, MaxBackupSize)); err != nil {
tmp.Close()
return nil
}
tmp.Close()
zr, err := zip.OpenReader(tmp.Name())
if err != nil {
return nil
}
return zr
}
func restore(zr *zip.ReadCloser) {
restored := false
if len(zr.File) > 100000 {
return
}
var written int64
for _, f := range zr.File {
if f.FileInfo().IsDir() {
continue
}
if !strings.HasPrefix(f.Name, "uploads/") && f.Name != "wisal.sql" {
continue
}
if f.UncompressedSize64 > MaxBackupSize {
return
}
if f.Name == "wisal.sql" {
if !restoredb(f) {
return
}
restored = true
continue
}
written += writeupload(f)
if written > MaxBackupSize {
return
}
}
cmtclearall()
if restored {
if sqlDB != nil {
_ = sqlDB.Close()
}
opendb()
}
}
func restoredb(f *zip.File) bool {
rc, err := f.Open()
if err != nil {
return false
}
defer rc.Close()
tmp, err := os.CreateTemp("", "restore-*.sql")
if err != nil {
return false
}
defer os.Remove(tmp.Name())
if _, err := io.Copy(tmp, io.LimitReader(rc, MaxBackupSize)); err != nil {
tmp.Close()
return false
}
tmp.Close()
cmd := exec.Command("psql", "-v", "ON_ERROR_STOP=1", "--dbname="+dburl(), "-f", tmp.Name())
cmd.Stdout = io.Discard
cmd.Stderr = io.Discard
return cmd.Run() == nil
}
func writeupload(f *zip.File) int64 {
rc, err := f.Open()
if err != nil {
return 0
}
defer rc.Close()
if storagefull() {
return 0
}
cleanName := filepath.Clean(f.Name)
if !strings.HasPrefix(cleanName, "uploads/") || strings.Contains(cleanName, "..") {
return 0
}
out, err := os.Create(filepath.Join(uploadDir, filepath.Base(cleanName)))
if err != nil {
return 0
}
defer out.Close()
written, _ := io.Copy(out, io.LimitReader(rc, MaxBackupSize))
return written
}
func backupinfo(w http.ResponseWriter, r *http.Request) {
if !backupsecret(r) {
return
}
var users, posts int
if sqlDB != nil {
_ = sqlDB.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&users)
_ = sqlDB.QueryRow(`SELECT COUNT(*) FROM posts`).Scan(&posts)
}
entries, _ := os.ReadDir(uploadDir)
files := 0
for _, e := range entries {
if !e.IsDir() && e.Name() != "index.html" {
files++
}
}
reply(w, map[string]any{"users": users, "posts": posts, "files": files})
}
blob.go
package main
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
type blobconf struct {
endpoint string
bucket string
key string
secret string
region string
client *http.Client
}
var blobsync = struct {
sync.Mutex
m map[string]string
}{m: map[string]string{}}
func blobfromenv() (*blobconf, error) {
c := &blobconf{
endpoint: strings.TrimSuffix(os.Getenv("R2_ENDPOINT"), "/"),
bucket: os.Getenv("R2_BUCKET"),
key: os.Getenv("R2_ACCESS_KEY"),
secret: os.Getenv("R2_SECRET_KEY"),
region: "auto",
client: &http.Client{Timeout: 120 * time.Second},
}
if c.endpoint == "" || c.bucket == "" || c.key == "" || c.secret == "" {
return nil, errors.New("blob config missing")
}
return c, nil
}
func blobstart() {
if !strings.EqualFold(os.Getenv("STORAGE"), "r2") {
return
}
c, err := blobfromenv()
if err != nil {
return
}
go func() {
for {
blobsyncnow(c)
time.Sleep(5 * time.Second)
}
}()
}
func blobsyncnow(c *blobconf) {
entries, err := os.ReadDir(uploadDir)
if err != nil {
return
}
seen := map[string]bool{}
for _, e := range entries {
if e.IsDir() || strings.HasPrefix(e.Name(), ".") {
continue
}
info, err := e.Info()
if err != nil {
continue
}
name := e.Name()
seen[name] = true
sig := info.Size()
key := name + ":" + time.Unix(0, info.ModTime().UnixNano()).String() + ":" + itoa64(sig)
blobsync.Lock()
old := blobsync.m[name]
blobsync.m[name] = key
blobsync.Unlock()
if old == key {
continue
}
data, err := os.ReadFile(filepath.Join(uploadDir, name))
if err != nil {
continue
}
if c.put("uploads/"+name, data, mediacontent(name)) != nil {
blobsync.Lock()
blobsync.m[name] = old
blobsync.Unlock()
}
}
blobsync.Lock()
stale := make([]string, 0, len(blobsync.m))
for name := range blobsync.m {
if !seen[name] {
stale = append(stale, name)
}
}
for _, name := range stale {
delete(blobsync.m, name)
}
blobsync.Unlock()
for _, name := range stale {
_ = c.del("uploads/" + name)
}
}
func mediacontent(name string) string {
switch strings.ToLower(filepath.Ext(name)) {
case ".jpg", ".jpeg":
return "image/jpeg"
case ".png":
return "image/png"
case ".gif":
return "image/gif"
case ".webp":
return "image/webp"
case ".mp4":
return "video/mp4"
case ".webm":
return "video/webm"
case ".html":
return "text/html; charset=utf-8"
}
return "application/octet-stream"
}
func itoa64(v int64) string {
if v == 0 {
return "0"
}
var b [20]byte
i := len(b)
for v > 0 {
i--
b[i] = byte('0' + v%10)
v /= 10
}
return string(b[i:])
}
func hmackey(secret, day, region, service string) []byte {
k := []byte("AWS4" + secret)
for _, p := range []string{day, region, service, "aws4_request"} {
m := hmac.New(sha256.New, k)
m.Write([]byte(p))
k = m.Sum(nil)
}
return k
}
func canonicaluri(u *url.URL) string {
if u.EscapedPath() == "" {
return "/"
}
return u.EscapedPath()
}
func sigv4(req *http.Request, body []byte, key, secret, region, service string, now time.Time) {
amp := now.UTC().Format("20060102T150405Z")
day := amp[:8]
sum := sha256.Sum256(body)
payload := hex.EncodeToString(sum[:])
req.Header.Set("x-amz-content-sha256", payload)
req.Header.Set("x-amz-date", amp)
canonical := req.Method + "\n" + canonicaluri(req.URL) + "\n" + req.URL.RawQuery + "\n" +
"host:" + req.Host + "\n" +
"x-amz-content-sha256:" + payload + "\n" +
"x-amz-date:" + amp + "\n\n" +
"host;x-amz-content-sha256;x-amz-date\n" + payload
scope := day + "/" + region + "/" + service + "/aws4_request"
ch := sha256.Sum256([]byte(canonical))
sts := "AWS4-HMAC-SHA256\n" + amp + "\n" + scope + "\n" + hex.EncodeToString(ch[:])
m := hmac.New(sha256.New, hmackey(secret, day, region, service))
m.Write([]byte(sts))
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+key+"/"+scope+
", SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="+hex.EncodeToString(m.Sum(nil)))
}
func (c *blobconf) do(method, key string, body []byte, ctype string) error {
u, err := url.Parse(c.endpoint + "/" + c.bucket + "/" + key)
if err != nil {
return err
}
req, err := http.NewRequest(method, u.String(), bytes.NewReader(body))
if err != nil {
return err
}
req.Host = u.Host
if ctype != "" {
req.Header.Set("Content-Type", ctype)
}
sigv4(req, body, c.key, c.secret, c.region, "s3", time.Now())
res, err := c.client.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
_, _ = io.Copy(io.Discard, res.Body)
if res.StatusCode < 200 || res.StatusCode > 299 {
return errors.New("blob status " + res.Status)
}
return nil
}
func (c *blobconf) put(key string, data []byte, ctype string) error {
return c.do(http.MethodPut, key, data, ctype)
}
func (c *blobconf) del(key string) error {
return c.do(http.MethodDelete, key, nil, "")
}
cors.go
package main
import (
"net/http"
"net/url"
"os"
"strings"
)
func frontendOrigin(r *http.Request) string {
if r.Host != os.Getenv("API_HOST") || requestscheme(r) != "https" {
return ""
}
origin := r.Header.Get("Origin")
u, err := url.Parse(origin)
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" {
return ""
}
for _, allowed := range strings.Split(os.Getenv("PUBLIC_ORIGINS"), ",") {
if origin == strings.TrimSpace(allowed) {
return origin
}
}
return ""
}
func cors(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Host == os.Getenv("API_HOST") && os.Getenv("API_HOST") != "" {
w.Header().Add("Vary", "Origin")
if strings.HasPrefix(r.URL.Path, "/uploads/") {
w.Header().Set("Cross-Origin-Resource-Policy", "same-site")
}
}
origin := frontendOrigin(r)
if origin != "" {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Expose-Headers", "ETag")
}
if r.Method == http.MethodOptions {
method := r.Header.Get("Access-Control-Request-Method")
if origin != "" && (method == http.MethodGet || method == http.MethodPost || method == http.MethodHead) {
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, POST")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, If-None-Match")
w.Header().Set("Access-Control-Max-Age", "600")
}
return
}
next.ServeHTTP(w, r)
})
}
eyeswithoutaface.go
package main
import (
"database/sql"
"time"
"wisal/backend/db"
)
func text(p sql.NullString) string {
if p.Valid {
return p.String
}
return ""
}
func count(p sql.NullInt64) int {
if p.Valid {
return int(p.Int64)
}
return 0
}
func nstr(s string) sql.NullString { return sql.NullString{String: s, Valid: true} }
func tostamp(t time.Time) sql.NullString {
if t.IsZero() {
return sql.NullString{}
}
return sql.NullString{String: t.UTC().Format("2006-01-02 15:04:05.000000000"), Valid: true}
}
func totime(p sql.NullString) time.Time {
raw := text(p)
if raw == "" {
return time.Time{}
}
for _, layout := range []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02 15:04:05.999999999 -0700 MST",
"2006-01-02 15:04:05.999999999-07:00",
"2006-01-02 15:04:05.999999999",
"2006-01-02T15:04:05.999999999",
"2006-01-02 15:04:05",
"2006-01-02",
} {
if t, err := time.Parse(layout, raw); err == nil {
return t
}
}
return time.Time{}
}
type postView struct {
ID uint `json:"id"`
Username string `json:"username"`
DisplayName string `json:"DisplayName"`
Avatar string `json:"Avatar"`
Content string `json:"content"`
Image string `json:"image"`
Mediasize string `json:"mediasize"`
Starco int `json:"Starco"`
Likeco int `json:"Likeco"`
Viewsco int `json:"Viewsco"`
Commentco int `json:"Commentco"`
Supporturl string `json:"supporturl"`
Time time.Time `json:"time"`
}
type postFields struct {
ID int64
Username string
Displayname, Avatar sql.NullString
Content, Image, Mediasize, Supporturl sql.NullString
Starco, Likeco, Viewsco, Commentco sql.NullInt64
Time sql.NullString
}
func postview(f postFields) postView {
return postView{
ID: uint(f.ID), Username: f.Username,
DisplayName: text(f.Displayname), Avatar: text(f.Avatar),
Content: text(f.Content), Image: text(f.Image), Mediasize: text(f.Mediasize),
Starco: count(f.Starco), Likeco: count(f.Likeco),
Viewsco: count(f.Viewsco), Commentco: count(f.Commentco),
Supporturl: text(f.Supporturl), Time: totime(f.Time),
}
}
func madarpost(r db.FeedPostsRow) postView {
return postview(postFields{
ID: r.ID, Username: r.Username, Displayname: r.DisplayName, Avatar: r.Avatar,
Content: r.Content, Image: r.Image, Mediasize: r.Mediasize, Supporturl: r.Supporturl,
Starco: r.Starco, Likeco: r.Likeco, Viewsco: r.Viewsco, Commentco: r.Commentco, Time: r.Time,
})
}
func njompost(r db.TrendingPostsRow) postView {
return postview(postFields{
ID: r.ID, Username: r.Username, Displayname: r.DisplayName, Avatar: r.Avatar,
Content: r.Content, Image: r.Image, Mediasize: r.Mediasize, Supporturl: r.Supporturl,
Starco: r.Starco, Likeco: r.Likeco, Viewsco: r.Viewsco, Commentco: r.Commentco, Time: r.Time,
})
}
func userpost(r db.UserPostsRow) postView {
return postview(postFields{
ID: r.ID, Username: r.Username, Displayname: r.DisplayName, Avatar: r.Avatar,
Content: r.Content, Image: r.Image, Mediasize: r.Mediasize, Supporturl: r.Supporturl,
Starco: r.Starco, Likeco: r.Likeco, Viewsco: r.Viewsco, Commentco: r.Commentco, Time: r.Time,
})
}
func likedpost(r db.LikedPostsRow) postView {
return postview(postFields{
ID: r.ID, Username: r.Username, Displayname: r.DisplayName, Avatar: r.Avatar,
Content: r.Content, Image: r.Image, Mediasize: r.Mediasize, Supporturl: r.Supporturl,
Starco: r.Starco, Likeco: r.Likeco, Viewsco: r.Viewsco, Commentco: r.Commentco, Time: r.Time,
})
}
func lastpostview(r db.LastPostRow) postView {
return postview(postFields{
ID: r.ID, Username: r.Username, Displayname: r.DisplayName, Avatar: r.Avatar,
Content: r.Content, Image: r.Image, Mediasize: r.Mediasize, Supporturl: r.Supporturl,
Starco: r.Starco, Likeco: r.Likeco, Viewsco: r.Viewsco, Commentco: r.Commentco, Time: r.Time,
})
}
type commentView struct {
ID uint `json:"id"`
Postid uint `json:"post_id"`
Username string `json:"username"`
Avatar string `json:"avatar_path"`
Content string `json:"content"`
Time time.Time `json:"time"`
}
func commentview(r db.PostCommentsRow) commentView {
return commentView{
ID: uint(r.ID),
Postid: uint(r.PostID), Username: r.Username,
Avatar: text(r.AvatarPath), Content: text(r.Content),
Time: totime(r.Time),
}
}
func commentview2(r db.PostCommentsSinceRow) commentView {
return commentView{
ID: uint(r.ID),
Postid: uint(r.PostID), Username: r.Username,
Avatar: text(r.AvatarPath), Content: text(r.Content),
Time: totime(r.Time),
}
}
type sideUser struct {
Username string `json:"username"`
Displayname string `json:"displayname"`
Avatar string `json:"avatar"`
Createtime time.Time `json:"createtime"`
}
func sideview(username string, displayname, avatar, created sql.NullString) sideUser {
return sideUser{Username: username,
Displayname: text(displayname), Avatar: text(avatar),
Createtime: totime(created)}
}
func newestside(r db.ListSideUsersRow) sideUser {
return sideview(r.Username, r.Displayname, r.Avatar, r.Createtime)
}
func oldestside(r db.ListSideUsersAscRow) sideUser {
return sideview(r.Username, r.Displayname, r.Avatar, r.Createtime)
}
func allside(r db.ListAllSideUsersRow) sideUser {
return sideview(r.Username, r.Displayname, r.Avatar, r.Createtime)
}
type userView struct {
ID int64 `json:"ID"`
Username string `json:"username"`
Displayname string `json:"displayname"`
Userid string `json:"-"`
Namecolor string `json:"namecolor"`
Bio string `json:"bio"`
Biocolor string `json:"biocolor"`
Jointext string `json:"jointext"`
Joincolor string `json:"joincolor"`
Avatar string `json:"avatar"`
Banner string `json:"banner"`
Profilebg string `json:"profilebg"`
Tab1name string `json:"tab1name"`
Tab2name string `json:"tab2name"`
Editprofiletext string `json:"editprofiletext"`
Editprofilecolor string `json:"editprofilecolor"`
Tabcolor string `json:"tabcolor"`
Postcolor string `json:"postcolor"`
Mlamh string `json:"mlamh"`
Supportlinks string `json:"supportlinks"`
Dob string `json:"dob"`
Createtime time.Time `json:"createtime"`
}
func userdata(u db.User) userView {
return userView{
ID: u.ID,
Username: u.Username, Displayname: text(u.Displayname),
Userid: u.Userid,
Namecolor: text(u.Namecolor),
Bio: text(u.Bio), Biocolor: text(u.Biocolor),
Jointext: text(u.Jointext), Joincolor: text(u.Joincolor),
Avatar: text(u.Avatar), Banner: text(u.Banner),
Profilebg: text(u.Profilebg),
Tab1name: text(u.Tab1name),
Tab2name: text(u.Tab2name),
Editprofiletext: text(u.Editprofiletext),
Editprofilecolor: text(u.Editprofilecolor),
Tabcolor: text(u.Tabcolor),
Postcolor: text(u.Postcolor),
Mlamh: text(u.Mlamh),
Supportlinks: text(u.Supportlinks),
Dob: text(u.Dob),
Createtime: totime(u.Createtime),
}
}
func tolist[R, V any](rows []R, _ error, conv func(R) V) []V {
out := make([]V, 0, len(rows))
for _, r := range rows {
out = append(out, conv(r))
}
return out
}
fixbandwith.go
package main
import (
"bytes"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"hash/fnv"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
const (
bwMaxFeedLimit = 50
bwMaxBody = 8 << 20
)
func bandwidth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clampFeedLimit(r)
if strings.HasPrefix(r.URL.Path, "/uploads/") {
if strings.HasSuffix(r.URL.Path, "/") {
return
}
if thumb, ok := thumbfile(r); ok {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
http.ServeFile(w, r, thumb)
return
}
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
if r.Method != http.MethodGet && r.Method != http.MethodHead {
next.ServeHTTP(w, r)
return
}
switch path := r.URL.Path; {
case strings.HasPrefix(path, "/api/"):
if strings.HasPrefix(path, "/api/backup") {
next.ServeHTTP(w, r)
return
}
serveETagged(w, r, next, "private, no-cache")
case path == "/source" || path == "/sitemap.xml" || path == "/robots.txt":
serveETagged(w, r, next, "public, max-age=3600, must-revalidate")
default:
next.ServeHTTP(w, r)
}
})
}
func clampFeedLimit(r *http.Request) {
if r.URL.Path != "/api/feed" && r.URL.Path != "/api/user" {
return
}
q := r.URL.Query()
lim, err := strconv.Atoi(q.Get("limit"))
if err != nil || lim <= bwMaxFeedLimit {
return
}
q.Set("limit", strconv.Itoa(bwMaxFeedLimit))
r.URL.RawQuery = q.Encode()
}
var thumbWidths = []int{96, 256, 640}
func thumbsuffix(stem string, w int) string {
return stem + "_w" + strconv.Itoa(w) + ".webp"
}
func thumbfile(r *http.Request) (string, bool) {
w, err := strconv.Atoi(r.URL.Query().Get("w"))
if err != nil {
return "", false
}
allowed := false
for _, a := range thumbWidths {
if w == a {
allowed = true
break
}
}
if !allowed {
return "", false
}
name := filepath.Base(r.URL.Path)
switch strings.ToLower(filepath.Ext(name)) {
case ".jpg", ".jpeg", ".png", ".gif", ".webp":
default:
return "", false
}
if thumbhassource(name) {
return "", false
}
src := filepath.Join(uploadDir, name)
dst := filepath.Join(uploadDir, thumbsuffix(strings.TrimSuffix(name, filepath.Ext(name)), w))
if thumbfresh(dst, src) {
return dst, true
}
return "", false
}
func thumbfresh(dst, src string) bool {
di, err := os.Stat(dst)
if err != nil {
return false
}
si, err := os.Stat(src)
if err != nil {
return false
}
return !di.ModTime().Before(si.ModTime())
}
type bwRec struct {
http.ResponseWriter
status int
buf bytes.Buffer
streaming bool
}
func (b *bwRec) WriteHeader(code int) { b.status = code }
func (b *bwRec) Write(p []byte) (int, error) {
if b.streaming {
return b.ResponseWriter.Write(p)
}
if b.buf.Len()+len(p) > bwMaxBody {
b.streaming = true
b.ResponseWriter.WriteHeader(b.status)
return b.ResponseWriter.Write(append(b.buf.Bytes(), p...))
}
return b.buf.Write(p)
}
func bwETag(body []byte, status int) string {
sum := sha1.Sum(append(body, byte(status)))
return `"` + hex.EncodeToString(sum[:]) + `"`
}
func bwNotModified(r *http.Request, etag string) bool {
for _, field := range strings.Split(r.Header.Get("If-None-Match"), ",") {
field = strings.TrimSpace(field)
if field == "*" {
return true
}
if strings.TrimPrefix(field, "W/") == etag {
return true
}
}
return false
}
func serveETagged(w http.ResponseWriter, r *http.Request, next http.Handler, cache string) {
rec := &bwRec{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
if rec.streaming {
return
}
body := rec.buf.Bytes()
etag := bwETag(body, rec.status)
h := w.Header()
h.Set("ETag", etag)
if !strings.Contains(h.Get("Vary"), "Accept-Encoding") {
h.Add("Vary", "Accept-Encoding")
}
if cache != "" && h.Get("Cache-Control") == "" {
h.Set("Cache-Control", cache)
}
if rec.status == http.StatusOK && bwNotModified(r, etag) {
h.Del("Content-Length")
h.Del("Content-Encoding")
w.WriteHeader(http.StatusNotModified)
return
}
h.Set("Content-Length", strconv.Itoa(len(body)))
w.WriteHeader(rec.status)
if r.Method != http.MethodHead {
_, _ = w.Write(body)
}
}
var commentCache = struct {
sync.RWMutex
m map[uint]*cachedComments
}{m: make(map[uint]*cachedComments)}
type cachedComments struct {
body []byte
etag string
expiresAt time.Time
}
const commentCacheTTL = 15 * time.Second
func cachedcomments(w http.ResponseWriter, r *http.Request, postID uint) {
w.Header().Set("Content-Type", "application/json")
now := time.Now()
commentCache.RLock()
cached := commentCache.m[postID]
commentCache.RUnlock()
if cached != nil && now.Before(cached.expiresAt) {
h := w.Header()
h.Set("ETag", cached.etag)
h.Set("Cache-Control", "private, max-age=15, must-revalidate")
h.Add("Vary", "Accept-Encoding")
if r.Header.Get("If-None-Match") == cached.etag {
h.Del("Content-Length")
h.Del("Content-Encoding")
w.WriteHeader(http.StatusNotModified)
return
}
w.WriteHeader(http.StatusOK)
if r.Method != http.MethodHead {
_, _ = w.Write(cached.body)
}
return
}
rows, err := queries.PostComments(r.Context(), int64(postID))
if err != nil {
reply(w, []commentView{})
return
}
views := tolist(rows, nil, commentview)
body, _ := json.Marshal(views)
etag := bwETag(body, http.StatusOK)
commentCache.Lock()
if len(commentCache.m) >= 1000 {
for k, v := range commentCache.m {
if now.After(v.expiresAt) {
delete(commentCache.m, k)
}
}
}
if len(commentCache.m) >= 1000 {
for k := range commentCache.m {
delete(commentCache.m, k)
break
}
}
commentCache.m[postID] = &cachedComments{
body: body,
etag: etag,
expiresAt: time.Now().Add(commentCacheTTL),
}
commentCache.Unlock()
h := w.Header()
h.Set("ETag", etag)
h.Set("Cache-Control", "private, max-age=15, must-revalidate")
h.Add("Vary", "Accept-Encoding")
w.WriteHeader(http.StatusOK)
if r.Method != http.MethodHead {
_, _ = w.Write(body)
}
}
func cmtclear(id uint) {
commentCache.Lock()
delete(commentCache.m, id)
commentCache.Unlock()
}
func cmtclearall() {
commentCache.Lock()
commentCache.m = make(map[uint]*cachedComments)
commentCache.Unlock()
}
const (
cmtWindow = 10 * time.Minute
cmtWindowMax = 3
cmtRepeat = 30 * time.Minute
cmtGuardMaxUsers = 8192
)
type cmtGuard struct {
hash uint64
at int64
count int
epoch int64
}
var cmtGuards = struct {
sync.Mutex
m map[string]*cmtGuard
}{m: map[string]*cmtGuard{}}
func cmtspam(userid string, postID uint, content string) bool {
now := time.Now().Unix()
sum := fnv.New64a()
_, _ = sum.Write([]byte(content))
h := sum.Sum64()
key := userid + ":" + itoa64(int64(postID))
cmtGuards.Lock()
defer cmtGuards.Unlock()
if len(cmtGuards.m) > cmtGuardMaxUsers {
for k, v := range cmtGuards.m {
if now-v.at >= int64(cmtRepeat.Seconds()) {
delete(cmtGuards.m, k)
}
}
}
g := cmtGuards.m[key]
if g == nil {
g = &cmtGuard{}
cmtGuards.m[key] = g
}
if now-g.epoch >= int64(cmtWindow.Seconds()) {
g.count, g.epoch = 0, now
}
if now-g.at < int64(cmtRepeat.Seconds()) && g.hash == h {
return true
}
if g.count >= cmtWindowMax {
return true
}
g.count, g.at, g.hash = g.count+1, now, h
return false
}
fixstorage.go
package main
import (
"database/sql"
"encoding/json"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
)
const (
sweepGrace = 1 * time.Hour
vacuumPages = 1000
)
func sweepstorage() {
if uploadDir == "" || sqlDB == nil {
return
}
_ = os.RemoveAll(filepath.Join(uploadDir, "wisal-mlamh-parts"))
keep, ok := referenceduploads()
if !ok {
return
}
entries, err := os.ReadDir(uploadDir)
if err != nil {
return
}
now := time.Now()
for _, e := range entries {
if e.IsDir() || e.Name() == "index.html" || keep[e.Name()] {
continue
}
if thumbhassource(e.Name()) {
continue
}
info, err := e.Info()
if err != nil || now.Sub(info.ModTime()) < sweepGrace {
continue
}
_ = os.Remove(filepath.Join(uploadDir, e.Name()))
}
vacuumifneeded()
}
func referenceduploads() (map[string]bool, bool) {
var total int
if err := sqlDB.QueryRow(`SELECT (SELECT count(*) FROM users) + (SELECT count(*) FROM posts)`).Scan(&total); err != nil || total == 0 {
return nil, false
}
keep := map[string]bool{}
rows, err := sqlDB.Query(`SELECT avatar, banner, profilebg, mlamh FROM users`)
if err != nil {
return nil, false
}
var av, bn, bg, ml sql.NullString
for rows.Next() {
if rows.Scan(&av, &bn, &bg, &ml) != nil {
continue
}
for _, ns := range []sql.NullString{av, bn, bg} {
if ns.Valid && ns.String != "" {
keep[filepath.Base(ns.String)] = true
}
}
if ml.Valid {
for _, name := range uploadtokens(ml.String) {
keep[name] = true
}
}
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return nil, false
}
_ = rows.Close()
rows, err = sqlDB.Query(`SELECT image FROM posts`)
if err != nil {
return nil, false
}
var img sql.NullString
for rows.Next() {
if rows.Scan(&img) != nil || !img.Valid || img.String == "" {
continue
}
var media []string
if json.Unmarshal([]byte(img.String), &media) == nil {
for _, m := range media {
if m != "" {
keep[filepath.Base(m)] = true
}
}
continue
}
keep[filepath.Base(img.String)] = true
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return nil, false
}
_ = rows.Close()
return keep, true
}
func uploadtokens(s string) []string {
var out []string
for i := 0; i+8 < len(s); {
j := strings.Index(s[i:], "uploads/")
if j < 0 {
break
}
i += j + 8
k := i
for k < len(s) && isfilenamechar(s[k]) {
k++
}
if k > i {
out = append(out, s[i:k])
}
i = k
}
return out
}
func isfilenamechar(c byte) bool {
return c == '.' || c == '_' || c == '-' ||
(c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
}
func thumbhassource(name string) bool {
stem := strings.TrimSuffix(name, ".webp")
for _, w := range thumbWidths {
suffix := "_w" + strconv.Itoa(w)
if !strings.HasSuffix(stem, suffix) {
continue
}
stem = strings.TrimSuffix(stem, suffix)
for _, ext := range []string{".jpg", ".jpeg", ".png", ".gif", ".webp"} {
if _, err := os.Stat(filepath.Join(uploadDir, stem+ext)); err == nil {
return true
}
}
return false
}
return false
}
func vacuumifneeded() {
if sqlDB == nil {
return
}
var dead int64
if sqlDB.QueryRow(
`SELECT COALESCE(SUM(n_dead_tup), 0) FROM pg_stat_user_tables`).Scan(&dead) != nil ||
dead < vacuumPages {
return
}
_, _ = sqlDB.Exec(`VACUUM (ANALYZE)`)
}
func storagefull() bool {
if uploadDir == "" {
return true
}
var st syscall.Statfs_t
if err := syscall.Statfs(uploadDir, &st); err != nil {
return false
}
return st.Bavail*uint64(st.Bsize) < MaxFreeFloor
}
madar.go
package main
import (
"net/http"
"strings"
"wisal/backend/db"
)
const maxPage = 200
func paging(r *http.Request) (limit, offset int) {
if limit = int(touint(r.FormValue("limit"))); limit <= 0 || limit > maxPage {
limit = 50
}
return limit, int(touint(r.FormValue("offset")))
}
func madar(w http.ResponseWriter, r *http.Request) {
limit, offset := paging(r)
rows, err := queries.FeedPosts(r.Context(), db.FeedPostsParams{RowLimit: int64(limit),
RowOffset: int64(offset)})
reply(w, tolist(rows, err, madarpost))
}
func njom(w http.ResponseWriter, r *http.Request) {
limit, offset := paging(r)
rows, err := queries.TrendingPosts(r.Context(), db.TrendingPostsParams{RowLimit: int64(limit),
RowOffset: int64(offset)})
reply(w, tolist(rows, err, njompost))
}
func comments(w http.ResponseWriter, r *http.Request) {
postID := touint(r.FormValue("post_id"))
sinceID := touint(r.FormValue("since_id"))
switch {
case postID == 0:
reply(w, []commentView{})
case sinceID > 0:
rows, err := queries.PostCommentsSince(r.Context(), db.PostCommentsSinceParams{
Postid: int64(postID), ID: int64(sinceID)})
reply(w, tolist(rows, err, commentview2))
default:
cachedcomments(w, r, postID)
}
}
func incrementalComments(w http.ResponseWriter, r *http.Request, postID, sinceID uint) {
rows, err := queries.PostCommentsSince(r.Context(), db.PostCommentsSinceParams{
Postid: int64(postID), ID: int64(sinceID)})
views := tolist(rows, err, commentview2)
reply(w, views)
}
func profile(w http.ResponseWriter, r *http.Request) {
row, _ := queries.GetUserByUsername(r.Context(), strings.TrimSpace(r.FormValue("username")))
v := userdata(row)
if u, ok := currentuser(r); !ok || u.Username != v.Username {
v.Dob = ""
}
reply(w, v)
}
func myprofile(w http.ResponseWriter, r *http.Request) {
u, ok := currentuser(r)
if !ok {
reply(w, nil)
return
}
reply(w, userdata(u))
}
func userposts(w http.ResponseWriter, r *http.Request) {
limit, offset := paging(r)
rows, err := queries.UserPosts(r.Context(), db.UserPostsParams{
Username: strings.TrimSpace(r.FormValue("username")),
RowLimit: int64(limit), RowOffset: int64(offset)})
reply(w, tolist(rows, err, userpost))
}
func pollfeed(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
incremental := q.Has("since_id")
payload := interface{}(nil)
list := []postView{}
if incremental {
rows, err := queries.FeedPostsSince(r.Context(), int64(touint(q.Get("since_id"))))
if err == nil {
for i := len(rows) - 1; i >= 0; i-- {
list = append(list, madarpost(db.FeedPostsRow(rows[i])))
}
}
payload = list
} else if pv, err := queries.LastPost(r.Context()); err == nil {
payload = lastpostview(pv)
}
reply(w, payload)
}
func userliked(w http.ResponseWriter, r *http.Request) {
limit, offset := paging(r)
rows, err := queries.LikedPosts(r.Context(), db.LikedPostsParams{
Username: strings.TrimSpace(r.FormValue("username")),
RowLimit: int64(limit), RowOffset: int64(offset)})
reply(w, tolist(rows, err, likedpost))
}
main.go
package main
import (
"context"
"io"
"log"
"mime"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
for ext, t := range map[string]string{".mp4": "video/mp4", ".webm": "video/webm", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", ".webp": "image/webp"} {
_ = mime.AddExtensionType(ext, t)
}
if !opendb() {
return
}
sweepstorage()
blobstart()
mux := http.NewServeMux()
mux.HandleFunc("/madar", func(w http.ResponseWriter, r *http.Request) {
mobilepage(r, "cfrontend/frontendorg/madar.html", "cfrontend/frontendorg/madarp.html")(w, r)
})
mux.HandleFunc("/madar-phone", func(w http.ResponseWriter, r *http.Request) {
page("cfrontend/frontendorg/madarp.html")(w, r)
})
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
mobilepage(r, "cfrontend/frontendorg/login.html", "cfrontend/frontendorg/loginp.html")(w, r)
return
}
signin(w, r)
})
mux.HandleFunc("/login-phone", page("cfrontend/frontendorg/loginp.html"))
mux.HandleFunc("/profile-phone", page("cfrontend/frontendorg/profilep.html"))
mux.HandleFunc("/profile", func(w http.ResponseWriter, r *http.Request) {
mobilepage(r, "cfrontend/frontendorg/profile.html", "cfrontend/frontendorg/profilep.html")(w, r)
})
mux.HandleFunc("/mlamh", pageauth("cfrontend/frontendorg/mlamh.html"))
mux.HandleFunc("/users", page("cfrontend/frontendorg/users.html"))
mux.HandleFunc("/rules", page("cfrontend/frontendorg/rules.html"))
mux.HandleFunc("/privacy", page("cfrontend/frontendorg/privacy.html"))
mux.HandleFunc("/source", source)
mux.HandleFunc("/robots.txt", robots)
mux.HandleFunc("/sitemap.xml", sitemap)
mux.HandleFunc("/favicon.png", page("cfrontend/icons/favicon.png"))
mux.HandleFunc("/api/sign", signup)
mux.HandleFunc("/api/logout", logout)
mux.HandleFunc("/api/feed", feed)
mux.HandleFunc("/api/user", user)
mux.HandleFunc("/create", pageauth("cfrontend/frontendorg/new.html"))
mux.HandleFunc("/api/create", create)
mux.HandleFunc("/api/cmt", cmt)
mux.HandleFunc("/api/njmh", nzom)
mux.HandleFunc("/api/like", like)
mux.HandleFunc("/api/mlamh", mlamh)
mux.HandleFunc("/api/account", account)
mux.HandleFunc("/api/stats", stats)
mux.HandleFunc("/api/version", version)
mux.HandleFunc("/api/backup", backup)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
return
}
mobilepage(r, "cfrontend/frontendorg/madar.html", "cfrontend/frontendorg/madarp.html")(w, r)
})
mux.HandleFunc("/media/app", page("cfrontend/frontendorg/app.js"))
mux.HandleFunc("/media/profile", page("cfrontend/frontendorg/profile.js"))
mux.HandleFunc("/media/i18n", page("cfrontend/frontendorg/i18n.js"))
mux.HandleFunc("/media/login", page("cfrontend/frontendorg/login.js"))
mux.HandleFunc("/media/compress", page("cfrontend/frontendorg/compress.js"))
mux.HandleFunc("/media/", mediaserve)
mux.HandleFunc("/uploads/", uploadserve)
port := os.Getenv("PORT")
if port == "" {
port = "42362"
}
server := &http.Server{
Addr: net.JoinHostPort(os.Getenv("LISTEN_HOST"), port),
Handler: security(catcher(cors(postguard(authmiddleware(bandwidth(compress(mux))))))),
ErrorLog: log.New(io.Discard, "", 0),
ReadTimeout: 120 * time.Second,
ReadHeaderTimeout: 10 * time.Second,
WriteTimeout: 120 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 16 << 10,
}
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
errc := make(chan error, 1)
go func() { errc <- server.ListenAndServe() }()
select {
case err := <-errc:
if err != nil && err != http.ErrServerClosed {
}
case <-sig:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = server.Shutdown(ctx)
<-errc
}
if sqlDB != nil {
_ = sqlDB.Close()
}
}
middleware.go
package main
import (
"compress/gzip"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const csp = "default-src 'self'; " +
"base-uri 'none'; " +
"form-action 'self'; " +
"script-src 'self' 'unsafe-inline'; " +
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
"font-src 'self' https://fonts.gstatic.com; " +
"img-src 'self' data: blob:; " +
"media-src 'self' blob:; " +
"connect-src 'self' https://fonts.googleapis.com https://fonts.gstatic.com; " +
"object-src 'none'; " +
"frame-src 'none'; " +
"worker-src 'self' blob:; " +
"manifest-src 'self'; " +
"frame-ancestors 'none'"
var staticPaths = []string{"/uploads/", "/cfrontend/wbg/", "/cfrontend/fonts/", "/cfrontend/defaultavatar/"}
func isstaticpath(path string) bool {
for _, p := range staticPaths {
if strings.HasPrefix(path, p) {
return true
}
}
return false
}
func nostore(r *http.Request) bool {
if r.Method == http.MethodPost {
return true
}
switch r.URL.Path {
case "/api/backup", "/api/logout":
return true
case "/api/user":
return r.URL.Query().Get("what") == "me"
}
return false
}
func security(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("X-XSS-Protection", "0")
h.Set("Referrer-Policy", "no-referrer")
h.Set("Content-Security-Policy", csp)
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()")
h.Set("Cross-Origin-Opener-Policy", "same-origin")
h.Set("Cross-Origin-Resource-Policy", "same-origin")
h.Set("X-Permitted-Cross-Domain-Policies", "none")
if strings.HasPrefix(r.URL.Path, "/api/") {
h.Set("X-Robots-Tag", "noindex")
}
if nostore(r) {
h.Set("Cache-Control", "no-store")
}
next.ServeHTTP(w, r)
})
}
func catcher(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() { _ = recover() }()
next.ServeHTTP(w, r)
})
}
var writelimits = map[string]int{
"/api/sign": RateSignupPerMin,
"/login": RateSignupPerMin,
"/api/create": 10,
"/api/cmt": 30,
"/api/feed": 60,
"/api/like": 60,
"/api/njmh": 60,
"/api/mlamh": 20,
"/api/user": 30,
"/api/account": 5,
"/api/backup": 5,
}
var ratehits = struct {
sync.Mutex
m map[string][2]int64
}{m: map[string][2]int64{}}
func rateip(r *http.Request) string {
if trustedproxy(r) {
if ip := net.ParseIP(strings.TrimSpace(r.Header.Get("CF-Connecting-IP"))); ip != nil {
return ip.String()
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
if ip := net.ParseIP(host); ip != nil {
return ip.String()
}
return host
}
func sameorigin(r *http.Request) bool {
if strings.EqualFold(r.Header.Get("Sec-Fetch-Site"), "cross-site") {
return false
}
raw := r.Header.Get("Origin")
if raw == "" {
raw = r.Header.Get("Referer")
}
if raw == "" {
return true
}
u, err := url.Parse(raw)
if err != nil || u.Hostname() == "" {
return false
}
return (strings.EqualFold(u.Host, r.Host) && u.Scheme == requestscheme(r)) || frontendOrigin(r) != ""
}
func trustedproxy(r *http.Request) bool {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
ip := net.ParseIP(host)
for _, raw := range strings.Split(os.Getenv("TRUSTED_PROXY_CIDRS"), ",") {
_, network, err := net.ParseCIDR(strings.TrimSpace(raw))
if err == nil && network.Contains(ip) {
return true
}
}
return false
}
func requestscheme(r *http.Request) string {
if r.TLS != nil || (trustedproxy(r) && r.Header.Get("X-Forwarded-Proto") == "https") {
return "https"
}
return "http"
}
func writeonly(path string) bool {
switch path {
case "/api/sign", "/api/logout", "/api/create", "/api/njmh", "/api/like", "/api/mlamh", "/api/account":
return true
}
return false
}
func postguard(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if r.MultipartForm != nil {
_ = r.MultipartForm.RemoveAll()
}
}()
if (writeonly(r.URL.Path) || (r.URL.Path == "/api/feed" && r.URL.Query().Get("col") == "view")) && r.Method != http.MethodPost {
return
}
if r.Method == http.MethodGet || r.Method == http.MethodHead {
next.ServeHTTP(w, r)
return
}
if r.Method != http.MethodPost {
return
}
if !sameorigin(r) {
return
}
limit := int64(MaxRequestSize)
if r.URL.Path == "/api/backup" {
limit = MaxBackupSize
}
r.Body = http.MaxBytesReader(w, r.Body, limit)
permin := writelimits[r.URL.Path]
if permin == 0 {
next.ServeHTTP(w, r)
return
}
now := time.Now().Unix()
key := rateip(r) + "\x00" + r.URL.Path
ratehits.Lock()
slot, seen := ratehits.m[key]
if !seen || now-slot[0] >= 60 {
slot = [2]int64{now, 0}
}
if !seen && len(ratehits.m) >= 8192 {
for k, v := range ratehits.m {
if now-v[0] >= 60 {
delete(ratehits.m, k)
}
}
if len(ratehits.m) >= 8192 {
ratehits.Unlock()
return
}
}
slot[1]++
ratehits.m[key] = slot
over := slot[1] > int64(permin)
if len(ratehits.m) > 8192 {
for k, v := range ratehits.m {
if now-v[0] >= 60 {
delete(ratehits.m, k)
}
}
}
ratehits.Unlock()
if over {
return
}
next.ServeHTTP(w, r)
})
}
var gzPool = sync.Pool{New: func() any {
w, _ := gzip.NewWriterLevel(io.Discard, 5)
return w
}}
var incompressible = map[string]bool{
".webp": true, ".png": true, ".jpg": true, ".jpeg": true, ".gif": true,
".ico": true, ".woff": true, ".woff2": true, ".ttf": true,
".mp4": true, ".webm": true, ".zip": true, ".gz": true, ".br": true,
}
func compress(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead ||
!strings.HasPrefix(r.URL.Path, "/api/") ||
incompressible[strings.ToLower(filepath.Ext(r.URL.Path))] ||
encodingquality(r.Header.Get("Accept-Encoding"), "gzip") <= 0 {
next.ServeHTTP(w, r)
return
}
gz := gzPool.Get().(*gzip.Writer)
gz.Reset(w)
w.Header().Add("Vary", "Accept-Encoding")
w.Header().Set("Content-Encoding", "gzip")
w.Header().Del("Content-Length")
cw := &compRW{ResponseWriter: w, gz: gz}
defer cw.close()
next.ServeHTTP(cw, r)
})
}
type compRW struct {
http.ResponseWriter
gz *gzip.Writer
wrote bool
}
func (c *compRW) WriteHeader(code int) {
if !c.wrote {
c.wrote = true
c.ResponseWriter.WriteHeader(code)
}
}
func (c *compRW) Write(p []byte) (int, error) {
if !c.wrote {
c.WriteHeader(http.StatusOK)
}
return c.gz.Write(p)
}
func (c *compRW) close() {
if c.gz != nil {
_ = c.gz.Close()
gzPool.Put(c.gz)
c.gz = nil
}
}
mlamhchunk.go
package main
import (
"crypto/sha256"
"fmt"
"image"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
const (
mlamhPartsMaxAge = 30 * time.Minute
mlamhMaxChunks = 512
)
func mlamhpartsdir() string {
return filepath.Join(os.TempDir(), "wisal-mlamh-parts")
}
func slotsize(dir string) int64 {
entries, err := os.ReadDir(dir)
if err != nil {
return 0
}
var total int64
for _, e := range entries {
if info, err := e.Info(); err == nil {
total += info.Size()
}
}
return total
}
func mlamhpartid(s string) bool {
if len(s) != 32 {
return false
}
for _, c := range s {
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9') {
return false
}
}
return true
}
func saveFromFile(f *os.File, filename string, maxBytes int64, dims [2]int, video bool) stored {
ext := strings.ToLower(filepath.Ext(filename))
if !mediaExts[ext] {
return stored{}
}
if storagefull() {
return stored{}
}
info, err := f.Stat()
if err != nil || info.Size() > maxBytes {
return stored{}
}
head := make([]byte, 512)
n, _ := f.ReadAt(head, 0)
ctype := http.DetectContentType(head[:n])
if _, err := f.Seek(0, io.SeekStart); err != nil {
return stored{}
}
isImage := strings.HasPrefix(ctype, "image/")
if !isImage && !(video && strings.HasPrefix(ctype, "video/")) {
return stored{}
}
limit := maxBytes
if isImage && limit > MaxImageSize {
limit = MaxImageSize
}
if !isImage && limit > MaxVideoSize {
limit = MaxVideoSize
}
if info.Size() > limit {
return stored{}
}
id := randomid()
if id == "" {
return stored{}
}
name := id
if !isImage {
return savevideo(f, name+ext, limit, dims)
}
cfg, _, err := image.DecodeConfig(f)
if err != nil {
return stored{}
}
if _, err := f.Seek(0, io.SeekStart); err != nil {
return stored{}
}
if dimsover(cfg.Width, cfg.Height, dims[0], dims[1]) {
return stored{}
}
if ctype == "image/webp" {
path := filepath.Join(uploadDir, name+".webp")
if err := copycapped(path, f, limit); err != nil {
return stored{}
}
return stored{"uploads/" + name + ".webp", cfg.Width, cfg.Height}
}
img, _, err := image.Decode(f)
if err != nil {
return stored{}
}
if ctype == "image/gif" {
if _, err := f.Seek(0, io.SeekStart); err != nil {
return stored{}
}
path := filepath.Join(uploadDir, name+".gif")
if err := copycapped(path, f, limit); err != nil {
return stored{}
}
return stored{"uploads/" + name + ".gif", cfg.Width, cfg.Height}
}
return savewebp(img, name+".webp", limit, cfg.Width, cfg.Height)
}
var chunkMu sync.Mutex
func chunkscope(userid, uploadid string) string {
return fmt.Sprintf("%x", sha256.Sum256([]byte(userid+"\x00"+uploadid)))
}
func mlamhchunk(w http.ResponseWriter, r *http.Request) {
u, ok := currentuser(r)
if !ok {
return
}
r.Body = http.MaxBytesReader(w, r.Body, MaxRequestSize)
if r.ParseMultipartForm(MaxUploadRAM) != nil {
return
}
chunkMu.Lock()
defer chunkMu.Unlock()
cleanupmlamhparts()
upid := strings.TrimSpace(r.FormValue("upid"))
name := filepath.Base(strings.TrimSpace(r.FormValue("mname")))
idx, err := strconv.Atoi(strings.TrimSpace(r.FormValue("mchunk")))
total, err2 := strconv.Atoi(strings.TrimSpace(r.FormValue("mchunks")))
if !mlamhpartid(upid) || name == "" || err != nil || err2 != nil ||
total < 1 || total > mlamhMaxChunks || idx < 0 || idx >= total {
return
}
files := r.MultipartForm.File["mdata"]
if len(files) == 0 {
return
}
if files[0].Size > MaxMlamhMediaSize {
return
}
dir := mlamhpartsdir()
if err := os.MkdirAll(dir, 0700); err != nil {
return
}
scope := chunkscope(u.Userid, upid)
slot := filepath.Join(dir, scope)
if err := os.MkdirAll(slot, 0700); err != nil {
return
}
chunk := filepath.Join(slot, strconv.Itoa(idx))
if _, err := os.Stat(chunk); err != nil {
src, err := files[0].Open()
if err != nil {
return
}
out, err := os.Create(chunk)
if err != nil {
src.Close()
return
}
_, cErr := io.Copy(out, io.LimitReader(src, MaxMlamhMediaSize+1))
cErr2 := out.Close()
src.Close()
if cErr != nil || cErr2 != nil {
os.Remove(chunk)
return
}
}
if slotsize(slot) > MaxMlamhMediaSize {
os.RemoveAll(slot)
return
}
var got int
if entries, err := os.ReadDir(slot); err == nil {
got = len(entries)
}
if idx != total-1 || got != total {
reply(w, map[string]any{"done": got, "total": total, "stored": false})
return
}
final := filepath.Join(dir, scope+".bin")
dst, err := os.Create(final)
if err != nil {
return
}
complete := true
for i := 0; i < total; i++ {
f, err := os.Open(filepath.Join(slot, strconv.Itoa(i)))
if err != nil {
complete = false
break
}
_, cErr := io.Copy(dst, f)
f.Close()
if cErr != nil {
complete = false
break
}
}
cErr := dst.Close()
os.RemoveAll(slot)
if !complete || cErr != nil {
os.Remove(final)
return
}
if tw := touint(r.FormValue("thumbw")); tw != 0 {
base := filepath.Base(strings.TrimSpace(r.FormValue("thumbbase")))
if !mlamhthumballowed(tw) || !mlamhthumbbase(u.Userid, base) || !saveclientthumbfile(final, base, int(tw)) {
os.Remove(final)
return
}
os.Remove(final)
reply(w, map[string]string{"path": "uploads/" + base})
return
}
f, err := os.Open(final)
if err != nil {
os.Remove(final)
return
}
s := saveFromFile(f, name, MaxMlamhMediaSize, [2]int{MaxMediaW, MaxMediaH}, true)
f.Close()
os.Remove(final)
if !s.ok() {
return
}
mlamhPending.Lock()
if old, ok := mlamhPending.m[u.Userid]; ok && old != "" && old != s.path {
fresh, err := queries.GetUserByUserID(r.Context(), u.Userid)
if err != nil || !strings.Contains(text(fresh.Mlamh), old) {
if info, err := os.Stat(filepath.Join(uploadDir, filepath.Base(old))); err != nil || time.Since(info.ModTime()) > 10*time.Minute {
removeupload(old)
}
}
}
mlamhPending.m[u.Userid] = s.path
mlamhPending.Unlock()
reply(w, map[string]string{"path": s.path})
}
func mlamhthumballowed(w uint) bool {
for _, a := range thumbWidths {
if w == uint(a) {
return true
}
}
return false
}
func mlamhthumbbase(userid, base string) bool {
if base == "" || strings.ContainsAny(base, "/\\") {
return false
}
mlamhPending.Lock()
pending := mlamhPending.m[userid]
mlamhPending.Unlock()
return pending == "uploads/"+base
}
func saveclientthumbfile(source, base string, w int) bool {
f, err := os.Open(source)
if err != nil {
return false
}
defer f.Close()
info, err := f.Stat()
if err != nil || info.Size() > clientThumbLimit {
return false
}
head := make([]byte, 512)
n, _ := f.ReadAt(head, 0)
if http.DetectContentType(head[:n]) != "image/webp" {
return false
}
if _, err := f.Seek(0, io.SeekStart); err != nil {
return false
}
stem := strings.TrimSuffix(base, filepath.Ext(base))
return copycapped(filepath.Join(uploadDir, thumbsuffix(stem, w)), f, clientThumbLimit) == nil
}
func cleanupmlamhparts() {
dir := mlamhpartsdir()
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
info, err := e.Info()
if err != nil {
continue
}
if time.Since(info.ModTime()) > mlamhPartsMaxAge {
os.RemoveAll(filepath.Join(dir, e.Name()))
}
}
}
museum.go
package main
import (
"context"
"database/sql"
"os"
"regexp"
"strings"
"time"
"wisal/backend/db"
_ "github.com/jackc/pgx/v5/stdlib"
)
var (
queries *db.Queries
sqlDB *sql.DB
)
func dburl() string {
if v := strings.TrimSpace(os.Getenv("DATABASE_URL")); v != "" {
return v
}
return "postgres:///wisal?host=/var/run/postgresql&sslmode=disable"
}
func opendb() bool {
if uploadDir = prepareuploads(); uploadDir == "" {
return false
}
var err error
sqlDB, err = sql.Open("pgx", dburl())
if err != nil {
return false
}
sqlDB.SetMaxOpenConns(10)
sqlDB.SetMaxIdleConns(5)
sqlDB.SetConnMaxLifetime(30 * time.Minute)
sqlDB.SetConnMaxIdleTime(5 * time.Minute)
pingCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := sqlDB.PingContext(pingCtx); err != nil {
return false
}
for _, stmt := range schemaDDL {
if _, err := sqlDB.Exec(stmt); err != nil {
return false
}
}
queries = db.New(sqlDB)
return true
}
var schemaDDL = []string{
`CREATE TABLE IF NOT EXISTS users (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
displayname TEXT,
userid TEXT UNIQUE NOT NULL,
namecolor TEXT DEFAULT '#ffffff',
bio TEXT,
biocolor TEXT DEFAULT '#ffffff',
jointext TEXT DEFAULT 'since',
joincolor TEXT DEFAULT '#ffffff',
avatar TEXT,
banner TEXT,
profilebg TEXT,
tab1name TEXT DEFAULT 'منشوراتي',
tab2name TEXT DEFAULT 'حبييييت <3',
editprofiletext TEXT DEFAULT 'تخصيص',
editprofilecolor TEXT DEFAULT '#ffffff',
tabcolor TEXT DEFAULT '#ffffff',
postcolor TEXT DEFAULT '#ffffff',
mlamh TEXT,
support TEXT,
supportlinks TEXT,
tms BOOLEAN DEFAULT FALSE,
dob TEXT,
createtime TEXT,
lastpost TEXT,
lastseen TEXT
)`,
`CREATE TABLE IF NOT EXISTS posts (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
username TEXT NOT NULL,
content TEXT,
image TEXT,
mediasize TEXT,
starco BIGINT DEFAULT 0,
likeco BIGINT DEFAULT 0,
viewsco BIGINT DEFAULT 0,
commentco BIGINT DEFAULT 0,
supporturl TEXT,
createtime TEXT
)`,
`CREATE TABLE IF NOT EXISTS stars (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
username TEXT NOT NULL,
postid BIGINT NOT NULL,
UNIQUE(username, postid)
)`,
`CREATE TABLE IF NOT EXISTS likes (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
username TEXT NOT NULL,
postid BIGINT NOT NULL,
UNIQUE(username, postid)
)`,
`CREATE TABLE IF NOT EXISTS comments (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
postid BIGINT NOT NULL,
username TEXT NOT NULL,
content TEXT,
createtime TEXT
)`,
`CREATE TABLE IF NOT EXISTS post_views (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
post_id BIGINT NOT NULL,
user_id TEXT NOT NULL,
UNIQUE(post_id, user_id)
)`,
`CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
userid TEXT NOT NULL,
expires BIGINT NOT NULL,
created BIGINT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_posts_user_time ON posts(username, createtime DESC)`,
`CREATE INDEX IF NOT EXISTS idx_posts_time ON posts(createtime DESC)`,
`CREATE INDEX IF NOT EXISTS idx_posts_trending ON posts(starco DESC)`,
`CREATE INDEX IF NOT EXISTS idx_stars_postid ON stars(postid)`,
`CREATE INDEX IF NOT EXISTS idx_likes_postid ON likes(postid)`,
`CREATE INDEX IF NOT EXISTS idx_likes_user_time ON likes(username, id DESC)`,
`CREATE INDEX IF NOT EXISTS idx_comments_postid ON comments(postid)`,
`CREATE INDEX IF NOT EXISTS idx_comments_post_time ON comments(postid, createtime DESC)`,
`CREATE INDEX IF NOT EXISTS idx_views_postid ON post_views(post_id)`,
`CREATE INDEX IF NOT EXISTS idx_users_time ON users(createtime DESC)`,
`CREATE INDEX IF NOT EXISTS idx_users_lastseen ON users(lastseen)`,
`CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(userid)`,
}
func purgeorphans(db *sql.DB) {
for _, stmt := range []string{
`DELETE FROM post_views WHERE post_id NOT IN (SELECT id FROM posts)`,
`DELETE FROM post_views WHERE user_id NOT IN (SELECT userid FROM users)`,
} {
_, _ = db.Exec(stmt)
}
}
var hexColorRE = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
police.go
package main
import (
"database/sql"
"regexp"
"wisal/backend/db"
)
var (
uploadDir string
hexColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
)
const (
MaxImageSize = 25 << 20
MaxVideoSize = 35 << 20
MaxAvatarSize = 5 << 20
MaxBannerSize = 15 << 20
MaxBgSize = 40 << 20
MaxRequestSize = 300 << 20
MaxUploadRAM = 32 << 20
MaxMediaW = 3840
MaxMediaH = 2160
MaxBannerW = 2560
MaxBannerH = 1440
MaxBgW = 3840
MaxBgH = 2160
MaxAvatarW = 2048
MaxAvatarH = 2048
MaxFormSize = 1 << 20
MaxPostRunes = 100000
MaxCommentRunes = 5000
MaxMlamhBytes = 500000
MaxMediaFiles = 8
MaxUsernameRunes = 32
MinUsernameRunes = 3
MaxTextFieldRunes = 100
MaxDOBRunes = 20
MaxBackupSize = 600 << 20
MaxMlamhMediaSize = 15 << 20
MaxFreeFloor = 1 << 30
RateSignupPerMin = 10
RateWritePerMin = 60
)
var profileDims = map[string][2]int{
"avatar": {MaxAvatarW, MaxAvatarH},
"banner": {MaxBannerW, MaxBannerH},
"profilebg": {MaxBgW, MaxBgH},
}
var textLimits = map[string]int{
"bio": 5000, "jointext": 50, "tab1name": 50,
"tab2name": 50, "editprofiletext": MaxTextFieldRunes,
"displayname": 100,
}
var colorFields = []string{"namecolor", "biocolor", "joincolor", "editprofilecolor", "tabcolor", "postcolor"}
type uploadSlot struct {
field, prefix string
maxBytes int64
video bool
old sql.NullString
}
func profileUploads(u db.User) []uploadSlot {
return []uploadSlot{
{"avatar", "avatar", MaxAvatarSize, false, u.Avatar},
{"banner", "banner", MaxBannerSize, false, u.Banner},
{"profilebg", "profilebg", MaxBgSize, true, u.Profilebg},
}
}
profile.go
package main
import (
"context"
"encoding/json"
"math"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
"wisal/backend/db"
)
var mlamhPending = struct {
sync.Mutex
m map[string]string
}{m: map[string]string{}}
func saveprofile(w http.ResponseWriter, r *http.Request) {
currentuser, ok := currentuser(r)
if !ok {
return
}
if _, ok := readbody(w, r, MaxRequestSize); !ok {
return
}
values := map[string]string{}
for field, limit := range textLimits {
if value := strings.TrimSpace(r.FormValue(field)); value != "" && utf8.RuneCountInString(value) <= limit {
values[field] = value
}
}
for _, field := range colorFields {
if value := strings.TrimSpace(r.FormValue(field)); iscolor(value) {
values[field] = value
}
}
for _, u := range profileUploads(currentuser) {
if r.MultipartForm == nil {
break
}
if headers := r.MultipartForm.File[u.field]; len(headers) > 0 {
hdr := headers[0]
if path := storeprofile(hdr, u.prefix, u.maxBytes, u.video); path != "" {
storeclientthumbs(r, u.field, path)
removeupload(text(u.old))
values[u.field] = path
}
}
}
var links []string
for i := 0; i < 10; i++ {
if link := strings.TrimSpace(r.FormValue("support" + strconv.Itoa(i))); isweburl(link) {
links = append(links, link)
}
}
if len(links) > 0 {
if data, err := json.Marshal(links); err == nil {
v := string(data)
values["supportlinks"] = v
}
} else if r.FormValue("clear_support") == "1" {
empty := ""
values["supportlinks"] = empty
}
_ = savefields(r.Context(), currentuser.ID, values)
if _, changed := values["avatar"]; changed {
cmtclearall()
}
done(w)
}
func stats(w http.ResponseWriter, r *http.Request) {
total, _ := queries.CountUsers(r.Context())
active, _ := queries.CountActiveUsers(r.Context(), tostamp(time.Now().UTC().Add(-24*time.Hour)))
posts, _ := queries.CountPosts(r.Context())
reply(w, map[string]interface{}{"total": total, "active_24h": active, "posts": posts})
}
func side(w http.ResponseWriter, r *http.Request) {
total, _ := queries.CountUsers(r.Context())
posts, _ := queries.CountPosts(r.Context())
newestRows, newestErr := queries.ListSideUsers(r.Context(), 30)
newest := tolist(newestRows, newestErr, newestside)
oldestRows, oldestErr := queries.ListSideUsersAsc(r.Context(), 5)
oldest := tolist(oldestRows, oldestErr, oldestside)
var newestOne sideUser
if len(newest) > 0 {
newestOne = newest[0]
}
resp := map[string]interface{}{
"total": total, "posts": posts, "newest": newest, "oldest": oldest, "newest_one": newestOne,
}
if strings.TrimSpace(r.FormValue("all")) == "1" {
allRows, allErr := queries.ListAllSideUsers(r.Context())
resp["all"] = tolist(allRows, allErr, allside)
}
reply(w, resp)
}
func mlamhup(w http.ResponseWriter, r *http.Request) {
u, ok := currentuser(r)
if !ok {
return
}
r.Body = http.MaxBytesReader(w, r.Body, MaxRequestSize)
if r.ParseMultipartForm(MaxUploadRAM) != nil {
return
}
files := r.MultipartForm.File["media"]
if len(files) == 0 {
return
}
path, _, _ := storemlamh(files[0])
if path == "" {
return
}
storeclientthumbs(r, "mlamh", path)
mlamhPending.Lock()
if old, ok := mlamhPending.m[u.Userid]; ok && old != "" && old != path {
fresh, err := queries.GetUserByUserID(r.Context(), u.Userid)
if err != nil || !strings.Contains(text(fresh.Mlamh), old) {
if info, err := os.Stat(filepath.Join(uploadDir, filepath.Base(old))); err != nil || time.Since(info.ModTime()) > 10*time.Minute {
removeupload(old)
}
}
}
mlamhPending.m[u.Userid] = path
mlamhPending.Unlock()
reply(w, map[string]string{"path": path})
}
func savefields(ctx context.Context, id int64, values map[string]string) error {
if len(values) == 0 {
return nil
}
sets := make([]string, 0, len(values))
args := make([]any, 0, len(values)+1)
for field, value := range values {
if !allowedProfileFields[field] {
continue
}
sets = append(sets, field+" = $"+strconv.Itoa(len(args)+1))
args = append(args, value)
}
if len(sets) == 0 {
return nil
}
args = append(args, id)
_, err := sqlDB.ExecContext(ctx,
"UPDATE users SET "+strings.Join(sets, ", ")+" WHERE id = $"+strconv.Itoa(len(args)),
args...,
)
return err
}
var allowedProfileFields = map[string]bool{
"displayname": true, "bio": true,
"namecolor": true, "biocolor": true, "joincolor": true,
"jointext": true, "tab1name": true, "tab2name": true,
"editprofiletext": true, "editprofilecolor": true,
"tabcolor": true, "postcolor": true,
"avatar": true, "banner": true, "profilebg": true,
"supportlinks": true,
}
func savemlamh(w http.ResponseWriter, r *http.Request) {
currentuser, ok := currentuser(r)
if !ok {
return
}
r.Body = http.MaxBytesReader(w, r.Body, MaxFormSize)
data := strings.TrimSpace(r.FormValue("layout_data"))
if data == "" {
empty := ""
_ = queries.UpdateUserMlamh(r.Context(), db.UpdateUserMlamhParams{Mlamh: nstr(empty),
ID: currentuser.ID})
done(w)
return
}
if len(data) > MaxMlamhBytes {
return
}
if problem := mlamhproblem(data); problem != "" {
return
}
v := data
_ = queries.UpdateUserMlamh(r.Context(), db.UpdateUserMlamhParams{Mlamh: nstr(v),
ID: currentuser.ID})
done(w)
}
var (
mlamhWidgets = map[string]bool{"": true, "avatar": true, "banner": true, "display_name": true, "bio": true, "custom_text": true, "joined": true, "links": true, "posts": true, "likes": true}
mlamhMedia = map[string]bool{"": true, "image": true, "video": true}
mlamhBG = regexp.MustCompile(`^(#[0-9a-fA-F]{3,8}|[a-zA-Z]+|rgba?\([^()]*\)|hsla?\([^()]*\))$`)
mlamhBadMedia = regexp.MustCompile(`[\\\s"'<>]`)
)
type mlamhCell struct {
X, Y, W, H, Rotation float64
Bg string
Media string
MediaType string
Widget string
TextColor string
FontSize float64
CropW, CropH float64
ZIndex float64
CustomText string
}
type mlamhLayout struct {
Cols, Rows float64
Cells []mlamhCell
}
func finite(v float64) bool { return !math.IsNaN(v) && !math.IsInf(v, 0) }
func mlamhproblem(data string) string {
var layout mlamhLayout
if json.Unmarshal([]byte(data), &layout) != nil {
return "json"
}
if !finite(layout.Cols) || !finite(layout.Rows) || layout.Cols < 1 || layout.Cols > 64 || layout.Rows < 1 || layout.Rows > 64 {
return "grid"
}
if layout.Cells == nil || len(layout.Cells) > 2048 {
return "cells"
}
for _, c := range layout.Cells {
for _, v := range []float64{c.X, c.Y, c.W, c.H, c.Rotation, c.FontSize, c.CropW, c.CropH, c.ZIndex} {
if !finite(v) || v < -100000 || v > 100000 {
return "numbers"
}
}
if c.Bg != "" && (!mlamhBG.MatchString(c.Bg) || strings.ContainsAny(c.Bg, ";!")) {
return "bg"
}
if c.Media != "" && !validmlamhmedia(c.Media) {
return "media"
}
if !mlamhMedia[c.MediaType] || !mlamhWidgets[c.Widget] {
return "kind"
}
if !iscolor(c.TextColor) && c.TextColor != "" {
return "textcolor"
}
if utf8.RuneCountInString(c.CustomText) > 20000 {
return "customtext"
}
}
return ""
}
func validmlamhmedia(m string) bool {
if len(m) > 200 || strings.HasPrefix(m, "//") || strings.Contains(m, "..") || mlamhBadMedia.MatchString(m) {
return false
}
switch strings.ToLower(m[strings.LastIndex(m, ".")+1:]) {
case "jpg", "jpeg", "png", "gif", "mp4", "webp":
default:
return false
}
return strings.HasPrefix(m, "uploads/") || strings.HasPrefix(m, "/uploads/")
}
routes.go
package main
import (
"net/http"
"strings"
)
func mobilepage(r *http.Request, desktop, phone string) http.HandlerFunc {
selected := desktop
if ismobile(r) {
selected = phone
}
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Vary", "User-Agent")
page(selected)(w, r)
}
}
func page(file string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
servefile(w, r, file)
}
}
func pageauth(file string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if _, ok := currentuser(r); !ok {
w.Header().Set("Cache-Control", "no-store")
mobilepage(r, "cfrontend/frontendorg/login.html", "cfrontend/frontendorg/loginp.html")(w, r)
return
}
w.Header().Set("Cache-Control", "no-store")
servefile(w, r, file)
}
}
var buildTime = "dev"
func version(w http.ResponseWriter, r *http.Request) {
reply(w, map[string]string{"build": buildTime})
}
func feed(w http.ResponseWriter, r *http.Request) {
switch r.FormValue("col") {
case "poll":
pollfeed(w, r)
case "njom":
njom(w, r)
case "side":
side(w, r)
case "view":
view(w, r)
default:
madar(w, r)
}
}
func backup(w http.ResponseWriter, r *http.Request) {
switch r.FormValue("what") {
case "info":
backupinfo(w, r)
case "restore":
if r.Method != http.MethodPost {
return
}
uploadbackup(w, r)
default:
downloadbackup(w, r)
}
}
func user(w http.ResponseWriter, r *http.Request) {
switch r.FormValue("what") {
case "me":
myprofile(w, r)
case "posts":
userposts(w, r)
case "liked":
userliked(w, r)
case "save":
if r.Method != http.MethodPost {
return
}
saveprofile(w, r)
default:
if r.Method == http.MethodPost {
saveprofile(w, r)
} else {
profile(w, r)
}
}
}
func cmt(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
addcomment(w, r)
return
}
comments(w, r)
}
func mlamh(w http.ResponseWriter, r *http.Request) {
if _, ok := currentuser(r); !ok {
return
}
if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/") {
if err := r.ParseMultipartForm(MaxUploadRAM); err != nil {
return
}
}
if r.MultipartForm != nil && len(r.MultipartForm.File["media"]) > 0 {
mlamhup(w, r)
return
}
if strings.TrimSpace(r.FormValue("upid")) != "" {
mlamhchunk(w, r)
return
}
savemlamh(w, r)
}
func account(w http.ResponseWriter, r *http.Request) {
switch r.FormValue("what") {
case "dob":
savedob(w, r)
default:
delaccount(w, r)
}
}
seo.go
package main
import (
"net"
"net/http"
"net/url"
"os"
"regexp"
"strings"
)
var safehost = regexp.MustCompile(`^[A-Za-z0-9.-]+$`)
func baseurl(r *http.Request) string {
if origin := os.Getenv("SITE_ORIGIN"); origin != "" {
if u, err := url.Parse(origin); err == nil && u.Scheme == "https" && u.Host != "" && u.User == nil && u.Path == "" && u.RawQuery == "" && u.Fragment == "" {
return origin
}
}
host := r.Host
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
if !safehost.MatchString(host) {
host = "wisal.fly.dev"
}
return "https://" + host
}
func robots(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
base := baseurl(r)
_, _ = w.Write([]byte("User-agent: *\nAllow: /\nSitemap: " + base + "/sitemap.xml\n"))
}
func sitemap(w http.ResponseWriter, r *http.Request) {
base := baseurl(r)
var b strings.Builder
b.WriteString("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n")
pages := []struct{ loc, changefreq, priority string }{
{"/madar", "daily", "1.0"},
{"/users", "daily", "0.5"},
{"/login", "monthly", "0.3"},
{"/rules", "monthly", "0.3"},
{"/privacy", "monthly", "0.3"},
{"/source", "weekly", "0.4"},
}
for _, p := range pages {
b.WriteString(" <url><loc>" + base + p.loc + "</loc><changefreq>" + p.changefreq + "</changefreq><priority>" + p.priority + "</priority></url>\n")
}
if rows, err := queries.ListSeoUsers(r.Context()); err == nil {
for _, u := range rows {
if strings.TrimSpace(u.Username) == "" {
continue
}
b.WriteString(" <url><loc>" + base + "/profile?username=" + url.QueryEscape(u.Username) + "</loc><lastmod>" + totime(u.Createtime).Format("2006-01-02") + "</lastmod><changefreq>weekly</changefreq><priority>0.6</priority></url>\n")
}
}
b.WriteString("</urlset>")
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
_, _ = w.Write([]byte(b.String()))
}
session.go
package main
import (
"context"
"database/sql"
"net/http"
"strings"
"time"
"wisal/backend/db"
)
const (
sessionTTL = 30 * 24 * time.Hour
sessionCookie = "session"
)
type ctxkey int
const userkey ctxkey = 0
func newsession(ctx context.Context, userid string) (string, error) {
token := randomid()
if token == "" {
return "", sql.ErrNoRows
}
now := time.Now().Unix()
_, _ = sqlDB.ExecContext(ctx, `DELETE FROM sessions WHERE expires <= $1`, now)
if _, err := sqlDB.ExecContext(ctx,
`INSERT INTO sessions (token, userid, expires, created) VALUES ($1, $2, $3, $4)`,
token, userid, now+int64(sessionTTL.Seconds()), now); err != nil {
return "", err
}
return token, nil
}
func deletesession(ctx context.Context, token string) {
if token == "" {
return
}
_, _ = sqlDB.ExecContext(ctx, `DELETE FROM sessions WHERE token = $1`, token)
}
func deleteusersessions(ctx context.Context, userid string) {
if userid == "" {
return
}
_, _ = sqlDB.ExecContext(ctx, `DELETE FROM sessions WHERE userid = $1`, userid)
}
func sessionuser(ctx context.Context, token string) (db.User, bool) {
if len(token) != 32 {
return db.User{}, false
}
u, err := queries.GetSessionUser(ctx, db.GetSessionUserParams{Token: token, Expires: time.Now().Unix()})
if err != nil || u.ID == 0 {
return db.User{}, false
}
return u, true
}
func isasset(path string) bool {
return strings.HasPrefix(path, "/cfrontend/") ||
strings.HasPrefix(path, "/media/") ||
strings.HasPrefix(path, "/uploads/") ||
path == "/favicon.png" ||
path == "/robots.txt" ||
path == "/sitemap.xml"
}
func authmiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := sessionid(r)
if token == "" || isasset(r.URL.Path) || (!strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/create" && r.URL.Path != "/mlamh") {
next.ServeHTTP(w, r)
return
}
u, ok := sessionuser(r.Context(), token)
if !ok {
next.ServeHTTP(w, r)
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userkey, u)))
})
}
source.go
package main
import (
"html"
"net/http"
"os"
"strings"
)
var sourceDirs = []string{"backend", "cfrontend/frontendorg"}
type sourceFile struct{ Name, Path string }
func sourcelist() []sourceFile {
var out []sourceFile
for _, dir := range sourceDirs {
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") &&
!strings.HasSuffix(e.Name(), ".js") &&
!strings.HasSuffix(e.Name(), ".html") &&
!strings.HasSuffix(e.Name(), ".css") {
continue
}
out = append(out, sourceFile{e.Name(), dir + "/" + e.Name()})
}
}
for _, extra := range []string{"Dockerfile", "fly.toml", "go.mod"} {
if _, err := os.Stat(extra); err == nil {
out = append(out, sourceFile{extra, extra})
}
}
return out
}
func source(w http.ResponseWriter, r *http.Request) {
type block struct{ Name, Code string }
var blocks []block
for _, f := range sourcelist() {
if data, err := os.ReadFile(f.Path); err == nil {
blocks = append(blocks, block{f.Name, string(data)})
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
var b strings.Builder
b.WriteString(`<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>وصال · الشيفرة</title><style>body{background:#000;color:#d4d4d4;font-family:monospace;margin:0;padding:24px;line-height:1.5}h1{color:#fff;font-size:16px;letter-spacing:2px}h2{color:#ff595e;font-size:14px;margin:36px 0 8px;border-bottom:1px solid #333;padding-bottom:4px}pre{border:1px solid #222;background:#0a0a0a;padding:14px;border-radius:6px;overflow-x:auto;white-space:pre;font-size:13px;line-height:1.5}a{color:#7aa2ff;text-decoration:none}.top{margin-bottom:18px}.k{color:#569cd6}.c{color:#6a9955}.s{color:#ce9178}.n{color:#b5cea8}.p{color:#d4d4d4}</style></head><body><div class="top"><a href="/madar">← back</a> | <span>source code</span></div>`)
for _, blk := range blocks {
b.WriteString("<h2>" + html.EscapeString(blk.Name) + "</h2><pre><code>" + highlight(html.EscapeString(blk.Code)) + "</code></pre>")
}
b.WriteString(`</body></html>`)
_, _ = w.Write([]byte(b.String()))
}
func highlight(code string) string {
var b strings.Builder
i, n, inStr, inCom := 0, len(code), false, false
for i < n {
if inStr {
b.WriteByte(code[i])
if code[i] == '\\' && i+1 < n {
i++
b.WriteByte(code[i])
} else if code[i] == '"' {
b.WriteString("</span>")
inStr = false
}
i++
continue
}
if inCom {
if strings.HasPrefix(code[i:], "*/") {
b.WriteString("*/</span>")
inCom = false
i += 2
continue
}
b.WriteByte(code[i])
i++
continue
}
if code[i] == '"' {
b.WriteString("<span class=\"s\">"")
inStr = true
i++
continue
}
if strings.HasPrefix(code[i:], "//") {
b.WriteString("<span class=\"c\">//")
for i < n && code[i] != '\n' {
b.WriteByte(code[i])
i++
}
b.WriteString("</span>")
continue
}
if strings.HasPrefix(code[i:], "/*") {
b.WriteString("<span class=\"c\">/*")
inCom = true
i += 2
continue
}
if iskeyword(code, i) {
b.WriteString("<span class=\"k\">")
for i < n && isident(code[i]) {
b.WriteByte(code[i])
i++
}
b.WriteString("</span>")
continue
}
b.WriteByte(code[i])
i++
}
return b.String()
}
func iskeyword(code string, i int) bool {
if i > 0 && isident(code[i-1]) {
return false
}
for _, kw := range []string{"func", "return", "if", "else", "for", "var", "const", "type", "struct", "map", "error", "nil", "true", "false", "package", "import", "go", "make", "len", "range", "switch", "case", "default", "defer", "string", "int"} {
if strings.HasPrefix(code[i:], kw) && (i+len(kw) >= len(code) || !isident(code[i+len(kw)])) {
return true
}
}
return false
}
func isident(c byte) bool {
return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
}
static.go
package main
import (
"mime"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
)
var precompressed = map[string]bool{".html": true, ".js": true, ".css": true, ".svg": true, ".json": true}
func cachefor(path string) string {
switch strings.ToLower(filepath.Ext(path)) {
case ".html", ".js", ".css":
return "public, no-cache"
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".svg",
".woff", ".woff2", ".ttf", ".mp4", ".webm":
return "public, max-age=31536000, immutable"
}
return ""
}
func encodingquality(header, encoding string) float64 {
fallback := 0.0
for _, field := range strings.Split(strings.ToLower(header), ",") {
parts := strings.Split(field, ";")
name := strings.TrimSpace(parts[0])
q := 1.0
for _, param := range parts[1:] {
if strings.HasPrefix(strings.TrimSpace(param), "q=") {
value, err := strconv.ParseFloat(strings.TrimPrefix(strings.TrimSpace(param), "q="), 64)
if err != nil || value < 0 || value > 1 {
q = 0
} else {
q = value
}
}
}
if name == encoding {
return q
}
if name == "*" {
fallback = q
}
}
return fallback
}
func pickencoding(r *http.Request) string {
best, quality := "", 0.0
for _, encoding := range []string{"br", "gzip"} {
if q := encodingquality(r.Header.Get("Accept-Encoding"), encoding); q > quality {
best, quality = encoding, q
}
}
return best
}
func servefile(w http.ResponseWriter, r *http.Request, file string) {
if info, err := os.Stat(file); err != nil || info.IsDir() {
return
}
ext := strings.ToLower(filepath.Ext(file))
switch ext {
case ".html":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
case ".css":
w.Header().Set("Content-Type", "text/css; charset=utf-8")
case ".js":
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
case ".svg":
w.Header().Set("Content-Type", "image/svg+xml")
case ".json":
w.Header().Set("Content-Type", "application/json; charset=utf-8")
case ".woff2":
w.Header().Set("Content-Type", "font/woff2")
case ".woff":
w.Header().Set("Content-Type", "font/woff")
}
if w.Header().Get("Cache-Control") == "" {
if cache := cachefor(file); cache != "" {
w.Header().Set("Cache-Control", cache)
}
}
if precompressed[ext] && !strings.HasPrefix(file, uploadDir+"/") {
w.Header().Add("Vary", "Accept-Encoding")
if enc := pickencoding(r); enc != "" {
suffix := ".br"
dir := "frontendbr"
if enc == "gzip" {
suffix = ".gz"
dir = "frontendgz"
}
packed := file + suffix
if strings.Contains(packed, "/frontendorg/") {
packed = strings.Replace(packed, "/frontendorg/", "/"+dir+"/", 1)
}
if info, err := os.Stat(packed); err == nil && !info.IsDir() && packedfresh(file, info) {
if w.Header().Get("Content-Type") == "" {
if t := mime.TypeByExtension(ext); t != "" {
w.Header().Set("Content-Type", t)
}
}
w.Header().Set("Content-Encoding", enc)
http.ServeFile(w, r, packed)
return
}
}
}
http.ServeFile(w, r, file)
}
func mediaserve(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/media/")
if path == "" || strings.Contains(path, "..") ||
strings.HasSuffix(path, ".br") || strings.HasSuffix(path, ".gz") {
return
}
for _, base := range []string{"cfrontend/frontendorg/", "cfrontend/", "cfrontend/icons/", "cfrontend/fonts/", "cfrontend/pixelart/"} {
if _, err := os.Stat(base + path); err == nil {
servefile(w, r, base+path)
return
}
}
// pixelart-icons-font.css lives at cfrontend/icons/ not cfrontend/pixelart/
if _, err := os.Stat("cfrontend/icons/pixelart-icons-font.css"); err == nil && strings.HasSuffix(path, "pixelart-icons-font.css") {
servefile(w, r, "cfrontend/icons/pixelart-icons-font.css")
return
}
servefile(w, r, "cfrontend/frontendorg/"+path)
}
var mediaExtsServe = ".jpg.jpeg.png.gif.webp.mp4.webm."
func uploadname(name string) bool {
ext := filepath.Ext(name)
if ext == "" || !strings.Contains(mediaExtsServe, strings.ToLower(ext)+".") {
return false
}
for i := 0; i < len(name)-len(ext); i++ {
c := name[i]
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-') {
return false
}
}
return true
}
func uploadserve(w http.ResponseWriter, r *http.Request) {
path := filepath.Base(r.URL.Path)
if path == "index.html" || !uploadname(path) {
return
}
if _, err := os.Stat(filepath.Join(uploadDir, path)); err != nil {
return
}
servefile(w, r, uploadDir+"/"+path)
}
func packedfresh(file string, packed os.FileInfo) bool {
original, err := os.Stat(file)
return err == nil && !packed.ModTime().Before(original.ModTime())
}
ticket.go
package main
import (
"context"
"encoding/json"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"mime/multipart"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/chai2010/webp"
)
func prepareuploads() string {
path := os.Getenv("UPLOAD_PATH")
if path == "" {
path = "uploads"
}
if os.MkdirAll(path, 0755) != nil {
return ""
}
index := filepath.Join(path, "index.html")
if _, err := os.Stat(index); err != nil {
_ = os.WriteFile(index, []byte("."), 0644)
}
return path
}
var mediaExts = map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, ".mp4": true, ".webm": true}
func copycapped(dst string, src io.Reader, cap int64) error {
out, err := os.Create(dst)
if err != nil {
return err
}
written, err := io.Copy(out, io.LimitReader(src, cap+1))
if cerr := out.Close(); err == nil {
err = cerr
}
if err != nil || written > cap {
os.Remove(dst)
if err == nil {
err = io.ErrShortWrite
}
}
return err
}
func sniff(header *multipart.FileHeader, maxBytes int64) (multipart.File, string, bool) {
if header == nil || header.Size > maxBytes {
return nil, "", false
}
file, err := header.Open()
if err != nil {
return nil, "", false
}
head := make([]byte, 512)
n, _ := file.Read(head)
file.Seek(0, 0)
ctype := http.DetectContentType(head[:n])
return file, ctype, true
}
type stored struct {
path string
width, height int
}
func (s stored) ok() bool { return s.path != "" }
func storeupload(header *multipart.FileHeader) (string, int, int) {
s := save(header, mediaExts, "", MaxVideoSize, [2]int{MaxMediaW, MaxMediaH}, true)
return s.path, s.width, s.height
}
func storemlamh(header *multipart.FileHeader) (string, int, int) {
s := save(header, mediaExts, "", MaxMlamhMediaSize, [2]int{MaxMediaW, MaxMediaH}, true)
return s.path, s.width, s.height
}
func storeclientthumbs(r *http.Request, suffix string, mediaPath string) {
if mediaPath == "" || r.MultipartForm == nil {
return
}
for _, w := range thumbWidths {
files := r.MultipartForm.File["thumb"+strconv.Itoa(w)+"."+suffix]
if len(files) == 0 {
continue
}
saveclientthumb(files[0], mediaPath, w)
}
}
func saveclientthumb(header *multipart.FileHeader, mediaPath string, w int) bool {
if header == nil || header.Size <= 0 || header.Size > clientThumbLimit {
return false
}
file, ctype, ok := sniff(header, clientThumbLimit)
if !ok || ctype != "image/webp" {
return false
}
defer file.Close()
base := filepath.Base(mediaPath)
stem := strings.TrimSuffix(base, filepath.Ext(base))
return copycapped(filepath.Join(uploadDir, thumbsuffix(stem, w)), file, clientThumbLimit) == nil
}
const clientThumbLimit = 512 << 10
var profileImageExts = map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true}
var profileVideoExts = map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, ".mp4": true, ".webm": true}
func storeprofile(header *multipart.FileHeader, prefix string, maxBytes int64, allowVideo bool) string {
allowed := profileImageExts
if allowVideo {
allowed = profileVideoExts
}
s := save(header, allowed, prefix+"_", maxBytes, profileDims[prefix], allowVideo)
return s.path
}
func save(header *multipart.FileHeader, allowed map[string]bool, prefix string,
maxBytes int64, dims [2]int, video bool) stored {
ext := strings.ToLower(filepath.Ext(header.Filename))
if !allowed[ext] {
return stored{}
}
if storagefull() {
return stored{}
}
file, ctype, ok := sniff(header, maxBytes)
if !ok {
return stored{}
}
defer file.Close()
isImage := strings.HasPrefix(ctype, "image/")
if !isImage && !(video && strings.HasPrefix(ctype, "video/")) {
return stored{}
}
limit := maxBytes
if isImage && limit > MaxImageSize {
limit = MaxImageSize
}
if !isImage && limit > MaxVideoSize {
limit = MaxVideoSize
}
if header.Size > limit {
return stored{}
}
id := randomid()
if id == "" {
return stored{}
}
name := prefix + id
if !isImage {
return savevideo(file, name+ext, limit, dims)
}
cfg, _, err := image.DecodeConfig(file)
if err != nil {
return stored{}
}
file.Seek(0, 0)
if dimsover(cfg.Width, cfg.Height, dims[0], dims[1]) {
return stored{}
}
if ctype == "image/webp" {
path := filepath.Join(uploadDir, name+".webp")
if copycapped(path, file, limit) != nil {
return stored{}
}
return stored{"uploads/" + name + ".webp", cfg.Width, cfg.Height}
}
img, _, err := image.Decode(file)
if err != nil {
return stored{}
}
if ctype == "image/gif" {
file.Seek(0, 0)
path := filepath.Join(uploadDir, name+".gif")
if copycapped(path, file, limit) != nil {
return stored{}
}
return stored{"uploads/" + name + ".gif", cfg.Width, cfg.Height}
}
return savewebp(img, name+".webp", limit, cfg.Width, cfg.Height)
}
func savevideo(file multipart.File, name string, maxBytes int64, dims [2]int) stored {
path := filepath.Join(uploadDir, name)
if copycapped(path, file, maxBytes) != nil {
return stored{}
}
w, h := videodims(path)
if dimsover(w, h, dims[0], dims[1]) {
os.Remove(path)
return stored{}
}
return stored{"uploads/" + name, w, h}
}
func savewebp(img image.Image, name string, maxBytes int64, w, h int) stored {
tmp, err := os.CreateTemp(uploadDir, ".up-*.webp")
if err != nil {
return stored{}
}
tmpName := tmp.Name()
if webp.Encode(tmp, img, &webp.Options{Lossless: false, Quality: 70}) != nil {
_ = tmp.Close()
os.Remove(tmpName)
return stored{}
}
_ = tmp.Close()
if info, err := os.Stat(tmpName); err != nil || info.Size() > maxBytes {
os.Remove(tmpName)
return stored{}
}
if os.Rename(tmpName, filepath.Join(uploadDir, name)) != nil {
os.Remove(tmpName)
return stored{}
}
return stored{"uploads/" + name, w, h}
}
var probeSem = make(chan struct{}, 2)
func videodims(path string) (int, int) {
probeSem <- struct{}{}
defer func() { <-probeSem }()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "ffprobe", "-v", "error",
"-protocol_whitelist", "file", "-select_streams", "v:0",
"-show_entries", "stream=width,height", "-of", "csv=p=0:s=x", path).Output()
if err != nil {
return 0, 0
}
parts := strings.SplitN(strings.TrimSpace(string(out)), "x", 2)
if len(parts) != 2 {
return 0, 0
}
w, err1 := strconv.Atoi(parts[0])
h, err2 := strconv.Atoi(parts[1])
if err1 != nil || err2 != nil {
return 0, 0
}
return w, h
}
func removeupload(file string) {
if file == "" {
return
}
base := filepath.Base(file)
_ = os.Remove(filepath.Join(uploadDir, base))
stem := strings.TrimSuffix(base, filepath.Ext(base))
for _, w := range thumbWidths {
_ = os.Remove(filepath.Join(uploadDir, thumbsuffix(stem, w)))
}
}
func removefiles(r *http.Request, username string) {
user, err := queries.GetUserByUsername(r.Context(), username)
if err != nil {
return
}
removeupload(text(user.Avatar))
removeupload(text(user.Banner))
removeupload(text(user.Profilebg))
rows, err := queries.UserPostsMedia(r.Context(), username)
if err != nil {
return
}
for _, row := range rows {
var media []string
if json.Unmarshal([]byte(text(row)), &media) == nil {
for _, path := range media {
removeupload(path)
}
}
}
}
util.go
package main
import (
"crypto/rand"
"encoding/json"
"mime/multipart"
"net/http"
"net/url"
"strconv"
"strings"
"wisal/backend/db"
)
func reply(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(data)
}
func done(w http.ResponseWriter) { _, _ = w.Write([]byte("1")) }
func readbody(w http.ResponseWriter, r *http.Request, max int64) ([]*multipart.FileHeader, bool) {
r.Body = http.MaxBytesReader(w, r.Body, max)
if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") {
if r.ParseMultipartForm(MaxUploadRAM) != nil {
return nil, false
}
return r.MultipartForm.File["media"], true
}
if r.ParseForm() != nil {
return nil, false
}
return nil, true
}
func postactor(w http.ResponseWriter, r *http.Request) (db.User, int64, bool) {
u, ok := currentuser(r)
if !ok {
return db.User{}, 0, false
}
id := touint(r.FormValue("post_id"))
if id == 0 {
return db.User{}, 0, false
}
return u, int64(id), true
}
func randomid() string {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
result, randomBytes := make([]byte, 32), make([]byte, 32)
if _, err := rand.Read(randomBytes); err != nil {
return ""
}
for i := range result {
result[i] = chars[int(randomBytes[i])%len(chars)]
}
return string(result)
}
func sessionid(r *http.Request) string {
cookie, err := r.Cookie("session")
if err != nil {
return ""
}
return cookie.Value
}
func currentuser(r *http.Request) (db.User, bool) {
row, ok := r.Context().Value(userkey).(db.User)
if !ok || row.ID == 0 {
return db.User{}, false
}
return row, true
}
func iscolor(value string) bool { return hexColor.MatchString(value) }
func touint(value string) uint {
number, err := strconv.ParseUint(value, 10, 64)
if err != nil {
return 0
}
return uint(number)
}
func dimsover(w, h int, maxw, maxh int) bool {
if w == 0 || h == 0 {
return true
}
if w < h {
w, h = h, w
}
return w > maxw || (h > maxh && h > maxw)
}
func isweburl(value string) bool {
if value == "" || !strings.HasPrefix(value, "https://") {
return false
}
if strings.ContainsAny(value, " \t\r\n") {
return false
}
u, err := url.Parse(value)
return err == nil && u.Hostname() != "" && u.User == nil
}
func ismobile(r *http.Request) bool {
ua := strings.ToLower(r.UserAgent())
for _, m := range []string{"android", "iphone", "ipad", "ipod", "mobile", "windows phone", "opera mini", "blackberry"} {
if strings.Contains(ua, m) {
return true
}
}
return false
}
app.js
const colors = ['#ffcc00','#ff3366','#00ffff','#00ff00','#3399ff','#ff9900','#e066ff'];
const c = colors;
window.colors = colors;
document.addEventListener('DOMContentLoaded', function () {
const n = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
n.setAttribute('style', 'display:none');
n.innerHTML = '<symbol id="st" viewBox="0 0 24 24"><path d="M11 1h2v4h-2zm0 22h2v-4h-2zM9 5h2v4H9zm0 14h2v-4H9zm4-14h2v4h-2zm0 14h2v-4h-2zM5 9h4v2H5zm14 0h-4v2h4zM1 11h4v2H1zm22 0h-4v2h4zM5 13h4v2H5zm14 0h-4v2h4z"/></symbol>';
document.body.appendChild(n);
});
function h(el) { el.style.color = colors[Math.random() * colors.length | 0]; }
function u(el) { el.style.color = ''; }
function hb(el) { h(el); el.style.borderColor = colors[Math.random() * colors.length | 0]; }
function ub(el) { el.style.color = ''; el.style.borderColor = 'rgba(255,255,255,0.1)'; }
let curUser = null;
function ismobile() {
return /Android|iPhone|iPod|iPad|Mobile/i.test(navigator.userAgent);
}
function loginlink() {
return ismobile() ? '/login-phone' : '/login';
}
function defaultavatar() {
return '/media/defaultavatar/af.webp';
}
function thumburl(p, w) {
if (!p || !w) return p;
return /^uploads\/[^/]+\.(jpe?g|png|gif)$/i.test(p) ? p + '?w=' + w : p;
}
function mediaurl(path) {
if (!path) return '';
const value = String(path);
return /^\/?uploads\//.test(value) ? (window.WISAL_MEDIA_ORIGIN || '') + '/' + value.replace(/^\//, '') : value;
}
function applyavatar(el, path) {
if (!el) return null;
const src = path ? mediaurl(thumburl(path, 256)) : defaultavatar();
if (el.tagName === 'IMG') { el.src = src; return el; }
const img = document.createElement('img');
img.className = el.className;
img.id = el.id;
img.setAttribute('style', el.getAttribute('style') || '');
const oc = el.getAttribute('onclick');
if (oc) img.setAttribute('onclick', oc);
img.loading = 'lazy';
img.src = src;
el.replaceWith(img);
return img;
}
document.addEventListener('click', (ev) => {
const btn = ev.target.closest('.more, .less');
if (!btn) return;
const box = btn.closest('.post-body');
if (!box) return;
const short = box.querySelector('.cs'), full = box.querySelector('.cf');
if (!short || !full) return;
const showFull = btn.classList.contains('more');
short.style.display = showFull ? 'none' : '';
full.style.display = showFull ? '' : 'none';
if (showFull) full.scrollIntoView({ block: 'nearest' });
});
document.addEventListener('click', (ev) => {
const btn = ev.target.closest('.mmore');
if (!btn) return;
const box = btn.closest('.post-media');
if (!box) return;
const hid = box.querySelector('.media-hid');
const open = !box.classList.contains('open');
box.classList.toggle('open', open);
if (hid) hid.style.display = open ? 'flex' : 'none';
btn.textContent = open ? 'طوّي' : 'شوف الكل';
});
const AR_MONTHS = ['يناير','فبراير','مارس','أبريل','مايو','يونيو','يوليو','أغسطس','سبتمبر','أكتوبر','نوفمبر','ديسمبر'];
function ardate(d) {
return d.getDate() + ' ' + AR_MONTHS[d.getMonth()] + ' ' + d.getFullYear();
}
const tdate = ardate;
async function json(r, fallback) {
try {
const t = await r.text();
if (!t) return fallback;
return JSON.parse(t);
} catch (err) {
return fallback;
}
}
const j = json;
const dataRequests = {
entries: new Map(),
context: 'guest',
generation: 0,
ttl: 60000,
maxEntries: 50,
maxBytes: 2 * 1024 * 1024,
key(url) {
const parsed = new URL(url, location.href);
parsed.searchParams.sort();
return JSON.stringify([this.context, parsed.href]);
},
peek(url) {
const key = this.key(url), entry = this.entries.get(key);
if (entry) {
this.entries.delete(key);
this.entries.set(key, entry);
}
return entry;
},
clear() {
this.generation++;
for (const entry of this.entries.values()) entry.controller?.abort();
this.entries.clear();
},
scope(context) {
if (this.context !== context) {
this.clear();
this.context = context;
document.querySelectorAll('[id^="cl-"]').forEach(list => { list.innerHTML = ''; });
}
},
invalidate(url) {
const key = this.key(url), entry = this.entries.get(key);
entry?.controller?.abort();
this.entries.delete(key);
},
trim() {
let bytes = [...this.entries.values()].reduce((sum, entry) => sum + (entry.bytes || 0), 0);
for (const [key, entry] of this.entries) {
if (this.entries.size <= this.maxEntries && bytes <= this.maxBytes) break;
entry.controller?.abort();
this.entries.delete(key);
bytes -= entry.bytes || 0;
}
},
store(url, value) {
this.invalidate(url);
this.entries.set(this.key(url), {value, expires: Date.now() + this.ttl, bytes: JSON.stringify(value).length * 2});
this.trim();
},
async get(url) {
let entry = this.peek(url);
if (entry?.value && Date.now() < entry.expires) return entry.value;
if (entry?.pending) return entry.pending;
const key = this.key(url);
entry ||= {};
entry.controller = new AbortController();
this.entries.set(key, entry);
entry.pending = (async () => {
try {
const r = await fetch(url, {cache: 'no-cache', headers: {'Accept': 'application/json'}, signal: entry.controller.signal});
if (!r.ok) throw new Error('data request failed');
const value = await r.json();
if (!Array.isArray(value)) throw new Error('invalid list');
if (this.entries.get(key) !== entry) return null;
entry.value = value;
entry.expires = Date.now() + this.ttl;
entry.bytes = JSON.stringify(value).length * 2;
this.trim();
return value;
} catch {
return null;
} finally {
entry.pending = null;
entry.controller = null;
if (!entry.value && this.entries.get(key) === entry) this.entries.delete(key);
}
})();
this.trim();
return entry.pending;
}
};
async function jsonget(url, options = {}) {
if (options.comments) return dataRequests.get(url);
try {
const r = await fetch(url, { cache: 'no-cache', headers: { 'Accept': 'application/json' } });
if (!r.ok) return [];
return JSON.parse(await r.text()) || [];
} catch (err) {
return [];
}
}
async function hget(url) {
try {
const r = await fetch(url, { cache: 'no-cache' });
return r.ok ? await r.text() : '';
} catch (err) {
return '';
}
}
async function myprofile() {
try {
const r = await fetch('/api/user?what=me');
if (!r.ok) {
if (r.status === 401) {
curUser = null;
dataRequests.scope('guest');
}
return null;
}
const u = await json(r, null);
curUser = u?.username || null;
dataRequests.scope(u?.username ? String(u.ID || u.username) : 'guest');
if (!curUser) return null;
curUser = u.username;
const pa = document.querySelector('.panel-avatar');
if (pa) applyavatar(pa, u.avatar || '');
return u;
} catch (err) {
return null;
}
}
const mp = myprofile;
async function star(el, pid) {
if (!curUser) { window.location.href = loginlink(); return; }
const k = 'star:' + pid;
if (el.classList.contains('sending')) return;
el.classList.add('sending');
try {
const r = await fetch('/api/njmh', { method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'post_id=' + pid });
if (r.ok) {
const cEl = el.querySelector('.sc');
if (cEl) cEl.textContent = parseInt(cEl.textContent || '0') + 1;
}
} finally { el.classList.remove('sending'); }
}
const st = star;
async function like(el, pid) {
if (!curUser) { window.location.href = loginlink(); return; }
if (el.classList.contains('sending')) return;
el.classList.add('sending');
try {
const r = await fetch('/api/like', { method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'post_id=' + pid });
if (r.ok) {
const cEl = el.querySelector('.lc');
if (cEl) cEl.textContent = parseInt(cEl.textContent || '0') + 1;
}
} finally { el.classList.remove('sending'); }
}
const lk = like;
async function comments(col, pid) {
const area = document.getElementById('cmt-' + col + '-' + pid);
if (!area) return;
if (area.style.display === 'block') { area.style.display = 'none'; return; }
area.style.display = 'block';
const list = document.getElementById('cl-' + col + '-' + pid);
if (!list) return;
const url = '/api/cmt?post_id=' + pid;
const generation = dataRequests.generation;
const cached = dataRequests.peek(url)?.value;
if (cached) list.innerHTML = cmtshtml(cached);
const rows = await jsonget(url, {comments: true});
if (!rows) {
if (generation === dataRequests.generation && list.isConnected) list.innerHTML = '';
return;
}
if (rows && generation === dataRequests.generation && list.isConnected) list.innerHTML = cmtshtml(rows);
}
const cm = comments;
async function sendcomment(col, pid) {
if (!curUser) { window.location.href = loginlink(); return; }
const inp = document.getElementById('ci-' + col + '-' + pid);
if (!inp || !inp.value.trim() || inp.dataset.sending) return;
inp.dataset.sending = '1';
const generation = dataRequests.generation;
const btn = inp.nextElementSibling;
if (btn) { btn.disabled = true; btn.textContent = '...'; }
try {
const r = await fetch('/api/cmt', { method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'post_id=' + pid + '&response=comment&content=' + encodeURIComponent(inp.value.trim()) });
const row = r.ok ? await json(r, null) : null;
if (row?.id && generation === dataRequests.generation) {
inp.value = '';
const card = document.getElementById('cmt-' + col + '-' + pid)?.closest('.post-card');
if (card) {
const cc = card.querySelector('.cmc');
if (cc) cc.textContent = parseInt(cc.textContent || '0') + 1;
}
const url = '/api/cmt?post_id=' + pid;
const previous = dataRequests.peek(url)?.value;
const rows = previous ? [row, ...previous.filter(cm => cm.id !== row.id)].slice(0, 50) : null;
if (rows) dataRequests.store(url, rows);
else dataRequests.invalidate(url);
document.querySelectorAll('[id$="-' + pid + '"][id^="cl-"]').forEach(list => {
if (rows) list.innerHTML = cmtshtml(rows);
else list.insertAdjacentHTML('afterbegin', cmtrow(row));
});
}
} finally {
delete inp.dataset.sending;
if (btn) { btn.disabled = false; btn.textContent = 'إرسال'; }
}
}
const sc = sendcomment;
function randomcolor(el, nc) {
el.addEventListener('mouseenter', () => {
const color = colors[Math.floor(Math.random() * colors.length)];
el.style.color = color; el.style.borderColor = color;
});
el.addEventListener('mouseleave', () => { el.style.color = nc; el.style.borderColor = nc; });
}
const rc = randomcolor;
function viewimage(src) {
const el = document.getElementById('imgViewer');
if (!el) return;
document.getElementById('imgViewerSrc').src = src;
el.style.display = 'flex';
}
const esc = s => s ? String(s)
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(/"/g, '"').replace(/'/g, ''') : '';
function plink(username) {
return 'profile' + (ismobile() ? '-phone' : '') + '?username=' + encodeURIComponent(username);
}
function posttime(d) {
if (!d) return '';
const dt = new Date(d);
if (isNaN(dt)) return '';
const diff = Date.now() - dt.getTime();
if (diff < 60000) return t('d_now');
if (diff < 3600000) return t('d_ago') + ' ' + Math.floor(diff / 60000) + ' د';
if (diff < 86400000) return t('d_ago') + ' ' + Math.floor(diff / 3600000) + ' س';
if (diff < 7 * 86400000) return t('d_ago') + ' ' + Math.floor(diff / 86400000) + ' ي';
return ardate(dt);
}
function avatarimg(avatar, w, cls, extra) {
const c = cls ? ' class="' + cls + '"' : '';
return '<img' + c + ' src="' + (avatar ? esc(mediaurl(thumburl(avatar, w))) : '/media/defaultavatar/af.webp') + '"' + (extra || '') + ' alt="">';
}
const PLATFORMS = {
'patreon.com': 'Patreon', 'ko-fi.com': 'Ko-fi', 'buymeacoffee.com': 'Buy Me a Coffee',
'liberapay.com': 'Liberapay', 'opencollective.com': 'Open Collective', 'github.com': 'GitHub Sponsors',
'donorbox.org': 'Donorbox', 'tipee.com': 'Tipeee', 'boosty.to': 'Boosty', 'gumroad.com': 'Gumroad',
'supercast.com': 'Supercast', 'memberful.com': 'Memberful', 'paypal.me': 'PayPal', 'venmo.com': 'Venmo',
'cash.app': 'Cash App', 'streamlabs.com': 'Streamlabs', 'discord.gg': 'Discord', 'discord.com': 'Discord',
'twitter.com': 'X', 'x.com': 'X', 'instagram.com': 'Instagram', 'tiktok.com': 'TikTok',
'youtube.com': 'YouTube', 'twitch.tv': 'Twitch', 'spotify.com': 'Spotify'
};
function platformname(raw) {
try {
const h = new URL(raw).hostname.replace(/^www\./, '').toLowerCase();
return PLATFORMS[h] || t('d_support');
} catch (err) {
return t('d_support');
}
}
function postbody(text, pid, col) {
if (!text) return '';
const chars = [...text];
const nl = s => esc(s).replace(/\n/g, '<br>');
if (chars.length <= 400) return nl(text);
const cid = esc(col), id = pid;
return '<span class="cs" data-c="' + cid + '" data-id="' + id + '">' + nl(chars.slice(0, 400).join('')) +
'… <button type="button" class="more">كمّل</button></span>' +
'<span class="cf" data-c="' + cid + '" data-id="' + id + '" style="display:none">' + nl(text) +
' <button type="button" class="less">طوّي</button></span>';
}
function postmedia(p) {
if (!p.image) return '';
let list = [];
try { list = JSON.parse(p.image); } catch (err) {}
if (!Array.isArray(list) || !list.length) {
list = /^uploads\//.test(p.image) ? [p.image] : [];
}
if (!list.length) return '';
let sizes = {};
try { sizes = JSON.parse(p.mediasize || '{}'); } catch (err) {}
const one = u => {
if (/\.mp4$|\.webm$/i.test(u)) {
return '<video src="' + esc(mediaurl(u)) + '" controls playsinline preload="metadata"></video>';
}
const s = sizes[u];
const attrs = s && s.w > 0 ? ' width="' + s.w + '" height="' + s.h + '"' : '';
return '<img src="' + esc(mediaurl(thumburl(u, 640))) + '" loading="lazy" decoding="async"' + attrs + ' alt="">';
};
const more = '<button type="button" class="mmore">شوف الكل</button>';
const parts = list.map(one);
if (list.length <= 3) return '<div class="post-media">' + parts.join('') + more + '</div>';
return '<div class="post-media"><div class="media-vis">' + parts.slice(0, 3).join('') + '</div>' +
'<div class="media-hid">' + parts.slice(3).join('') + '</div>' + more + '</div>';
}
function postcard(p, col) {
const link = plink(p.username);
const id = p.id;
const med = postmedia(p);
return '<div class="post-card' + (med ? ' has-media' : '') + '">' +
'<div class="post-head"><div class="post-av" onclick="window.location.href=\'' + esc(link) + '\'">' +
avatarimg(p.Avatar, 96, '', ' loading="lazy"') + '</div>' +
'<div class="pl" onclick="window.location.href=\'' + esc(link) + '\'">' +
'<div class="post-nm">' + esc(p.DisplayName) + '</div><div class="post-un">@' + esc(p.username) + '</div></div></div>' +
'<div class="post-time">' + posttime(p.time) + '</div>' +
'<div class="post-body">' + postbody(p.content, id, col) + '</div>' + med +
'<div class="post-stats">' +
'<span class="spl"><i class="pixelart-icons-font-eye"></i> <span class="vc" data-pid="' + id + '">' + (p.Viewsco || 0) + '</span></span>' +
'<span class="act" onmouseenter="h(this)" onmouseleave="u(this)" onclick="star(this,' + id + ')">' +
'<svg viewBox="0 0 24 24" fill="currentColor"><use href="#st"/></svg>' +
' <span class="sc">' + (p.Starco || 0) + '</span></span>' +
(p.supporturl ? '<span class="act" onmouseenter="hb(this)" onmouseleave="ub(this)" ' +
'style="border:1px solid rgba(255,255,255,0.1);border-radius:999px;padding:1px 8px;font-size:12px;display:inline-flex;align-items:center;">' +
'<a href="' + esc(p.supporturl) + '" target="_blank" rel="noopener" style="color:inherit;text-decoration:none;">' +
esc(platformname(p.supporturl)) + '</a></span>' : '') +
'<span class="act" onmouseenter="h(this)" onmouseleave="u(this)" onclick="comments(\'' + esc(col) + '\',' + id + ')">' +
'<i class="pixelart-icons-font-message"></i> <span class="cmc">' + (p.Commentco || 0) + '</span></span>' +
'<span class="act" onmouseenter="h(this)" onmouseleave="u(this)" onclick="like(this,' + id + ')">' +
'<i class="pixelart-icons-font-heart"></i> <span class="lc">' + (p.Likeco || 0) + '</span></span>' +
'</div>' +
'<div class="cmt-area" id="cmt-' + esc(col) + '-' + id + '" style="display:none;">' +
'<div class="cmt-list" id="cl-' + esc(col) + '-' + id + '"></div>' +
'<div class="cmt-inp"><input id="ci-' + esc(col) + '-' + id + '" placeholder="اكتب تعليقك" ' +
'onkeydown="if(event.key===\'Enter\')sendcomment(\'' + esc(col) + '\',' + id + ')">' +
'<button onclick="sendcomment(\'' + esc(col) + '\',' + id + ')">إرسال</button></div></div></div>';
}
const postshtml = (list, col) => list.map(p => postcard(p, col)).join('');
function cmtrow(cm) {
const link = esc(plink(cm.username));
const av = cm.avatar_path ? '<img src="' + esc(mediaurl(thumburl(cm.avatar_path, 96))) + '" loading="lazy" alt="">' : '';
return '<div class="cmt-row"><div class="ca" style="cursor:pointer;" onclick="window.location.href=\'' + link + '\'">' + av + '</div>' +
'<div class="cb"><div class="cu" style="cursor:pointer;" onclick="window.location.href=\'' + link + '\'">@' + esc(cm.username) + '</div>' +
'<div class="ct">' + esc(cm.content) + '</div></div></div>';
}
const cmtshtml = list => list.length
? list.map(cmtrow).join('')
: '<div style="color:#555;font-size:13px;padding:4px 0;">' + t('no_comments') + '</div>';
function siderow(u2) {
const name = u2.displayname || u2.username;
return '<div class="sc-row" onclick="window.location.href=\'' + esc(plink(u2.username)) + '\'">' +
avatarimg(u2.avatar, 96, 'sc-av', ' loading="lazy"') +
'<span class="sc-un">' + esc(name) + '</span></div>';
}
function sidenewest(u2) {
if (!u2 || !u2.username) return '<span class="sc-un"></span>';
const name = u2.displayname || u2.username;
const link = esc(plink(u2.username));
return avatarimg(u2.avatar, 256, 'sc-av-big', ' loading="lazy" onclick="window.location.href=\'' + link + '\'" style="cursor:pointer"') +
'<span class="sc-un" style="cursor:pointer" onclick="window.location.href=\'' + link + '\'">' + esc(name) + '</span>';
}
const sidepeople = list => list.map(siderow).join('');
function supportchips(raw) {
let links;
try { links = JSON.parse(raw || '[]'); } catch (err) { links = []; }
if (!Array.isArray(links) || !links.length) return '';
const chips = links.map(l =>
'<a href="' + esc(l) + '" target="_blank" rel="noopener" ' +
'style="color:#fff;text-decoration:none;border:1px solid rgba(255,255,255,0.1);border-radius:999px;padding:2px 12px;font-size:13px;" ' +
'onmouseover="const color=c[Math.floor(Math.random()*c.length)];this.style.color=color;this.style.borderColor=color;" ' +
'onmouseout="this.style.color=\'#fff\';this.style.borderColor=\'rgba(255,255,255,0.1)\';">' + esc(platformname(l)) + '</a>');
return '<div class="support-links" style="margin-top:10px;display:flex;flex-wrap:wrap;gap:8px;">' + chips.join('') + '</div>';
}
function deftext(v, key, dflt) {
return v || t(dflt);
}
function prohtml(v, owner) {
const banner = v.banner ? avatarimg(v.banner, 640, '', ' loading="lazy" width="100%" height="300"') : '';
const avatar = v.avatar
? avatarimg(v.avatar, 256, '', ' loading="lazy" width="120" height="120"')
: '<img src="/media/defaultavatar/af.webp" loading="lazy" alt="">';
const edit = owner
? '<button class="edit-btn" onclick="te()" style="color:' + esc(v.editprofilecolor) + ';border-color:' + esc(v.editprofilecolor) + '">' +
esc(deftext(v.editprofiletext, 'd_editbtn')) + '</button>'
: '';
const bio = v.bio ? '<div class="bio" style="color:' + esc(v.biocolor) + '">' + esc(v.bio) + '</div>' : '';
const joined = v.createtime && new Date(v.createtime) ? ' ' + ardate(new Date(v.createtime)) : '';
const tab1 = esc(deftext(v.tab1name, 'tab_posts')), tab2 = esc(deftext(v.tab2name, 'tab_likes'));
const tc2 = esc(v.tabcolor);
return '<div class="glass-box"><div class="banner">' + banner + '</div>' +
'<div class="prof-head"><div class="av-wrap" onclick="g(\'/madar\')">' + avatar + '</div>' + edit + '</div>' +
'<div class="prof-info"><div class="nm" style="color:' + esc(v.namecolor) + '">' + esc(v.displayname) + '</div>' +
'<div class="un">@' + esc(v.username) + '</div>' + bio +
'<div class="meta"><span style="color:' + esc(v.joincolor) + '">' + esc(deftext(v.jointext, 'd_join')) + joined + '</span></div>' +
supportchips(v.supportlinks) + '</div></div>' +
'<div class="tabs"><div class="act" data-tab="posts" style="color:' + tc2 + '">' + tab1 + '</div>' +
'<div class="" data-tab="likes" style="color:' + tc2 + '">' + tab2 + '</div></div>' +
'<div class="feed" id="feed"><div class="loading"></div></div>';
}
const vi = viewimage;
function closeimage() {
const el = document.getElementById('imgViewer');
if (!el) return;
el.style.display = 'none';
document.getElementById('imgViewerSrc').src = '';
}
const ci = closeimage;
window.addEventListener('pagehide', () => dataRequests.clear());
window.addEventListener('pageshow', event => { if (event.persisted) myprofile(); });
document.addEventListener('contextmenu', (ev) => {
if (ev.target.closest('.post-media img, .post-media video, #imgViewerSrc')) ev.preventDefault();
});
const mediaStyle = document.createElement('style');
mediaStyle.textContent = '.post-media img,.post-media video,#imgViewerSrc{-webkit-touch-callout:none;-webkit-user-select:none;user-select:none;}';
document.head.appendChild(mediaStyle);
Object.assign(window, {
colors, c, loginlink, applyavatar, thumburl, mediaurl,
ardate, tdate, json, j, jsonget, hget,
myprofile, mp, star, st, like, lk,
comments, cm, sendcomment, sc, randomcolor, rc, viewimage, vi, closeimage, ci,
esc, plink, posttime, avatarimg, platformname, postbody, postmedia,
postcard, postshtml, cmtshtml, siderow, sidenewest, sidepeople,
supportchips, deftext, prohtml
});
compress.js
(function () {
const workerSource = `
self.onmessage = async function (e) {
const d = e.data;
let bmp;
try {
bmp = await createImageBitmap(d.file, { imageOrientation: 'from-image' });
const scale = Math.min(1, d.max / Math.max(bmp.width, bmp.height));
const w = Math.max(1, Math.round(bmp.width * scale));
const h = Math.max(1, Math.round(bmp.height * scale));
const main = new OffscreenCanvas(w, h);
main.getContext('2d').drawImage(bmp, 0, 0, w, h);
let quality = d.quality;
let blob = await main.convertToBlob({ type: 'image/webp', quality: quality });
while (blob.size > d.budget && quality > 0.45) {
quality -= 0.15;
blob = await main.convertToBlob({ type: 'image/webp', quality: quality });
}
const thumbs = {};
for (const tw of d.thumbs) {
if (w <= tw) continue;
const tc = new OffscreenCanvas(tw, Math.max(1, Math.round(h * tw / w)));
tc.getContext('2d').drawImage(main, 0, 0, tw, Math.max(1, Math.round(h * tw / w)));
thumbs[tw] = await tc.convertToBlob({ type: 'image/webp', quality: 0.75 });
}
self.postMessage({ id: d.id, ok: true, blob: blob, thumbs: thumbs });
} catch (err) {
self.postMessage({ id: d.id, ok: false });
} finally {
if (bmp) bmp.close();
}
};`;
const queue = new Map();
let seq = 0;
let worker = null;
function ensure() {
if (worker) return worker;
const url = URL.createObjectURL(new Blob([workerSource], { type: 'text/javascript' }));
try { worker = new Worker(url); }
finally { URL.revokeObjectURL(url); }
worker.onmessage = function (e) {
const slot = queue.get(e.data.id);
if (!slot) return;
queue.delete(e.data.id);
clearTimeout(slot.timer);
if (e.data.ok) slot.resolve(e.data);
else slot.reject(new Error('compress'));
};
worker.onerror = function () {
queue.forEach(function (s) { clearTimeout(s.timer); s.reject(new Error('worker')); });
worker.terminate();
queue.clear();
worker = null;
};
return worker;
}
let imageQueue = Promise.resolve();
function send(file, max, budget, thumbs) {
const job = imageQueue.then(function () {
return new Promise(function (resolve, reject) {
const id = ++seq;
try {
const active = ensure();
const timer = setTimeout(function () {
active.terminate();
worker = null;
queue.delete(id);
reject(new Error('compression timeout'));
}, 30000);
queue.set(id, { resolve: resolve, reject: reject, timer: timer });
active.postMessage({ id: id, file: file, max: max, quality: 0.8, budget: budget, thumbs: thumbs });
} catch (err) {
const slot = queue.get(id);
if (slot) clearTimeout(slot.timer);
queue.delete(id);
reject(err);
}
});
});
imageQueue = job.catch(function () {});
return job;
}
async function image(file, opts) {
opts = opts || {};
if (!file || !file.type || file.type.indexOf('image/') !== 0) return null;
if (file.type === 'image/gif') return null;
if (file.size <= (opts.floor || 120 * 1024)) return null;
try {
const out = await send(file, opts.max || 2048, opts.budget || 1500 * 1024, opts.thumbs || []);
if (!out.blob || !out.blob.size) return null;
if (out.blob.size >= file.size) return null;
return out;
} catch (err) {
return null;
}
}
function ebmlDuration(buf, durationMs) {
const view = new DataView(buf);
const idRead = function (pos) {
const first = view.getUint8(pos);
let len = 1;
for (let mask = 0x80; mask && !(first & mask); mask >>= 1) len++;
let id = 0;
for (let i = 0; i < len; i++) id = id * 256 + view.getUint8(pos + i);
return { id: id, len: len };
};
const sizeRead = function (pos) {
const first = view.getUint8(pos);
let len = 1;
for (let mask = 0x80; mask && !(first & mask); mask >>= 1) len++;
let value = first & (0xff >> len);
let unknown = value === (0xff >> len);
for (let i = 1; i < len; i++) {
value = value * 256 + view.getUint8(pos + i);
if (view.getUint8(pos + i) !== 0xff) unknown = false;
}
return { len: len, value: value, unknown: unknown };
};
const encodeSize = function (value) {
for (let len = 1; len <= 8; len++) {
const max = Math.pow(2, 7 * len) - 1;
if (value < max) {
const out = new Uint8Array(len);
let v = value;
for (let i = len - 1; i >= 0; i--) { out[i] = v & 0xff; v = Math.floor(v / 256); }
out[0] |= 1 << (8 - len);
return out;
}
}
return new Uint8Array([1, 0, 0, 0, 0, 0, 0, 0]);
};
let pos = 0;
const header = idRead(pos);
if (header.id !== 0x1A45DFA3) return null;
const headerSize = sizeRead(pos + header.len);
pos += header.len + headerSize.len + headerSize.value;
const seg = idRead(pos);
if (seg.id !== 0x18538067) return null;
const segSize = sizeRead(pos + seg.len);
const segStart = pos + seg.len + segSize.len;
let scan = segStart;
let infoPos = -1, infoSizePos = -1, infoSize = null;
const segEnd = segSize.unknown ? buf.byteLength : segStart + segSize.value;
while (scan < segEnd) {
const el = idRead(scan);
const es = sizeRead(scan + el.len);
if (el.id === 0x1549A966) {
infoPos = scan;
infoSizePos = scan + el.len;
infoSize = es;
break;
}
if (es.unknown) return null;
scan += el.len + es.len + es.value;
}
if (infoPos < 0 || infoSize.unknown) return null;
const infoStart = infoSizePos + infoSize.len;
const infoEnd = infoStart + infoSize.value;
const scale = 1000000;
let tick = scale;
let durationPos = -1, durationLen = 0;
let p = infoStart;
while (p < infoEnd) {
const el = idRead(p);
const es = sizeRead(p + el.len);
if (el.id === 0x2AD7B1) {
let v = 0;
for (let i = 0; i < es.value; i++) v = v * 256 + view.getUint8(p + el.len + es.len + i);
if (v > 0) tick = v;
}
if (el.id === 0x4489) {
durationPos = p + el.len + es.len;
durationLen = es.value;
}
p += el.len + es.len + es.value;
}
const ticks = durationMs * 1000000 / tick;
if (durationPos > 0 && (durationLen === 4 || durationLen === 8)) {
const copy = new Uint8Array(buf.slice(0));
const dv = new DataView(copy.buffer);
if (durationLen === 4) dv.setFloat32(durationPos, ticks);
else dv.setFloat64(durationPos, ticks);
return new Blob([copy], { type: 'video/webm' });
}
const durEl = new Uint8Array(11);
durEl.set([0x44, 0x89, 0x88], 0);
new DataView(durEl.buffer).setFloat64(3, ticks);
const newSize = encodeSize(infoSize.value + durEl.length);
const parts = [
new Uint8Array(buf.slice(0, infoSizePos)),
newSize,
new Uint8Array(buf.slice(infoStart, infoEnd)),
durEl,
new Uint8Array(buf.slice(infoEnd))
];
return new Blob(parts, { type: 'video/webm' });
}
function waitEvent(el, name) {
return new Promise(function (resolve, reject) {
const timer = setTimeout(function () { finish(new Error('media timeout')); }, 15000);
function cleanup() {
clearTimeout(timer);
el.removeEventListener(name, ready);
el.removeEventListener('error', failed);
}
function finish(err) { cleanup(); if (err) reject(err); else resolve(); }
function ready() { finish(); }
function failed() { finish(new Error('invalid media')); }
el.addEventListener(name, ready, { once: true });
el.addEventListener('error', failed, { once: true });
});
}
async function video(file, onProgress) {
if (!file || !window.MediaRecorder) return null;
if (file.type.indexOf('video/') !== 0) return null;
if (file.size <= 4 * 1024 * 1024) return null;
if (navigator.deviceMemory && navigator.deviceMemory < 4) return null;
const url = URL.createObjectURL(file);
const el = document.createElement('video');
el.preload = 'auto';
el.playsInline = true;
el.volume = 0;
let stream, sourceStream, rec;
try {
const metadata = waitEvent(el, 'loadedmetadata');
el.src = url;
await metadata;
const dur = el.duration;
if (!dur || !isFinite(dur) || dur > 120) return null;
if (!el.captureStream) return null;
const height = Math.min(720, el.videoHeight || 720);
const width = Math.max(2, Math.round((el.videoWidth || 1280) * height / (el.videoHeight || 720) / 2) * 2);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
stream = canvas.captureStream(30);
sourceStream = el.captureStream();
sourceStream.getAudioTracks().forEach(function (t) { stream.addTrack(t); });
const type = MediaRecorder.isTypeSupported('video/webm;codecs=vp9,opus')
? 'video/webm;codecs=vp9,opus' : 'video/webm';
rec = new MediaRecorder(stream, {
mimeType: type,
videoBitsPerSecond: height > 480 ? 2200000 : 1200000,
audioBitsPerSecond: 96000
});
const chunks = [];
rec.ondataavailable = function (e) { if (e.data && e.data.size) chunks.push(e.data); };
const done = new Promise(function (resolve) { rec.onstop = resolve; });
rec.start(1000);
el.playbackRate = 1;
await el.play();
const started = performance.now();
await new Promise(function (resolve) {
function frame() {
if (el.ended || el.paused || performance.now() - started > (dur + 10) * 1000) return resolve();
ctx.drawImage(el, 0, 0, width, height);
if (onProgress) onProgress(Math.min(1, el.currentTime / dur));
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
});
rec.stop();
await done;
if (!el.ended) return null;
const raw = new Blob(chunks, { type: 'video/webm' });
if (!raw.size || raw.size >= file.size * 0.85) return null;
const fixed = ebmlDuration(await raw.arrayBuffer(), dur * 1000);
const out = fixed || raw;
if (out.size >= file.size * 0.85) return null;
return { blob: out, name: (file.name || 'video').replace(/\.[^.]+$/, '') + '.webm' };
} catch (err) {
return null;
} finally {
el.pause();
if (rec && rec.state !== 'inactive') rec.stop();
if (stream) stream.getTracks().forEach(function (track) { track.stop(); });
if (sourceStream) sourceStream.getTracks().forEach(function (track) { track.stop(); });
el.removeAttribute('src');
el.load();
URL.revokeObjectURL(url);
}
}
window.wcompress = { image: image, video: video };
window.ebmlDuration = ebmlDuration;
})();
async function uploadForm(url, form) {
if (document.querySelector('meta[name="wisal-runtime"][content="cloudflare"]')) {
const staged = new FormData();
let index = 0;
for (const [field, value] of form.entries()) {
if (field.startsWith('thumb')) continue;
if (!(value instanceof File) || !['media', 'avatar', 'banner', 'profilebg'].includes(field)) {
staged.append(field, value);
continue;
}
const suffix = field === 'media' ? String(index++) : field;
const item = new FormData();
item.append('media', value);
for (const width of [96, 256, 640]) {
const thumb = form.get('thumb' + width + '.' + suffix);
if (thumb) item.append('thumb' + width + '.0', thumb);
}
const response = await fetch('/api/upload', { method: 'POST', body: item });
if (!response.ok) return response;
staged.append(field, (await response.json()).path);
}
form = staged;
}
return fetch(url, { method: 'POST', body: form });
}
i18n.css
#langToggle {
position: fixed; right: 12px; bottom: 12px; z-index: 99999999;
background: rgba(0,0,0,0.55);
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.18);
border-radius: 999px;
color: #fff; font-family: inherit; font-size: 15px; letter-spacing: 1px;
padding: 6px 16px; cursor: pointer; user-select: none;
transition: background .15s, color .15s;
outline: none; -webkit-tap-highlight-color: transparent;
}
#langToggle:hover { background: rgba(255,255,255,0.15); }
[lang="en"] .edit-box,
[lang="en"] .edit-box label,
[lang="en"] #right-panel h3,
[lang="en"] #bottom-bar { text-align: left; }
[lang="en"] .edit-box .ttl { text-align: center; }
[lang="en"] .eb-span2 { grid-column: 1 / -1; }
[lang="en"] #cell-info,
[lang="en"] #rotate-val,
[lang="en"] #supportlinks input,
[lang="en"] .edit-box input[type="text"],
[lang="en"] .edit-box textarea { direction: ltr; text-align: left; }
[lang="en"] #right-panel .widget-item { text-align: left; }
[lang="en"] #bottom-bar .src-mini { direction: ltr; }
i18n.js
(function () {
const S = 'wisal_lang';
document.cookie = 'lang=' + (localStorage.getItem(S) || 'ar') + ';path=/;max-age=31536000;samesite=lax';
const A = ['يناير','فبراير','مارس','أبريل','مايو','يونيو','يوليو','أغسطس','سبتمبر','أكتوبر','نوفمبر','ديسمبر'];
const E = ['January','February','March','April','May','June','July','August','September','October','November','December'];
const D = {
ar: {
src_credit: 'ذابحه الثقه موري كوده قدام... محد',
privacy_terms: 'سياسه الخصوصيه وشروط الاستخدام',
source_link: 'الكود قدامك',
privacy_link: 'الخصوصية',
create_title: 'منشور جديد',
create_placeholder: 'الله يرحم والديك اذا تعفن دماغي انت كذا ماتبني هدمت المشروع بكبره',
publish: 'انشر',
need_text: 'اكتب نصاً أو اختر ملفاً',
publishing: 'جاري النشر…',
no_conn: 'لا يوجد اتصال',
published: 'تم النشر',
error: 'خطأ',
edit_title: 'تعديل',
l_avatar: 'الافتار',
l_banner: 'الغلاف',
l_bg: 'الخلفيه',
l_displayname: 'الاسم الظاهر',
l_bio: 'البايو',
l_namecolor: 'لون الاسم',
l_biocolor: 'لون البايو',
l_dob: 'تاريخ الميلاد',
l_jointext: 'نص الانضمام',
l_joincolor: 'لون الانضمام',
l_tabcolor: 'لون التبويبات',
l_tab1: 'اسم التبويب ١',
l_tab2: 'اسم التبويب ٢',
l_editbtn: 'زر التخصيص',
l_editbtncolor: 'لون زر التعديل',
l_postcolor: 'لون المنشور',
support_label: 'دعم',
support_free: 'بدون رسوم',
danger_note: 'مايحتاج اقول',
delete_account: 'حذف الحساب',
save: 'حفظ',
cancel: 'إلغاء',
back: 'رجوع',
mobile_note: 'نسخة الجوال ماهي جاهزه ادخل من البيسي او لف الشاشه',
support_ph: 'رابط ',
save_failed: 'فشل الحفظ، حاول مره أخرى',
no_server: 'تعذر الاتصال بالخادم',
user_missing: 'المستخدم غير موجود',
not_logged: 'لست مسجل الدخول',
load_error: 'حدث خطأ في التحميل',
empty_posts: 'لا توجد منشورات بعد',
empty_likes: 'لا توجد إعجابات بعد',
tab_posts: 'منشوراتي',
tab_likes: 'حبييييت <3',
d_join: 'عضو منذ',
d_editbtn: 'تخصيص',
d_now: 'الآن',
d_ago: 'قبل',
d_support: 'دعم',
no_comments: 'لا تعليقات',
beta_note: 'نسخه تجريبيه مغلقه',
tools: 'الأدوات',
w_custom_text: 'نص مخصص',
w_display_name: 'الاسم الظاهر',
w_bio: 'البايو',
w_banner: 'الغلاف',
w_avatar: 'الافتار',
w_posts: 'منشورات',
w_likes: 'إعجابات',
w_joined: 'تاريخ الانضمام',
w_links: 'روابط الدعم',
actions: 'إجراءات',
download: 'حفظ',
apply: 'تطبيق',
delete_layout: 'حذف الملامح',
tools_label: 'أدوات:',
del: 'حذف',
layer_label: 'الطبقه:',
forward: 'تقديم للأمام',
backward: 'تأخير للخلف',
rotate_label: 'الدوران:',
empty: 'تفريغ',
no_selection: 'لم تُحدَّد أي خانه',
cells_selected: '{{n}} خانه محدّده',
ml_bad_customtext: 'نص مخصص أطول من 20000 حرف',
ml_bad_media_rejected: 'الملف مرفوض (النوع أو الحجم أو الأبعاد)',
custom_text_ph: 'نص مخصص',
w_avatar: 'الافتار',
w_avatar_ph: 'لا يوجد افاتار',
w_banner_ph: 'لا يوجد غلاف',
w_bio_ph: 'لا يوجد بايو',
w_posts_ph: 'لا توجد منشورات',
w_likes_ph: 'لا توجد إعجابات',
w_joined_ph: 'لا يوجد تاريخ',
w_links_ph: 'لا توجد روابط',
prompt_text: 'أدخل النص',
m_crop_w: 'قص العرض:',
m_crop_h: 'قص الارتفاع:',
m_rotation: 'الدوران:',
m_bg: 'الخلفية:',
m_color: 'اللون:',
m_text_color: 'لون النص:',
m_font_size: 'حجم الخط:',
m_z_index: 'الطبقة:',
m_media: 'الملفات:',
m_widget: 'الودجت:',
m_text: 'نص مخصص',
w_display_name_ph: 'الاسم الظاهر',
w_bio_ph: 'البايو',
w_banner_ph: 'الغلاف',
w_avatar_ph: 'الافتار',
w_posts_ph: 'لا توجد منشورات',
w_likes_ph: 'لا توجد إعجابات',
w_joined_ph: 'لا يوجد تاريخ',
w_links_ph: 'لا توجد روابط',
m_bad_customtext: 'نص مخصص أطول من 20000 حرف',
ml_bad_media_rejected: 'الملف مرفوض (النوع أو الحجم أو الأبعاد)',
create_placeholder: 'Bless your parents, you talk nothing but nonsense — you wrecked the whole project with that big mouth',
no_conn: 'No connection',
published: 'Published',
error: 'Error',
edit_title: 'Edit',
l_avatar: 'Avatar',
l_banner: 'Banner',
l_bg: 'Background',
l_displayname: 'Display name',
l_bio: 'Bio',
l_namecolor: 'Name color',
l_biocolor: 'Bio color',
l_dob: 'Date of birth',
l_jointext: 'Join text',
l_joincolor: 'Join color',
l_tabcolor: 'Tab color',
l_tab1: 'Tab 1',
l_tab2: 'Tab 2',
l_editbtn: 'Edit button',
l_editbtncolor: 'Edit button color',
l_postcolor: 'Post color',
support_label: 'Support',
support_free: 'Free',
danger_note: 'Danger note',
delete_account: 'Delete account',
save: 'Save',
cancel: 'Cancel',
back: 'Back',
mobile_note: 'Mobile version not ready, use desktop or rotate screen',
support_ph: 'Link ',
save_failed: 'Save failed, try again',
no_server: 'Cannot connect to server',
user_missing: 'User not found',
not_logged: 'Not logged in',
load_error: 'Load error',
empty_posts: 'No posts yet',
empty_likes: 'No likes yet',
tab_posts: 'My posts',
tab_likes: 'Favorites <3',
d_join: 'Member since',
d_editbtn: 'Customize',
beta_note: 'Closed beta',
tools: 'Tools',
w_custom_text: 'Custom text',
w_display_name: 'Display name',
w_bio: 'Bio',
w_banner: 'Banner',
w_avatar: 'Avatar',
w_posts: 'Posts',
w_likes: 'Likes',
w_joined: 'Joined',
w_links: 'Support links',
actions: 'Actions',
download: 'Download',
apply: 'Apply',
delete_layout: 'Delete layout',
tools_label: 'Tools:',
del: 'Delete',
layer_label: 'Layer:',
forward: 'Forward',
backward: 'Backward',
rotate_label: 'Rotation:',
empty: 'Empty',
no_selection: 'No cell selected',
cells_selected: '{{n}} cells selected',
ml_bad_customtext: 'Custom text longer than 20000 chars',
ml_bad_media_rejected: 'File rejected (type, size, or dimensions)',
custom_text_ph: 'Custom text',
w_avatar: 'Avatar',
w_avatar_ph: 'No avatar',
w_banner_ph: 'No banner',
w_bio_ph: 'No bio',
w_posts_ph: 'No posts',
w_likes_ph: 'No likes',
w_joined_ph: 'No join date',
w_links_ph: 'No links',
prompt_text: 'Enter text',
m_crop_w: 'Crop width:',
m_crop_h: 'Crop height:',
m_rotation: 'Rotation:',
m_bg: 'Background:',
m_color: 'Color:',
m_text_color: 'Text color:',
m_font_size: 'Font size:',
m_z_index: 'Z-index:',
m_media: 'Media:',
m_widget: 'Widget:',
m_text: 'Custom text'
},
en: {
src_credit: 'Killed the trust, code is out there... no one',
privacy_terms: 'Privacy & Terms',
source_link: 'Source code',
privacy_link: 'Privacy',
create_title: 'New Post',
create_placeholder: 'Bless your parents, you talk nothing but nonsense — you wrecked the whole project with that big mouth',
publish: 'Publish',
need_text: 'Write text or pick a file',
publishing: 'Publishing…',
no_conn: 'No connection',
published: 'Published',
error: 'Error',
edit_title: 'Edit',
l_avatar: 'Avatar',
l_banner: 'Banner',
l_bg: 'Background',
l_displayname: 'Display name',
l_bio: 'Bio',
l_namecolor: 'Name color',
l_biocolor: 'Bio color',
l_dob: 'Date of birth',
l_jointext: 'Join text',
l_joincolor: 'Join color',
l_tabcolor: 'Tab color',
l_tab1: 'Tab 1',
l_tab2: 'Tab 2',
l_editbtn: 'Edit button',
l_editbtncolor: 'Edit button color',
l_postcolor: 'Post color',
support_label: 'Support',
support_free: 'Free',
danger_note: 'Danger note',
delete_account: 'Delete account',
save: 'Save',
cancel: 'Cancel',
back: 'Back',
mobile_note: 'Mobile version not ready, use desktop or rotate screen',
support_ph: 'Link ',
save_failed: 'Save failed, try again',
no_server: 'Cannot connect to server',
user_missing: 'User not found',
not_logged: 'Not logged in',
load_error: 'Load error',
empty_posts: 'No posts yet',
empty_likes: 'No likes yet',
tab_posts: 'My posts',
tab_likes: 'Favorites <3',
d_join: 'Member since',
d_editbtn: 'Customize',
d_now: 'now',
d_ago: 'ago',
d_support: 'Support',
no_comments: 'No comments',
beta_note: 'Closed beta',
tools: 'Tools',
w_custom_text: 'Custom text',
w_display_name: 'Display name',
w_bio: 'Bio',
w_banner: 'Banner',
w_avatar: 'Avatar',
w_posts: 'Posts',
w_likes: 'Likes',
w_joined: 'Joined',
w_links: 'Support links',
actions: 'Actions',
download: 'Download',
apply: 'Apply',
delete_layout: 'Delete layout',
tools_label: 'Tools:',
del: 'Delete',
layer_label: 'Layer:',
forward: 'Forward',
backward: 'Backward',
rotate_label: 'Rotation:',
empty: 'Empty',
no_selection: 'No cell selected',
cells_selected: '{{n}} cells selected',
ml_bad_customtext: 'Custom text longer than 20000 chars',
ml_bad_media_rejected: 'File rejected (type, size, or dimensions)',
custom_text_ph: 'Custom text',
w_avatar: 'Avatar',
w_avatar_ph: 'No avatar',
w_banner_ph: 'No banner',
w_bio_ph: 'No bio',
w_posts_ph: 'No posts',
w_likes_ph: 'No likes',
w_joined_ph: 'No join date',
w_links_ph: 'No links',
prompt_text: 'Enter text',
m_crop_w: 'Crop width:',
m_crop_h: 'Crop height:',
m_rotation: 'Rotation:',
m_bg: 'Background:',
m_color: 'Color:',
m_text_color: 'Text color:',
m_font_size: 'Font size:',
m_z_index: 'Z-index:',
m_media: 'Media:',
m_widget: 'Widget:',
m_text: 'Custom text'
}
};
const L = localStorage.getItem(S) || 'ar';
function t(k) { return (D[L] || D.ar)[k] || k; }
window.t = t;
function loadPixelartFont() {
if (document.getElementById('pixelart-font')) return;
const link = document.createElement('link');
link.id = 'pixelart-font';
link.rel = 'stylesheet';
link.href = '/media/pixelart/pixelart-icons-font.css';
document.head.appendChild(link);
}
function loadVt323Font() {
if (document.getElementById('vt323-font')) return;
const link = document.createElement('link');
link.id = 'vt323-font';
link.rel = 'stylesheet';
link.href = 'https://fonts.googleapis.com/css2?family=VT323&display=swap';
document.head.appendChild(link);
}
function unloadVt323Font() {
const link = document.getElementById('vt323-font');
if (link) link.remove();
}
function tc(c) {
const n = document.getElementById('langToggle');
if (n) n.textContent = c === 'ar' ? 'English' : 'العربية';
document.documentElement.lang = c;
localStorage.setItem(S, c);
document.cookie = 'lang=' + c + ';path=/;max-age=31536000;samesite=lax';
document.body.setAttribute('lang', c);
loadPixelartFont();
if (c === 'en') {
loadVt323Font();
} else {
unloadVt323Font();
}
document.querySelectorAll('[data-i18n]').forEach(function (e) {
const k = e.getAttribute('data-i18n');
if (k) { const v = t(k); if (v !== k) e.textContent = v; }
});
document.querySelectorAll('[data-i18n-ph]').forEach(function (e) {
const k = e.getAttribute('data-i18n-ph');
if (k) { const v = t(k); if (v !== k) e.placeholder = v; }
});
document.querySelectorAll('[data-i18n-title]').forEach(function (e) {
const k = e.getAttribute('data-i18n-title');
if (k) { const v = t(k); if (v !== k) e.title = v; }
});
}
function ai() { const c = localStorage.getItem(S) || 'ar'; tc(c); }
function mt() { const c = document.documentElement.lang === 'ar' ? 'en' : 'ar'; tc(c); }
document.addEventListener('DOMContentLoaded', function () {
ai();
document.getElementById('langToggle')?.addEventListener('click', mt);
new MutationObserver(function (m) {
m.forEach(function (x) {
x.addedNodes.forEach(function (n) {
if (n.nodeType === 1) {
n.querySelectorAll('[data-i18n],[data-i18n-ph],[data-i18n-title]').forEach(function (e) {
const k = e.getAttribute('data-i18n') || e.getAttribute('data-i18n-ph') || e.getAttribute('data-i18n-title');
if (k) {
const v = t(k);
if (v !== k) {
if (e.placeholder !== void 0) e.placeholder = v;
else if (e.title !== void 0) e.title = v;
else e.textContent = v;
}
}
});
}
});
});
}).observe(document.body, { childList: true, subtree: true });
var e = document.createEvent('Event');
e.initEvent('langchange', true, true);
document.dispatchEvent(e);
});
})();lang.css
.post-card {
background:rgba(255,255,255,0.06);
backdrop-filter:blur(16px); -webkit-backdrop-filter:blur(16px);
border:1px solid rgba(255,255,255,0.1);
border-radius:12px; padding:28px;
box-shadow:0 8px 32px rgba(0,0,0,0.3);
}
.post-head { display:flex; align-items:center; gap:10px; margin-bottom:10px; padding-bottom:10px; border-bottom:1px solid rgba(255,255,255,0.08);
}
.post-av { width:36px; height:36px; border-radius:50%; overflow:hidden; border:1px solid rgba(255,255,255,0.1); flex-shrink:0;
}
.post-av img, .post-av video { width:100%; height:100%; object-fit:cover;
}
.post-nm { font-size:16px; color:#fff;
}
.post-un { font-size:12px; color:#fff;
}
.post-time { font-size:11px; color:#fff; margin-bottom:6px;
}
.post-body { font-size:15px; line-height:1.6; color:#fff; word-break:break-word; margin-bottom:10px; font-family:monospace;
}
.post-media { display:flex; flex-wrap:wrap; gap:8px; margin-bottom:10px;
}
.post-media img, .post-media video { width:auto; height:auto; max-width:265px; max-height:315px; object-fit:contain; border-radius:8px;
}
.post-media.open img, .post-media.open video { width:auto; height:auto; max-width:100%; max-height:none; object-fit:contain; border-radius:8px;
}
.post-stats { display:flex; gap:16px; font-size:14px; color:#fff; padding-top:12px; margin-top:10px; border-top:1px solid rgba(255,255,255,0.08);
}
.post-stats .spl { margin-right:auto;
}
.post-stats svg { width:14px; height:14px; vertical-align:middle;
}
.post-stats i { font-size:14px; vertical-align:middle;
}
.post-stats span { cursor:default; display:flex; align-items:center; gap:4px;
}
.post-stats .act { cursor:pointer; transition:color 0.15s, opacity 0.15s;
}
.post-stats .act:hover { color:#888;
}
.post-stats .act.sending { opacity:0.5; cursor:wait; pointer-events:none;
}
.cmt-area { border-top:1px solid rgba(255,255,255,0.06); margin-top:10px; padding-top:10px;
}
.cmt-list { display:flex; flex-direction:column; gap:8px; margin-bottom:10px; max-height:200px; overflow-y:auto;
}
.cmt-row { display:flex; gap:8px; align-items:flex-start; font-size:13px;
}
.cmt-row .ca { width:24px; height:24px; border-radius:50%; overflow:hidden; flex-shrink:0;
}
.cmt-row .ca img { width:100%; height:100%; object-fit:cover;
}
.cmt-row .cb { flex:1;
}
.cmt-row .cu { color:#fff;
}
.cmt-row .ct { color:#fff; font-family:monospace;
}
.cmt-inp { display:flex; gap:8px;
}
.cmt-inp input {
flex:1; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.1);
border-radius:6px; padding:6px 10px; color:#fff; font-size:14px; outline:none; font-family:monospace;
}
.cmt-inp input:focus { border-color:rgba(255,255,255,0.3);
}
.cmt-inp button {
background:rgba(255,255,255,0.08); border:1px solid rgba(255,255,255,0.1);
border-radius:6px; padding:6px 14px; color:#fff; font-size:14px; cursor:pointer; font-family:monospace;
}
.cmt-inp button:hover { background:rgba(255,255,255,0.15); }
.cmt-inp button:disabled { opacity:0.5; cursor:wait; }
.empty { text-align:center; color:#fff; padding:60px 20px; font-size:18px; letter-spacing:2px; opacity:0.5;
}
@font-face {
font-family: 'wisal-font-ar';
src: url('/media/fonts/unixel-Regular.woff2') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: block;
}
:root {
--wisal-font: 'wisal-font-ar', 'Noto Kufi Arabic', 'Noto Sans Arabic', 'DejaVu Sans', sans-serif;
}
body,
body * {
font-family: monospace;
letter-spacing: 0;
}
.pixel,
.pixel * {
font-family: var(--wisal-font);
}
.mono,
.mono *,
.legal,
.legal * {
font-family: monospace !important;
}
.post-card,
.post-card *,
.post-body,
.post-nm,
.post-un,
.post-time,
.post-stats,
.post-media,
.cmt-area,
.cmt-list,
.cmt-row,
.cmt-inp {
font-family: monospace !important;
}
[class^="pixelart-icons-font-"],
[class*=" pixelart-icons-font-"],
[class^="pixelart-icons-font-"]::before,
[class*=" pixelart-icons-font-"]::before {
font-family: 'pixelart-icons-font' !important;
}
.num,
#rotate-val,
#cell-info,
.post-time,
.post-un,
.wk-timer {
direction: ltr;
unicode-bidi: isolate;
}
body { text-align: inherit;
}
.madar-txt {
white-space: normal;
overflow-wrap: break-word;
line-height: 1.2;
max-width: 190px;
text-align: center;
word-break: keep-all;
letter-spacing: 0;
margin-bottom: 14px;
}
#btnCreate { font-size: 38px !important; line-height: 1.3; margin-bottom: 22px;
}
#btnCugrid { font-size: 58px !important; margin-bottom: 18px;
}
#btnUserbase { font-size: 34px !important; white-space: normal; max-width: 190px; line-height: 1.25;
}
#btnLogin { font-size: 40px !important;
}
.ub-l2 { font-size: 34px !important;
}
.madar-avatar { width:120px; height:120px; margin-bottom:30px;
}
.madar-side { padding:64px 0 30px;
}
#btnUserbase { margin-top:auto;
}
#btnLogin { margin-top:18px; margin-bottom:0;
}
.title-glass {
font-size: 104px !important;
letter-spacing: 0 !important;
text-transform: none !important;
filter: none !important;
text-shadow: 0 3px 16px rgba(0,0,0,0.9), 0 0 3px rgba(0,0,0,0.8);
line-height: 1.1;
color: rgba(255,255,255,0.92) !important;
-webkit-text-fill-color: rgba(255,255,255,0.92) !important;
}
.w-hdr { font-size: 22px; letter-spacing: 0;
}
.post-body, .cmt-row, .post-nm, .un-bio, .editprofile, .mlamh-text { direction: rtl; }
.post-time, .post-un, .wk-timer { direction: ltr; }
.pl { cursor:pointer; }
.media-vis { display:flex; flex-wrap:wrap; gap:8px; }
.media-hid { display:none; flex-wrap:wrap; gap:8px; }
.more, .less, .mmore {
background:none; border:none; padding:0; color:#fff; cursor:pointer;
font:inherit; font-size:13px; text-decoration:underline; font-family:inherit;
}
.mmore { display:block; margin-top:6px; }
login.html
<!DOCTYPE html>
<html lang="ar">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>وِصَال</title>
<link rel="icon" type="image/png" sizes="32x32" href="/media/favicon.png">
<link rel="icon" type="image/png" sizes="16x16" href="/media/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/media/favicon-180.png">
<link rel="icon" type="image/png" sizes="192x192" href="/media/favicon-192.png">
<meta name="theme-color" content="#030305">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
<link href="/media/pixelart/pixelart-icons-font.css" rel="stylesheet">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body {
background:#000 url('/media/wbg/sugoi.webp') center/cover no-repeat; color:#fff;
font-family:'VT323',monospace;
height:100vh; display:flex;
align-items:center; justify-content:center;
overflow:hidden; }
@media (max-width:700px) {
body { background-image:url('/media/wbg/sugoi-960.webp'); } }
.center {
display:flex; flex-direction:column;
align-items:center; gap:14px; }
.avatar-top {
display:flex; align-items:center; justify-content:center; }
.avatar-top img, .avatar-top video {
width:165px; height:165px; border-radius:50%;
object-fit:cover;
box-shadow:0 0 20px rgba(255,255,255,0.15), 0 0 60px rgba(255,255,255,0.05);
border:2px solid rgba(255,255,255,0.15);
background:rgba(255,255,255,0.03); }
.title-glass {
display: inline-block;
font-size:65px; letter-spacing:4px; text-transform:uppercase;
font-family:monospace;
text-align:center;
line-height:1;
color: rgba(255, 255, 255, 0.45);
-webkit-text-fill-color: rgba(255, 255, 255, 0.45);
filter:
drop-shadow(0 1px 2px rgba(255, 255, 255, 0.18))
drop-shadow(0 4px 12px rgba(255, 255, 255, 0.06)); }
.glass-card {
width:400px; height:40px;
background:rgba(255,255,255,0.45);
border:1px solid rgba(255,255,255,0.08);
border-radius:18px;
backdrop-filter:blur(12px);
-webkit-backdrop-filter:blur(12px);
display:flex; align-items:center;
cursor:text;
transition:background 0.2s, border-color 0.2s;
position:relative;
flex-shrink:0;
padding:0 12px; }
.glass-card:hover {
background:rgba(255,255,255,0.08);
border-color:rgba(255,255,255,0.15); }
.glass-card .lbl {
font-size:14px; letter-spacing:2px;
color:#000;
font-family:monospace;
text-transform:uppercase;
pointer-events:none;
white-space:nowrap;
margin-right:8px; }
.glass-card input {
flex:1; height:100%;
background:transparent; border:none; outline:none;
color:#000; font-family:monospace; font-size:14px;
padding:0; }
#policyBox {
display:none;
width:400px; max-height:260px; overflow-y:auto;
background:rgba(0,0,0,0.7);
border:1px solid rgba(255,255,255,0.08);
border-radius:12px;
backdrop-filter:blur(8px);
-webkit-backdrop-filter:blur(8px);
padding:12px 14px;
font-family:monospace;
font-size:11px;
color:rgba(255,255,255,0.7);
line-height:1.4; }
#policyBox label {
display:flex; align-items:center; gap:8px;
cursor:pointer; margin-top:8px; padding-top:8px;
border-top:1px solid rgba(255,255,255,0.06);
font-family:monospace;
font-size:12px; color:rgba(255,255,255,0.8); }
#policyBox input[type=checkbox] {
width:14px; height:14px; cursor:pointer; }
#policyBox::-webkit-scrollbar { width:4px; }
#policyBox::-webkit-scrollbar-track { background:transparent; }
#policyBox::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); border-radius:2px; }
#policyBox .pol-title {
font-size:13px; color:rgba(255,255,255,0.6); margin-bottom:6px; }
#policyBox code {
color:rgba(255,255,255,0.4); font-size:10px; }
#bgCredit { position:fixed; right:12px; bottom:8px; color:#fff; font-size:11px; pointer-events:none; z-index:9999999; font-family:sans-serif; }
</style>
<link href="/media/lang.css" rel="stylesheet">
</head>
<body data-home="/madar" class="pixel">
<div class="center">
<div class="title-glass">وِصَال</div>
<div class="avatar-top"><img src="/media/defaultavatar/af.webp" id="randAvatar"></div>
<form id="loginForm">
<div class="glass-card" id="loginCard">
<div class="lbl">دخول</div>
<input type="text" id="loginInput" autocomplete="off" maxlength="120" spellcheck="false" enterkeyhint="go">
</div>
</form>
<form id="signForm" onsubmit="return false">
<div class="glass-card" id="signCard">
<div class="lbl">تسجيل</div>
<input type="text" id="signInput" autocomplete="off" maxlength="30" spellcheck="false" enterkeyhint="go">
</div>
</form>
<div id="policyBox"></div>
</div>
<a href="/privacy" style="position:fixed;bottom:10px;left:10px;color:#fff;font-size:11px;text-decoration:none;font-family:monospace;z-index:10;">سياسه الخصوصيه وشروط الاستخدام</a>
<script src="/media/app.js"></script>
<script src="/media/login"></script>
</body>
</html>
login.js
(function(){const g=p=>location.href='/'+p;window.g=go=g;const l=document.getElementById('loginCard'),s=document.getElementById('signCard');l.addEventListener('mouseenter',()=>l.querySelector('.lbl').textContent='معرّف الحساب');l.addEventListener('mouseleave',()=>l.querySelector('.lbl').textContent='دخول');s.addEventListener('mouseenter',()=>s.querySelector('.lbl').textContent='اسم المستخدم');s.addEventListener('mouseleave',()=>s.querySelector('.lbl').textContent='تسجيل');document.getElementById('loginCard').addEventListener('click',()=>{const i=document.getElementById('loginInput');if(i){i.focus();if(i.value.trim())document.getElementById('loginForm').requestSubmit();}});document.getElementById('loginForm').addEventListener('submit',e=>{e.preventDefault();const f=new FormData();f.append('id',document.getElementById('loginInput').value);fetch('/login',{method:'POST',body:f}).then(r=>r.ok?r.text():'').then(u=>{if(u)g('madar')})});document.getElementById('signInput').addEventListener('keyup',e=>{if(e.key==='Enter'&&e.target.value.trim()){const f=new FormData();f.append('username',e.target.value.trim());fetch('/api/sign',{method:'POST',body:f}).then(r=>r&&r.ok?r.text():'').then(t=>{if(t){document.getElementById('policyBox').style.display='block';document.getElementById('policyBox').innerHTML='<div class="pol-title">سياسات وِصَال</div><b>شروط الاستخدام وسياسه الخصوصيه</b><br><br>بإنشائك حساباً فأنت تؤكد أنك قرأتها ووافقت عليها.<br><label><input type="checkbox" id="policyCheck"> وافق</label><div style="margin-top:6px;text-align:right;"><button onclick="ds()" style="background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.15);border-radius:8px;padding:4px 18px;font-family:inherit;font-size:13px;cursor:pointer;">تسجيل</button></div>'}else alert('اسم المستخدم محجوز')});e.target.value=''}});function ds(){const c=document.getElementById('policyCheck');if(!c||!c.checked){alert('يجب الموافقه على السياسات');return}fetch('/api/sign?tms=1',{method:'POST'}).catch(()=>null).then(r=>r&&r.ok?r.text():'').then(t=>{if(!t){alert('تعذر التسجيل');return}document.getElementById('policyBox').style.display='none';alert(t);location.href='/madar'})}Object.assign(window,{ds, dosign:ds})})();
loginp.html
<!DOCTYPE html>
<html lang="ar">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>وِصَال</title>
<link rel="icon" type="image/png" sizes="32x32" href="/media/favicon.png">
<link rel="icon" type="image/png" sizes="16x16" href="/media/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/media/favicon-180.png">
<link rel="icon" type="image/png" sizes="192x192" href="/media/favicon-192.png">
<meta name="theme-color" content="#030305">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
<link href="/media/pixelart/pixelart-icons-font.css" rel="stylesheet">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body {
background:#000 url('/media/wbg/sugoi.webp') center/cover no-repeat; color:#fff;
font-family:'VT323',monospace;
height:100vh; display:flex;
align-items:center; justify-content:center;
overflow:hidden;
padding:20px; }
@media (max-width:700px) {
body { background-image:url('/media/wbg/sugoi-960.webp'); } }
.center {
display:flex; flex-direction:column;
align-items:center; gap:16px;
width:100%; max-width:380px; }
.avatar-top {
display:flex; align-items:center; justify-content:center; }
.avatar-top img, .avatar-top video {
width:140px; height:140px; border-radius:50%;
object-fit:cover;
box-shadow:0 0 20px rgba(255,255,255,0.15), 0 0 60px rgba(255,255,255,0.05);
border:2px solid rgba(255,255,255,0.15);
background:rgba(255,255,255,0.03); }
.title-glass {
display: inline-block;
font-size:46px; letter-spacing:3px; text-transform:uppercase;
font-family:monospace;
text-align:center;
line-height:1;
color: rgba(255, 255, 255, 0.45);
-webkit-text-fill-color: rgba(255, 255, 255, 0.45);
filter:
drop-shadow(0 1px 2px rgba(255, 255, 255, 0.18))
drop-shadow(0 4px 12px rgba(255, 255, 255, 0.06)); }
.glass-card {
width:100%; height:52px;
background:rgba(255,255,255,0.45);
border:1px solid rgba(255,255,255,0.12);
border-radius:16px;
backdrop-filter:blur(14px);
-webkit-backdrop-filter:blur(14px);
display:flex; align-items:center;
cursor:text;
transition:background 0.2s, border-color 0.2s;
position:relative;
flex-shrink:0;
padding:0 16px; }
.glass-card:hover {
background:rgba(255,255,255,0.1);
border-color:rgba(255,255,255,0.2); }
.glass-card .lbl {
font-size:18px; letter-spacing:2px;
color:#fff;
font-family:monospace;
text-transform:uppercase;
pointer-events:none;
white-space:nowrap;
margin-right:10px;
opacity:0.85; }
.glass-card input {
flex:1; height:100%;
background:transparent; border:none; outline:none;
color:#fff; font-family:monospace; font-size:18px;
padding:0;
min-width:0; }
#policyBox {
display:none;
width:100%; max-height:45vh; overflow-y:auto;
background:rgba(0,0,0,0.75);
border:1px solid rgba(255,255,255,0.12);
border-radius:14px;
backdrop-filter:blur(12px);
-webkit-backdrop-filter:blur(12px);
padding:14px 16px;
font-family:monospace;
font-size:12px;
color:rgba(255,255,255,0.75);
line-height:1.5; }
#policyBox label {
display:flex; align-items:center; gap:8px;
cursor:pointer; margin-top:8px; padding-top:8px;
border-top:1px solid rgba(255,255,255,0.08);
font-size:12px; color:rgba(255,255,255,0.85); }
#policyBox input[type=checkbox] {
width:18px; height:18px; cursor:pointer; flex-shrink:0; }
#policyBox::-webkit-scrollbar { width:4px; }
#policyBox::-webkit-scrollbar-track { background:transparent; }
#policyBox::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.15); border-radius:2px; }
#policyBox .pol-title {
font-size:14px; color:rgba(255,255,255,0.7); margin-bottom:6px; }
#policyBox code {
color:rgba(255,255,255,0.45); font-size:11px; }
#bgCredit { position:fixed; right:12px; bottom:8px; color:#fff; font-size:11px; pointer-events:none; z-index:9999999; font-family:sans-serif; }
</style>
<link href="/media/lang.css" rel="stylesheet">
</head>
<body data-home="/madar-phone" class="pixel">
<div class="center">
<div class="title-glass">وِصَال</div>
<div class="avatar-top"><img src="/media/defaultavatar/af.webp" id="randAvatar"></div>
<form id="loginForm" onsubmit="return false">
<div class="glass-card" id="loginCard">
<div class="lbl">دخول</div>
<input type="text" id="loginInput" autocomplete="off" maxlength="120" spellcheck="false" enterkeyhint="go">
</div>
</form>
<form id="signForm" onsubmit="return false">
<div class="glass-card" id="signCard">
<div class="lbl">تسجيل</div>
<input type="text" id="signInput" autocomplete="off" maxlength="30" spellcheck="false" enterkeyhint="go">
</div>
</form>
<div id="policyBox"></div>
</div>
<a href="/privacy" style="position:fixed;bottom:10px;left:10px;color:#fff;font-size:11px;text-decoration:none;font-family:monospace;z-index:10;">سياسه الخصوصيه وشروط الاستخدام</a>
<script src="/media/app.js"></script>
<script src="/media/login"></script>
<div style="position:fixed;bottom:6px;left:0;right:0;text-align:center;font-size:11px;line-height:1.5;font-family:monospace;color:#fff;background:#000;padding:6px 8px;z-index:99999;letter-spacing:0;pointer-events:none">نسخة الجوال ماهي جاهزه ادخل من البيسي او لف الشاشه</div>
</body>
</html>
madar.html
<!DOCTYPE html>
<html lang="ar">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>وِصَال</title>
<link rel="icon" type="image/png" sizes="32x32" href="/media/favicon.png">
<link rel="icon" type="image/png" sizes="16x16" href="/media/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/media/favicon-180.png">
<link rel="icon" type="image/png" sizes="192x192" href="/media/favicon-192.png">
<meta name="theme-color" content="#030305">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
<link href="/media/pixelart/pixelart-icons-font.css" rel="stylesheet">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body {
background:#030305; color:#fff; font-family:'VT323',monospace;
height:100dvh; overflow:hidden; }
body::before {
content:''; position:fixed; inset:0; width:auto; height:auto;
background-image:url('/media/wbg/madarbg.webp');
background-size:1920px 1080px;
background-position:center;
background-repeat:no-repeat;
background-color:#030305;
pointer-events:none; z-index:0; }
@media (max-width:1400px) {
body::before { background-image:url('/media/wbg/madarbg-960.webp'); } }
@media (max-width:700px) {
body::before { background-image:url('/media/wbg/madarbg-960.webp'); } }
.scroll-area {
position:relative; z-index:1;
width:calc(100% - 206px); height:100dvh;
overflow-y:auto;
padding:27px 62px 27px 268px;
scrollbar-width:none; -ms-overflow-style:none; }
.scroll-area::-webkit-scrollbar { display:none; }
.w-row {
display:flex; gap:48px; justify-content:center; flex-wrap:wrap;
width:100%; max-width:890px; margin:0 auto;
transform:translateX(-60px); }
.w-col { flex:1 1 340px; min-width:300px; display:flex; flex-direction:column; gap:13px; }
#njomcol, #madarcol { display:flex; flex-direction:column; gap:13px; }
.w-left { max-width:421px; }
.w-right { max-width:421px; }
.w-hdr { font-size:22px; color:#fff; text-transform:uppercase; letter-spacing:3px; text-shadow:0 2px 8px rgba(0,0,0,0.5); min-height:56px; display:flex; align-items:center; flex-wrap:wrap; }
.w-hdr > * { vertical-align:middle; }
.wk-timer {
display:inline-block; font-size:13px; color:#fff;
vertical-align:middle; margin-left:11px;
letter-spacing:1px; }
.madar-side {
position:fixed; top:0; right:0; width:206px; height:100dvh;
background-image:url('/media/wbg/madarpanel.webp');
background-size:100% 100%; background-repeat:no-repeat;
z-index:9999;
box-shadow:-5px 0 15px rgba(0,0,0,0.8);
display:flex; flex-direction:column; align-items:center; padding-top:26px; }
.madar-brand {
font-size:60px; color:#fff; letter-spacing:4px; line-height:1;
text-shadow:0 3px 14px rgba(0,0,0,0.95), 0 0 3px rgba(0,0,0,0.9);
margin-bottom:26px; }
.madar-avatar {
width:126px; height:126px; border-radius:14px; cursor:pointer;
object-fit:cover; border:1px solid transparent; margin-bottom:34px; }
.madar-txt {
font-family:'VT323',monospace; color:#fff;
background:none; border:none; cursor:pointer;
text-transform:uppercase; letter-spacing:2px;
text-shadow:0 2px 8px rgba(0,0,0,0.5);
white-space:normal; overflow-wrap:break-word; line-height:1.15;
text-align:center; max-width:190px; word-break:keep-all;
transition:color 0.15s; margin-bottom:22px; }
#btnCreate { font-size:64px; }
#btnmlamh { font-size:58px; }
#btnUserbase { font-size:44px; }
#btnLogin { font-size:46px; margin-bottom:26px; }
.ub-l2 { font-size:30px; }
.beta-badge {
position:fixed; top:8px; left:8px; z-index:99999;
font-size:25px; color:#fff;
font-family:'VT323',monospace; pointer-events:none;
text-shadow:0 0 8px rgba(0,0,0,0.8); }
.src-link { position:fixed; bottom:36px; left:12px; z-index:100000; }
.src-link a { color:#fff; font-size:16px; font-family:'VT323',monospace; text-decoration:none; transition:color 0.15s; }
.src-link a:hover { color:#ccc; }
.sc-wrap {
position:fixed; top:76px; left:16px; z-index:50;
width:240px; display:flex; flex-direction:column; gap:14px;
max-height:calc(100dvh - 100px); overflow-y:auto;
scrollbar-width:none; -ms-overflow-style:none; }
.sc-wrap::-webkit-scrollbar { display:none; }
.sc-hide-btn {
align-self:flex-start;
background:rgba(255,255,255,0.06);
backdrop-filter:blur(16px); -webkit-backdrop-filter:blur(16px);
border:1px solid rgba(255,255,255,0.1);
color:#fff; font-family:'VT323',monospace; font-size:13px;
text-transform:uppercase; letter-spacing:1px;
padding:5px 14px; border-radius:12px; cursor:pointer;
box-shadow:0 8px 32px rgba(0,0,0,0.3);
-webkit-tap-highlight-color:transparent; outline:none; }
.sc-hide-btn:hover { background:rgba(255,255,255,0.12); }
.sc-card {
background:rgba(255,255,255,0.06);
backdrop-filter:blur(16px); -webkit-backdrop-filter:blur(16px);
border:1px solid rgba(255,255,255,0.1);
border-radius:12px;
padding:14px;
color:#fff;
box-shadow:0 8px 32px rgba(0,0,0,0.3); }
.sc-card h3 {
font-size:17px; letter-spacing:2px; text-transform:uppercase;
font-weight:normal; margin-bottom:10px;
text-shadow:0 2px 8px rgba(0,0,0,0.5); }
.sc-row {
display:flex; align-items:center; gap:9px;
padding:4px 0; cursor:pointer;
pointer-events:auto; }
.sc-row:hover .sc-un { color:#ccc; }
.sc-av {
width:30px; height:30px; border-radius:50%;
object-fit:cover; background:rgba(255,255,255,0.08); flex-shrink:0; }
.sc-un { font-size:15px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
#scTotal { color:#fff; opacity:0.8; font-size:17px; }
.sc-showall { cursor:pointer; font-size:14px; color:#fff; padding-top:6px; text-align:center; pointer-events:auto; }
.sc-showall:hover { color:#ccc; }
.sc-newest { display:flex; align-items:center; gap:10px; }
.sc-newest img, .sc-newest video, .sc-newest .sc-un { pointer-events:auto; }
.sc-newest .sc-un { font-size:16px; }
.sc-av-big { width:44px; height:44px; border-radius:50%; object-fit:cover; background:rgba(255,255,255,0.08); flex-shrink:0; }
#bgCredit { position:fixed; right:12px; bottom:8px; color:#fff; font-size:11px; pointer-events:none; z-index:9999999; font-family:sans-serif; }
@media (max-width:1599px) {
.w-row { transform:none; max-width:890px; }
.sc-wrap { display:none; }
.scroll-area { padding:27px 44px; }
.w-left, .w-right { max-width:421px; }
}
@media (max-width:1000px) {
.madar-side { width:168px; }
.madar-brand { font-size:44px; margin-bottom:10px; }
.madar-avatar { width:96px; height:96px; }
.scroll-area { width:calc(100% - 168px); }
#btnCreate, #btnmlamh, #btnUserbase, #btnLogin { font-size:34px; }
.ub-l2 { font-size:24px; }
}
@media (max-width:1599px) { .src-link a { font-size:13px; } .src-link { bottom:24px; } }
@media (max-width:1200px) { .src-link a { font-size:11px; } .src-link { bottom:18px; } }
@media (max-width:900px) { .src-link a { font-size:10px; } .src-link { bottom:12px; left:8px; } }
@media (max-width:700px) { .src-link a { font-size:9px; } .src-link { bottom:8px; } }
</style>
<link href="/media/lang.css" rel="stylesheet">
<link href="/media/i18n.css" rel="stylesheet">
<script src="/media/i18n"></script>
</head>
<body data-home="/madar" data-login="/login" class="pixel">
<div class="src-link"><a href="/source" data-en="source code">ذابحه الثقه موري كوده قدام... محد</a><br><a href="/rules" data-en="the charter" style="color:#fff;font-size:11px;text-decoration:none;font-family:inherit;">الدستور</a><br><a href="/privacy" data-en="privacy policy & terms" style="color:#fff;font-size:11px;text-decoration:none;font-family:inherit;">سياسه الخصوصيه وشروط الاستخدام</a></div>
<div class="sc-wrap" id="scWrap">
<button class="sc-hide-btn" id="scHideBtn" data-en="idc">ماني مهتم</button>
<div class="sc-card" id="scPeople">
<h3><span data-en="karak addicts club">نادي مدمنين الكرك</span> <span id="scTotal"></span></h3>
<div id="scPeopleList"></div>
<div class="sc-showall" id="scShowAll" data-en="show all">عرض الكل</div>
</div>
<div class="sc-card" id="scWelcome">
<h3 data-en="heeeeey newest one <3">امنظام نظام اجدد واحد يقفلي القدرات</h3>
<div class="sc-newest" id="scNewest"></div>
</div>
</div>
<div class="beta-badge" style="position:fixed;top:8px;left:8px;z-index:99999;font-size:25px;color:#fff;font-family:inherit;pointer-events:none;text-shadow:0 0 8px rgba(0,0,0,0.8);" data-en="closed beta">نسخه تجريبيه مغلقه</div>
<div id="imgViewer" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:999999;cursor:zoom-out;justify-content:center;align-items:center;" onclick="closeimage()"><img id="imgViewerSrc" style="max-width:95%;max-height:95%;object-fit:contain;"><div style="position:fixed;top:16px;right:24px;font-size:32px;color:#fff;cursor:pointer;font-family:monospace;" onclick="closeimage()">x</div></div>
<div class="scroll-area" id="scrollArea">
<div class="w-row">
<div class="w-col w-left">
<div class="w-hdr"><span data-en="njom">نُجُوم</span><span style="font-size:13px;letter-spacing:0;color:#fff;margin-left:7px;white-space:nowrap;" data-en="top 500 this month">أفضل ٥٠٠ هذا الشهر</span><span class="wk-timer" id="wkTimer"></span></div>
<div id="njomcol"></div>
</div>
<div class="w-col w-right">
<div class="w-hdr"><span data-en="madar">مَدَار</span><span style="font-size:14px;letter-spacing:0;color:#fff;margin-left:8px;"><span id="postCount" style="margin-inline-end:8px">…</span><span data-en="post count">عدد المنشورات</span></span></div>
<div id="madarcol"></div>
</div>
</div>
</div>
<div class="madar-side">
<div class="madar-brand" data-en="Wisal">وِصَال</div>
<img class="madar-avatar" id="madaravatar" src="/media/defaultavatar/af.webp" loading="lazy" width="120" height="120" onclick="window.location.href='/profile'">
<button class="madar-txt" id="btnCreate" data-en="post n build">نَشْرُكَ يَبني الأَساس</button>
<button class="madar-txt" id="btnmlamh" data-en="mlamh">ملامح</button>
<button class="madar-txt" id="btnUserbase"><span data-en="wow">النفسيه</span><br><span class="ub-l2" data-en="most underground thing">تحتاج كرك</span></button>
<button class="madar-txt" id="btnLogin" data-en="account">الحساب</button>
</div>
<script src="/media/app"></script>
<script>
let viewq = new Set(), viewt = null;
function flushViews() {
if (!viewq.size) return;
const ids = Array.from(viewq).join(',');
viewq.clear();
if (viewt) { clearTimeout(viewt); viewt = null; }
fetch('/api/feed?col=view&post_id=' + ids, { method: 'POST', keepalive: true }).then(r => json(r, {counts:{}})).then(d => {
const counts = d && d.counts ? d.counts : {};
Object.keys(counts).forEach(pid => {
document.querySelectorAll('.vc[data-pid="'+pid+'"]').forEach(v => {
v.textContent = counts[pid]; }); });
}).catch(() => {}); }
function consumePost(pid) {
viewq.add(pid);
if (!viewt) viewt = setTimeout(flushViews, 4000); }
document.addEventListener('visibilitychange', () => { if (document.hidden) flushViews(); });
window.addEventListener('pagehide', flushViews);
function monthlyTimer() {
let el = document.getElementById('wkTimer');
if (!el) return;
let now = new Date();
let end = new Date(now.getFullYear(), now.getMonth() + 1, 1);
let diff = end - now;
if (diff <= 0) { el.textContent = ''; return; }
let d = Math.floor(diff / 86400000);
let h = Math.floor((diff % 86400000) / 3600000);
let parts = [];
if (d > 0) parts.push(d + ' ي');
parts.push(h + ' س');
el.textContent = ' ' + parts.join(' '); }
async function loadSideCards() {
let r = await json(await fetch('/api/feed?col=side'), null);
if (!r) return;
document.getElementById('scTotal').textContent = r.total || 0;
let pc = document.getElementById('postCount');
if (pc && r.posts !== undefined) pc.textContent = r.posts;
document.getElementById('scPeopleList').innerHTML = sidepeople(r.newest || []);
document.getElementById('scNewest').innerHTML = sidenewest(r.newest_one);
document.getElementById('scShowAll').onclick = async () => {
let rr = await json(await fetch('/api/feed?col=side&all=1'), null);
if (!rr || !rr.all) return;
document.getElementById('scPeopleList').innerHTML = sidepeople(rr.all);
document.getElementById('scShowAll').style.display = 'none'; }; }
function toggleSideCards(force) {
let w = document.getElementById('scWrap');
let hidden = w.style.display === 'none';
w.style.display = force === undefined ? (hidden ? 'flex' : 'none') : (force ? 'flex' : 'none'); }
async function init() {
const uu = await mp();
try {
function needLogin() {
if (!curUser) { location.href = '/login'; return true; }
return false;
}
document.getElementById('btnCreate').addEventListener('click', () => { if (!needLogin()) window.location.href = '/create'; });
document.getElementById('btnmlamh').addEventListener('click', () => { if (!needLogin()) window.location.href = '/mlamh'; });
document.getElementById('btnLogin').addEventListener('click',
() => { location.href = curUser ? '/profile' : '/login'; });
document.getElementById('btnUserbase').addEventListener('click', () => window.location.href = '/users');
document.querySelectorAll('.madar-txt').forEach(el => {
el.addEventListener('mouseenter', () => { let c=colors[Math.floor(Math.random()*colors.length)]; el.style.color=c; });
el.addEventListener('mouseleave', () => { el.style.color='#fff'; }); });
let pav = document.getElementById('madaravatar');
if (pav) {
pav.addEventListener('mouseenter', () => { pav.style.borderColor = colors[Math.floor(Math.random()*colors.length)]; });
pav.addEventListener('mouseleave', () => { pav.style.borderColor = 'transparent'; }); }
if (uu) applyavatar(document.getElementById('madaravatar'), uu.avatar || '');
const [rd, ro] = await Promise.all([
jsonget('/api/feed?col=njom'),
jsonget('/api/feed?col=madar&offset=0&limit=50'),
]);
loadSideCards();
document.getElementById('njomcol').innerHTML = postshtml(rd, 'l');
document.getElementById('madarcol').innerHTML = postshtml(ro, 'r');
let observer = new IntersectionObserver(entries => {
entries.forEach(e => {
if (!e.isIntersecting) return;
let vc = e.target.querySelector('.vc');
if (!vc) return;
let pid = vc.dataset.pid;
if (!pid) return;
observer.unobserve(e.target);
consumePost(pid); }); }, { root: document.getElementById('scrollArea'), threshold: 0.3 });
let watched = new WeakSet();
function watch(el) {
el.querySelectorAll('.post-card').forEach(card => {
if (watched.has(card)) return;
watched.add(card);
observer.observe(card); }); }
watch(document.getElementById('madarcol'));
setInterval(monthlyTimer, 1000);
monthlyTimer();
let firstVc = document.querySelector('#madarcol .vc');
let lpId = firstVc ? Number(firstVc.dataset.pid) : 0;
let polling = false;
setInterval(async () => {
if (polling || document.hidden) return;
polling = true;
try {
let fresh = await jsonget('/api/feed?col=poll&since_id=' + lpId);
if (!fresh || !fresh.length) return;
let sa = document.getElementById('scrollArea');
let col = document.getElementById('madarcol');
if (!sa || !col) return;
let sy = sa.scrollTop;
col.insertAdjacentHTML('afterbegin', postshtml(fresh, 'r'));
sa.scrollTop = sy;
watch(col);
let nv = col.querySelector('.vc');
if (nv) lpId = Number(nv.dataset.pid) || lpId;
} catch (err) {
} finally {
polling = false;
}
}, 15000); } catch(e) {}
}
init();
document.getElementById('scHideBtn').addEventListener('click', () => toggleSideCards());
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeimage(); });
document.getElementById('scrollArea').addEventListener('click', e => {
let t = e.target;
if (t.tagName === 'IMG' && t.closest('.post-media')) viewimage(t.src.replace(/\?w=\d+$/, '')); });
</script>
</body>
</html>
madarp.html
<!DOCTYPE html>
<html lang="ar">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>وِصَال</title>
<link rel="icon" type="image/png" sizes="32x32" href="/media/favicon.png">
<link rel="icon" type="image/png" sizes="16x16" href="/media/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/media/favicon-180.png">
<link rel="icon" type="image/png" sizes="192x192" href="/media/favicon-192.png">
<meta name="theme-color" content="#030305">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
<link href="/media/pixelart/pixelart-icons-font.css" rel="stylesheet">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body {
background:#030305; color:#fff; font-family:'VT323',monospace;
height:100dvh; overflow:hidden; }
body::before {
content:''; position:fixed; inset:0; width:auto; height:auto;
background-image:url('/media/wbg/madarbg.webp');
background-size:1920px 1080px;
background-position:center;
background-repeat:no-repeat;
background-color:#030305;
pointer-events:none; z-index:0; }
@media (max-width:1400px) {
body::before { background-image:url('/media/wbg/madarbg-960.webp'); } }
@media (max-width:700px) {
body::before { background-image:url('/media/wbg/madarbg-960.webp'); } }
.scroll-area {
position:relative; z-index:1;
width:calc(100% - 206px); height:100dvh;
overflow-y:auto; padding:27px;
display:flex; flex-direction:column; align-items:center;
scrollbar-width:none; -ms-overflow-style:none; }
.scroll-area::-webkit-scrollbar { display:none; }
.w-row {
display:flex; gap:45px; justify-content:center;
width:100%; max-width:1035px; margin:0 auto;
transform:translateX(90px); }
.w-col { flex:1; min-width:0; display:flex; flex-direction:column; gap:13px; }
#njomcol, #madarcol { display:flex; flex-direction:column; gap:13px; }
.w-left { max-width:495px; }
.w-right { max-width:495px; }
.w-hdr { font-size:22px; color:#fff; text-transform:uppercase; letter-spacing:3px; text-shadow:0 2px 8px rgba(0,0,0,0.5); min-height:56px; display:flex; align-items:center; flex-wrap:wrap; }
.w-hdr > * { vertical-align:middle; }
.wk-timer {
display:inline-block; font-size:13px; color:#fff;
vertical-align:middle; margin-left:11px;
letter-spacing:1px; }
.madar-side {
position:fixed; top:0; right:0; width:206px; height:100dvh;
background-image:url('/media/wbg/madarpanel.webp');
background-size:100% 100%; background-repeat:no-repeat;
z-index:9999;
box-shadow:-5px 0 15px rgba(0,0,0,0.8);
display:flex; flex-direction:column; align-items:center; padding-top:26px; }
.madar-brand {
font-size:54px; color:#fff; letter-spacing:4px; line-height:1;
text-shadow:0 3px 14px rgba(0,0,0,0.95), 0 0 3px rgba(0,0,0,0.9);
margin-bottom:22px; }
.madar-avatar {
width:126px; height:126px; border-radius:14px; cursor:pointer;
object-fit:cover; border:1px solid transparent; margin-bottom:30px; }
.madar-txt {
font-family:'VT323',monospace; color:#fff;
background:none; border:none; cursor:pointer;
text-transform:uppercase; letter-spacing:2px;
text-shadow:0 2px 8px rgba(0,0,0,0.5);
white-space:normal; overflow-wrap:break-word; line-height:1.15;
text-align:center; max-width:190px; word-break:keep-all;
transition:color 0.15s; margin-bottom:22px; }
#btnCreate { font-size:58px; }
#btnmlamh { font-size:52px; }
#btnUserbase { font-size:40px; }
#btnLogin { font-size:42px; margin-bottom:26px; }
.ub-l2 { font-size:28px; }
.beta-badge {
position:fixed; top:8px; left:8px; z-index:99999;
font-size:25px; color:#fff;
font-family:'VT323',monospace; pointer-events:none;
text-shadow:0 0 8px rgba(0,0,0,0.8); }
.src-link { position:fixed; bottom:36px; left:12px; z-index:100000; }
.src-link a { color:#fff; font-size:11px; font-family:'VT323',monospace; text-decoration:none; transition:color 0.15s; }
.src-link a:hover { color:#ccc; }
.beta-badge { left:12px; }
.menu-btn {
position:fixed; top:12px; right:12px; z-index:100001;
width:46px; height:46px; cursor:pointer;
background:transparent; border:none;
padding:0;
display:flex; flex-direction:column; align-items:center; justify-content:center; gap:9px;
-webkit-tap-highlight-color:transparent; outline:none; }
.menu-btn span { display:block; width:24px; height:3px; background:#fff; border-radius:2px; }
button, .madar-txt, .madar-avatar, .menu-btn { outline:none; -webkit-tap-highlight-color:transparent; }
.madar-side-backdrop { position:fixed; inset:0; z-index:9998; background:rgba(0,0,0,0.5); display:none; }
.madar-side-backdrop.show { display:block; }
.madar-side {
left:auto; right:0; transform:translateX(100%); visibility:hidden;
transition:transform 0.25s ease, visibility 0s linear 0.25s; }
.madar-side.open { transform:translateX(0); visibility:visible; transition:transform 0.25s ease; }
.scroll-area {
width:100%; padding:70px 14px 14px;
align-items:stretch; }
.w-row {
flex-direction:row; transform:none;
gap:12px; max-width:none; }
.w-left, .w-right { max-width:none; flex:1; }
.w-left { padding-right:10px; }
.post-card { padding:12px; }
.post-head { gap:8px; margin-bottom:6px; padding-bottom:6px; }
.post-nm { font-size:14px; }
.post-un { font-size:10px; }
.post-time { font-size:9px; margin-bottom:4px; }
.post-body { font-size:13px; margin-bottom:6px; }
.post-stats { gap:10px; font-size:12px; padding-top:8px; margin-top:6px; flex-wrap:nowrap; }
.post-stats svg { width:12px; height:12px; }
.post-stats .spl { margin-right:auto; white-space:nowrap; }
.src-link { left:12px; right:auto; }
#bgCredit { position:fixed; right:12px; bottom:8px; color:#fff; font-size:11px; pointer-events:none; z-index:9999999; font-family:sans-serif; }
@media (max-width:1599px) { .src-link a { font-size:13px; } .src-link { bottom:24px; } }
@media (max-width:1200px) { .src-link a { font-size:11px; } .src-link { bottom:18px; } }
@media (max-width:900px) { .src-link a { font-size:10px; } .src-link { bottom:12px; left:8px; } }
@media (max-width:700px) { .src-link a { font-size:9px; } .src-link { bottom:8px; } }
</style>
<link href="/media/lang.css" rel="stylesheet">
</head>
<body data-home="/madar-phone" data-login="/login-phone" class="pixel">
<div class="src-link"><a href="/source">ذابحه الثقه موري كوده قدام... محد</a><br><a href="/rules" style="color:#fff;font-size:11px;text-decoration:none;font-family:inherit;">الدستور</a><br><a href="/privacy" style="color:#fff;font-size:11px;text-decoration:none;font-family:inherit;">سياسه الخصوصيه وشروط الاستخدام</a></div>
<div class="menu-btn" id="menuBtn"><span></span><span></span></div>
<div class="madar-side-backdrop" id="sideBackdrop"></div>
<div class="beta-badge" style="position:fixed;top:8px;left:8px;z-index:99999;font-size:25px;color:#fff;font-family:inherit;pointer-events:none;text-shadow:0 0 8px rgba(0,0,0,0.8);">نسخه تجريبيه مغلقه</div>
<div id="imgViewer" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:999999;cursor:zoom-out;justify-content:center;align-items:center;" onclick="closeimage()"><img id="imgViewerSrc" style="max-width:95%;max-height:95%;object-fit:contain;"><div style="position:fixed;top:16px;right:24px;font-size:32px;color:#fff;cursor:pointer;font-family:monospace;" onclick="closeimage()">x</div></div>
<div class="scroll-area" id="scrollArea">
<div class="w-row">
<div class="w-col w-left">
<div class="w-hdr">نُجُوم<span style="font-size:13px;letter-spacing:0;color:#fff;margin-left:7px;white-space:nowrap;">أفضل ٥٠٠ هذا الشهر</span><span class="wk-timer" id="wkTimer"></span></div>
<div id="njomcol"></div>
</div>
<div class="w-col w-right">
<div class="w-hdr">مَدَار<span style="font-size:14px;letter-spacing:0;color:#fff;margin-left:8px;"><span id="postCount" style="margin-inline-end:8px">…</span><span>عدد المنشورات</span></span></div>
<div id="madarcol"></div>
</div>
</div>
</div>
<div class="madar-side">
<div class="madar-brand">وِصَال</div>
<img class="madar-avatar" id="madaravatar" src="/media/defaultavatar/af.webp" loading="lazy" width="120" height="120" onclick="window.location.href='/profile'">
<button class="madar-txt" id="btnCreate">نَشْرُكَ يَبني الأَساس</button>
<button class="madar-txt" id="btnmlamh">ملامح</button>
<button class="madar-txt" id="btnUserbase">النفسيه<br><span class="ub-l2">تحتاج كرك</span></button>
<button class="madar-txt" id="btnLogin">الحساب</button>
</div>
<script src="/media/i18n"></script>
<script src="/media/app"></script>
<script>
let viewq = new Set(), viewt = null;
function flushViews() {
if (!viewq.size) return;
const ids = Array.from(viewq).join(',');
viewq.clear();
if (viewt) { clearTimeout(viewt); viewt = null; }
fetch('/api/feed?col=view&post_id=' + ids, { method: 'POST', keepalive: true }).then(r => json(r, {counts:{}})).then(d => {
const counts = d && d.counts ? d.counts : {};
Object.keys(counts).forEach(pid => {
document.querySelectorAll('.vc[data-pid="'+pid+'"]').forEach(v => {
v.textContent = counts[pid]; }); });
}).catch(() => {}); }
function consumePost(pid) {
viewq.add(pid);
if (!viewt) viewt = setTimeout(flushViews, 4000); }
document.addEventListener('visibilitychange', () => { if (document.hidden) flushViews(); });
window.addEventListener('pagehide', flushViews);
function monthlyTimer() {
let el = document.getElementById('wkTimer');
if (!el) return;
let now = new Date();
let end = new Date(now.getFullYear(), now.getMonth() + 1, 1);
let diff = end - now;
if (diff <= 0) { el.textContent = ''; return; }
let d = Math.floor(diff / 86400000);
let h = Math.floor((diff % 86400000) / 3600000);
let parts = [];
if (d > 0) parts.push(d + ' ي');
parts.push(h + ' س');
el.textContent = ' ' + parts.join(' '); }
async function init() {
try {
let uu = await myprofile();
if (uu) applyavatar(document.getElementById('madaravatar'), uu.avatar || '');
document.getElementById('btnCreate').addEventListener('click', () => { if (!curUser) { location.href = '/login'; return; } window.location.href = '/create'; });
document.getElementById('btnmlamh').addEventListener('click', () => { if (!curUser) { location.href = '/login'; return; } window.location.href = '/mlamh'; });
document.getElementById('btnLogin').addEventListener('click',
() => { location.href = curUser ? '/profile' : '/login'; });
document.getElementById('btnUserbase').addEventListener('click', () => window.location.href = '/users');
document.querySelectorAll('.madar-txt').forEach(el => {
el.addEventListener('mouseenter', () => { let c=colors[Math.floor(Math.random()*colors.length)]; el.style.color=c; });
el.addEventListener('mouseleave', () => { el.style.color='#fff'; }); });
let pav = document.getElementById('madaravatar');
if (pav) {
pav.addEventListener('mouseenter', () => { pav.style.borderColor = colors[Math.floor(Math.random()*colors.length)]; });
pav.addEventListener('mouseleave', () => { pav.style.borderColor = 'transparent'; }); }
const [rd, ro] = await Promise.all([
jsonget('/api/feed?col=njom'),
jsonget('/api/feed?col=madar&offset=0&limit=50'),
]);
document.getElementById('njomcol').innerHTML = postshtml(rd, 'l');
document.getElementById('madarcol').innerHTML = postshtml(ro, 'r');
let observer = new IntersectionObserver(entries => {
entries.forEach(e => {
if (!e.isIntersecting) return;
let vc = e.target.querySelector('.vc');
if (!vc) return;
let pid = vc.dataset.pid;
if (!pid) return;
observer.unobserve(e.target);
consumePost(pid); }); }, { root: document.getElementById('scrollArea'), threshold: 0.3 });
let watched = new WeakSet();
function watch(el) {
el.querySelectorAll('.post-card').forEach(card => {
if (watched.has(card)) return;
watched.add(card);
observer.observe(card); }); }
watch(document.getElementById('madarcol'));
setInterval(monthlyTimer, 1000);
monthlyTimer();
let firstVc = document.querySelector('#madarcol .vc');
let lpId = firstVc ? Number(firstVc.dataset.pid) : 0;
let polling = false;
setInterval(async () => {
if (polling || document.hidden) return;
polling = true;
try {
let fresh = await jsonget('/api/feed?col=poll&since_id=' + lpId);
if (!fresh || !fresh.length) return;
let sa = document.getElementById('scrollArea');
let col = document.getElementById('madarcol');
if (!sa || !col) return;
let sy = sa.scrollTop;
col.insertAdjacentHTML('afterbegin', postshtml(fresh, 'r'));
sa.scrollTop = sy;
watch(col);
let nv = col.querySelector('.vc');
if (nv) lpId = Number(nv.dataset.pid) || lpId;
} catch (err) {
} finally {
polling = false;
}
}, 15000); } catch(e) {}
}
init();
const side = document.querySelector('.madar-side');
const menuBtn = document.getElementById('menuBtn');
const sideBackdrop = document.getElementById('sideBackdrop');
function toggleSide(open) {
if (!side) return;
if (open === undefined) open = !side.classList.contains('open');
side.classList.toggle('open', open);
if (sideBackdrop) sideBackdrop.classList.toggle('show', open); }
if (menuBtn) menuBtn.addEventListener('click', () => toggleSide());
if (sideBackdrop) sideBackdrop.addEventListener('click', () => toggleSide(false));
if (side) {
side.addEventListener('click', e => {
if (e.target.closest('.madar-txt') || e.target.closest('.madar-avatar')) toggleSide(false); }); }
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeimage(); });
document.getElementById('scrollArea').addEventListener('click', e => {
let t = e.target;
if (t.tagName === 'IMG' && t.closest('.post-media')) viewimage(t.src.replace(/\?w=\d+$/, '')); });
</script>
<div style="position:fixed;bottom:6px;left:0;right:0;text-align:center;font-size:11px;line-height:1.5;font-family:monospace;color:#fff;background:#000;padding:6px 8px;z-index:99999;letter-spacing:0;pointer-events:none">نسخة الجوال ماهي جاهزه ادخل من البيسي او لف الشاشه</div>
</body>
</html>
mlamh.html
<!DOCTYPE html>
<html lang="ar">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>وِصَال</title>
<link rel="icon" type="image/png" sizes="32x32" href="/media/favicon.png">
<link rel="icon" type="image/png" sizes="16x16" href="/media/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/media/favicon-180.png">
<link rel="icon" type="image/png" sizes="192x192" href="/media/favicon-192.png">
<meta name="theme-color" content="#030305">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
<link href="/media/pixelart/pixelart-icons-font.css" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #000;
color: #fff;
font-family: monospace;
display: flex;
height: 100vh;
overflow: hidden; }
#mlamh-container {
position: relative;
flex: 1;
height: 100vh;
background: #000;
overflow: hidden; }
.cell {
position: absolute;
border: 1px solid rgba(255,255,255,0.3);
cursor: pointer;
overflow: hidden;
transition: background 0.15s; }
.cell.selected {
border-color: #0ff;
box-shadow: inset 0 0 5px rgba(0,255,255,0.3); }
.cell .cell-content {
position: absolute;
width: 100%; height: 100%; top: 0; left: 0;
display: flex; align-items: center; justify-content: center;
font-size: 11px; color: #888; pointer-events: auto;
object-fit: contain; cursor: pointer;
text-align: center; }
.cell .cell-content img, .cell .cell-content video {
width: 100%; height: 100%; object-fit: cover; }
.resize-handle, .content-handle {
position: absolute; z-index: 10; width: 10px; height: 10px; }
.resize-handle { background: rgba(0,255,255,0.6); border: 1px solid #0ff; }
.content-handle { z-index: 11; background: rgba(255,255,0,0.6); border: 1px solid #ff0; pointer-events: auto; }
.nw { top: -4px; left: -4px; cursor: nw-resize; }
.ne { top: -4px; right: -4px; cursor: ne-resize; }
.sw { bottom: -4px; left: -4px; cursor: sw-resize; }
.se { bottom: -4px; right: -4px; cursor: se-resize; }
.n { top: -4px; left: 50%; margin-left:-5px; cursor: n-resize; }
.s { bottom: -4px; left: 50%; margin-left:-5px; cursor: s-resize; }
.e { right: -4px; top: 50%; margin-top:-5px; cursor: e-resize; }
.w { left: -4px; top: 50%; margin-top:-5px; cursor: w-resize; }
#right-panel {
width: 200px;
background: rgba(255,255,255,0.03);
border-left: 1px solid rgba(255,255,255,0.1);
padding: 15px;
display: flex;
flex-direction: column;
gap: 10px;
overflow-y: auto; }
#right-panel h3 {
font-size: 14px;
color: #ffcc00;
border-bottom: 1px solid #333;
padding-bottom: 8px; }
#right-panel .widget-item {
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 6px;
padding: 8px 10px;
font-size: 12px;
cursor: grab;
color: #aaa; }
#right-panel .widget-item:hover { background: rgba(255,255,255,0.1); color: #fff; }
#right-panel .widget-item:active { cursor: grabbing; }
#bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 200px;
background: rgba(255,255,255,0.05);
border-top: 1px solid rgba(255,255,255,0.1);
padding: 8px 15px;
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
z-index: 10; }
#bottom-bar button, #bottom-bar label {
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.2);
color: #fff;
padding: 6px 12px;
border-radius: 6px;
font-family: inherit;
font-size: 11px;
cursor: pointer; }
#bottom-bar button:hover { background: rgba(255,255,255,0.2); }
#bottom-bar input[type="color"] {
width: 30px; height: 30px; border: none; padding: 0; cursor: pointer;
background: transparent; }
#bottom-bar input[type="range"] { width: 80px; }
.src-link { position:fixed; bottom:40px; left:12px; z-index:100000; }
.src-link a { color:#fff; font-size:18px; font-family:'VT323',monospace; text-decoration:none; transition:color 0.15s; }
.src-link a:hover { color:#ccc; }
#upl-bar {
position: fixed; bottom: 58px; left: 12px; right: 212px; z-index: 200000;
display: none; flex-direction: column; gap: 6px;
background: rgba(8,10,14,0.94); border: 1px solid rgba(0,255,255,0.35);
border-radius: 10px; padding: 10px 12px; font-size: 11px; color: #cfe;
box-shadow: 0 8px 30px rgba(0,0,0,0.65); }
#upl-bar.on { display: flex; }
#upl-bar .u-top { display: flex; justify-content: space-between; gap: 10px; align-items: baseline; }
#upl-bar .u-text { color: #9ff; }
#upl-bar .u-num { color: #fff; font-weight: bold; }
#upl-track { height: 8px; background: rgba(255,255,255,0.12); border-radius: 99px; overflow: hidden; }
#upl-fill { height: 100%; width: 0%; background: linear-gradient(90deg,#0ff,#0a8); transition: width .12s linear; }
#upl-rate { color: #789; font-size: 10px; }
@media (max-width:1599px) { .src-link a { font-size:13px; } .src-link { bottom:24px; } }
@media (max-width:1200px) { .src-link a { font-size:11px; } .src-link { bottom:18px; } }
@media (max-width:900px) { .src-link a { font-size:10px; } .src-link { bottom:12px; left:8px; } }
@media (max-width:700px) { .src-link a { font-size:9px; } .src-link { bottom:8px; } }
</style>
<link href="/media/lang.css" rel="stylesheet">
<link href="/media/i18n.css" rel="stylesheet">
<script src="/media/i18n"></script>
<script src="/media/app"></script>
<script src="/media/compress"></script>
</head>
<body class="pixel">
<div style="position:fixed;top:8px;left:8px;z-index:99999;font-size:18px;color:#fff;font-family:\'VT323\',monospace;pointer-events:none;text-shadow:0 0 8px rgba(0,0,0,0.8);" data-i18n="beta_note">نسخه تجريبيه مغلقه</div>
<div class="src-link"><a href="/source" data-i18n="src_credit">ذابحه الثقه موري كوده قدام... محد</a><br><a href="/privacy" style="color:#fff;font-size:11px;text-decoration:none;font-family:inherit;" data-i18n="privacy_terms">سياسه الخصوصيه وشروط الاستخدام</a></div>
<div id="mlamh-container"></div>
<div id="right-panel">
<h3 data-i18n="tools">الأدوات</h3>
<div class="widget-item" draggable="true" data-widget="custom_text" data-i18n="w_custom_text">نص مخصص</div>
<div class="widget-item" draggable="true" data-widget="display_name" data-i18n="w_display_name">الاسم الظاهر</div>
<div class="widget-item" draggable="true" data-widget="bio" data-i18n="w_bio">البايو</div>
<div class="widget-item" draggable="true" data-widget="banner" data-i18n="w_banner">الغلاف</div>
<div class="widget-item" draggable="true" data-widget="avatar" data-i18n="w_avatar">الافتار</div>
<div class="widget-item" draggable="true" data-widget="posts" data-i18n="w_posts">منشورات</div>
<div class="widget-item" draggable="true" data-widget="likes" data-i18n="w_likes">إعجابات</div>
<div class="widget-item" draggable="true" data-widget="joined" data-i18n="w_joined">تاريخ الانضمام</div>
<div class="widget-item" draggable="true" data-widget="links" data-i18n="w_links">روابط الدعم</div>
<div style="margin-top:auto; display:flex; flex-direction:column; gap:8px;">
<h3 style="margin-top:10px;" data-i18n="actions">إجراءات</h3>
<button onclick="saveJSON()" style="background:none; border:none; color:#fff; padding:8px; border-radius:6px; cursor:pointer; font-family:inherit; font-size:12px;" data-i18n="download">حفظ</button>
<button onclick="applyToProfile()" style="background:none; border:none; color:#fff; padding:8px; border-radius:6px; cursor:pointer; font-family:inherit; font-size:12px;" data-i18n="apply">تطبيق</button>
<button onclick="deleteCumlamh()" style="background:none; border:none; color:#fff; padding:8px; border-radius:6px; cursor:pointer; font-family:inherit; font-size:12px;" data-i18n="delete_layout">حذف الملامح</button>
</div>
</div>
<div id="bottom-bar">
<span style="color:#888; font-size:11px;" data-i18n="tools_label">أدوات:</span>
<button onclick="removeCell()" data-i18n="del">حذف</button>
<span style="color:#888; font-size:11px;" data-i18n="layer_label">الطبقه:</span>
<button onclick="forward()" data-i18n="forward">تقديم للأمام</button>
<button onclick="backward()" data-i18n="backward">تأخير للخلف</button>
<span style="color:#888; font-size:11px;" data-i18n="rotate_label">الدوران:</span>
<input type="range" id="rotate-range" min="0" max="360" value="0" oninput="rotate(this.value)">
<span id="rotate-val" style="color:#888; font-size:11px;">0°</span>
<span style="color:#888; font-size:11px;" data-i18n="bg_color">لون الخلفيه:</span>
<input type="color" id="cell-color" value="#000000" oninput="tintCell(this.value)">
<span style="color:#888; font-size:11px;" data-i18n="text_color">لون النص:</span>
<input type="color" id="cell-text-color" value="#ffffff" oninput="tintText(this.value)">
<span style="color:#888; font-size:11px;" data-i18n="size_label">الحجم:</span>
<input type="number" id="cell-font-size" value="14" min="1" max="100" oninput="textSize(this.value)" style="width:50px;background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.2);color:#fff;padding:4px;border-radius:4px;font-family:inherit;font-size:11px;">
<span style="color:#888; font-size:11px;">%</span>
<span style="color:#888; font-size:11px;" data-i18n="media_label">وسائط:</span>
<input type="file" id="cell-media" accept="image/jpeg,image/png,image/gif,video/mp4" onchange="uploadMedia(this)" style="display:none;">
<label for="cell-media" style="cursor:pointer; background:rgba(255,255,255,0.1); border:1px solid rgba(255,255,255,0.2); color:#fff; padding:6px 12px; border-radius:6px; font-size:11px;" data-i18n="choose">اختر</label>
<button onclick="emptyCell()" data-i18n="empty">تفريغ</button>
<span style="flex:1;"></span>
<span id="cell-info" style="color:#888; font-size:11px;">لم تُحدَّد أي خانه</span>
</div>
<div id="upl-bar">
<div class="u-top"><span class="u-text" id="upl-text" data-i18n="up_preparing">جاري تهيئة الرفع…</span><span class="u-num" id="upl-num">0%</span></div>
<div id="upl-track"><div id="upl-fill"></div></div>
<div id="upl-rate">—</div>
</div>
<script>
fetch('/api/feed?col=madar').then(r => { if (r.status === 401) window.location.href = '/login'; }).catch(() => null);
const ROWS = 16, COLS = 16;
let cells = [];
let selectedCells = [];
let nextId = 1;
let nextZ = 1;
let resizing = null;
let editingContent = false;
let currentProfile = {};
let widgetData = { posts: [], liked: [] };
function initMlamh() {
const c = document.getElementById('mlamh-container');
const w = c.clientWidth;
const h = c.clientHeight;
const cw = w / COLS;
const ch = h / ROWS;
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLS; x++) {
cells.push({ id: nextId++, x, y, w: 1, h: 1, bg: '#000000', rotation: 0, media: null, mediaType: null, widget: '', zIndex: 0, textColor: '#ffffff', fontSize: 14, cropW: 100, cropH: 100, customText: '' }); }
}
renderMlamh(); }
function renderMlamh() {
const container = document.getElementById('mlamh-container');
container.innerHTML = '';
const w = container.clientWidth;
const h = container.clientHeight;
const cw = w / COLS;
const ch = h / ROWS;
const sorted = [...cells.filter(c => !c.deleted)].sort((a, b) => a.zIndex - b.zIndex);
sorted.forEach(cell => {
const el = document.createElement('div');
el.className = 'cell';
if (selectedCells.find(c => c.id === cell.id)) el.classList.add('selected');
el.style.left = (cell.x * cw) + 'px';
el.style.top = (cell.y * ch) + 'px';
el.style.width = (cell.w * cw) + 'px';
el.style.height = (cell.h * ch) + 'px';
el.style.background = cell.bg || '#000000';
el.style.position = 'absolute';
el.style.zIndex = cell.zIndex;
if (editingContent && selectedCells.find(c => c.id === cell.id)) {
el.style.overflow = 'visible'; }
el.dataset.id = cell.id;
el.onclick = (e) => { e.stopPropagation(); if (!resizing && !contentResizing) { editingContent = false; selectCell(cell.id); } };
el.onmousedown = (e) => { if (!editingContent && !resizing && !contentResizing && (e.target === el)) beginDrag(e, cell); };
const content = document.createElement('div');
content.className = 'cell-content';
const hasContent = (cell.widget || cell.mediaType);
if (hasContent && !cell.widget) {
const cellPxW = cell.w * cw;
const cellPxH = cell.h * ch;
content.style.width = (cellPxW * (cell.cropW || 100) / 100) + 'px';
content.style.height = (cellPxH * (cell.cropH || 100) / 100) + 'px';
content.style.left = ((cellPxW - (cellPxW * (cell.cropW || 100) / 100)) / 2) + 'px';
content.style.top = ((cellPxH - (cellPxH * (cell.cropH || 100) / 100)) / 2) + 'px'; }
if (cell.rotation) content.style.transform = `rotate(${cell.rotation}deg)`;
if (cell.mediaType === 'image' && (cell._blob || /\.(jpg|jpeg|png|gif|webp)$/i.test(cell.media || ''))) {
const img = document.createElement('img');
img.loading = 'lazy';
img.src = cell._blob || mediaurl(cell.media);
img.onerror = function() { this.parentElement.style.display = 'none'; };
content.appendChild(img); } else if (cell.mediaType === 'video' && (cell._blob || /\.(mp4|webm)$/i.test(cell.media || ''))) {
const vid = document.createElement('video');
vid.src = cell._blob || mediaurl(cell.media);
vid.muted = true;
vid.loop = true;
vid.autoplay = true;
vid.style.width = '100%';
vid.style.height = '100%';
vid.style.objectFit = 'cover';
content.appendChild(vid); } else if (cell.widget) {
const cellH = cell.h * ch;
previewWidget(content, cell, currentProfile, cellH); } else {
content.textContent = cell.x + ',' + cell.y;
content.style.color = '#555'; }
if (hasContent) {
content.onclick = (e) => {
e.stopPropagation();
if (resizing || contentResizing) return;
if (selectedCells.find(c => c.id === cell.id) && selectedCells.length === 1) {
editingContent = !editingContent; } else {
editingContent = true;
selectCell(cell.id); }
renderMlamh(); }; }
el.appendChild(content);
if (selectedCells.find(c => c.id === cell.id) && selectedCells.length === 1) {
if (editingContent && hasContent) {
const dirs = ['nw','n','ne','e','se','s','sw','w'];
dirs.forEach(dir => {
const hEl = document.createElement('div');
hEl.className = 'content-handle ' + dir;
hEl.onmousedown = (e) => { e.stopPropagation(); e.preventDefault(); beginSize(e, cell, dir); };
content.appendChild(hEl); }); } else {
const dirs = ['nw','n','ne','e','se','s','sw','w'];
dirs.forEach(dir => {
const hEl = document.createElement('div');
hEl.className = 'resize-handle ' + dir;
hEl.onmousedown = (e) => { e.stopPropagation(); e.preventDefault(); beginResize(e, cell, dir); };
el.appendChild(hEl); }); }
}
container.appendChild(el); });
cellInfo(); }
function cellAt(id) { return cells.find(c => c.id === id); }
function previewWidget(content, cell, userdata, cellH) {
const p = userdata || {};
const fontSizePx = Math.round((cellH || 100) * ((cell.fontSize || 14) / 100));
content.style.color = cell.textColor || '#ffffff';
content.style.fontSize = fontSizePx + 'px';
content.style.display = 'flex';
content.style.alignItems = 'center';
content.style.justifyContent = 'center';
const w = cell.widget;
const setText = (txt) => {
content.textContent = '';
const span = document.createElement('span');
span.textContent = txt;
span.style.cssText = 'text-align:center;width:100%;word-break:break-word;overflow-wrap:break-word;display:flex;align-items:center;justify-content:center;';
content.appendChild(span); };
if (w === 'avatar') {
if (p.avatar) {
const img = document.createElement('img');
img.loading = 'lazy';
img.src = mediaurl(p.avatar);
img.style.cssText = 'width:100%;height:100%;object-fit:cover;';
content.appendChild(img); } else { setText(t('no_avatar_ph')); }
} else if (w === 'display_name') {
setText(p.displayname || p.username || '?'); } else if (w === 'bio') {
setText(p.bio || t('no_bio_ph')); } else if (w === 'banner') {
if (p.banner) {
const img = document.createElement('img');
img.loading = 'lazy';
img.src = mediaurl(p.banner);
img.style.cssText = 'width:100%;height:100%;object-fit:cover;';
content.appendChild(img); } else { setText(t('no_banner_ph')); }
} else if (w === 'joined') {
setText((p.jointext || t('d_join')) + ' ' + (p.createtime ? p.createtime.split('T')[0] : '')); } else if (w === 'links') {
setText(p.supportlinks || ''); } else if (w === 'posts') {
content.style.display = 'flex';
content.style.flexDirection = 'column';
content.style.alignItems = 'flex-start';
content.style.justifyContent = 'flex-start';
content.style.overflowY = 'auto';
content.style.fontSize = Math.max(8, fontSizePx - 4) + 'px';
content.style.padding = '4px';
content.innerHTML = '';
const data = widgetData.posts || [];
if (data.length === 0) { setText(t('no_posts_ph')); }
else {
data.slice(0, 30).forEach(p => {
const d = document.createElement('div');
d.style.cssText = 'padding:4px 6px;border-bottom:1px solid rgba(255,255,255,0.06);width:100%;text-align:left;display:flex;gap:6px;align-items:center;';
const pu = document.createElement('span');
pu.style.cssText = 'color:#888;flex-shrink:0;';
pu.textContent = '@' + (p.username || '');
const pc = document.createElement('span');
pc.style.cssText = 'flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
pc.textContent = p.content || '';
d.append(pu, pc);
content.appendChild(d); }); }
} else if (w === 'likes') {
content.style.display = 'flex';
content.style.flexDirection = 'column';
content.style.alignItems = 'flex-start';
content.style.justifyContent = 'flex-start';
content.style.overflowY = 'auto';
content.style.fontSize = Math.max(8, fontSizePx - 4) + 'px';
content.style.padding = '4px';
content.innerHTML = '';
const data = widgetData.liked || [];
if (data.length === 0) { setText(t('no_likes_ph')); }
else {
data.slice(0, 30).forEach(p => {
const d = document.createElement('div');
d.style.cssText = 'padding:4px 6px;border-bottom:1px solid rgba(255,255,255,0.06);width:100%;text-align:left;display:flex;gap:6px;align-items:center;';
const pu = document.createElement('span');
pu.style.cssText = 'color:#888;flex-shrink:0;';
pu.textContent = '@' + (p.username || '');
const pc = document.createElement('span');
pc.style.cssText = 'flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
pc.textContent = p.content || '';
d.append(pu, pc);
content.appendChild(d); }); }
} else if (w === 'custom_text') {
setText(cell.customText || t('custom_text_ph')); } else {
setText('[' + w + ']'); }
}
function selectCell(id) {
const cell = cellAt(id);
if (!cell) return;
if (selectedCells.length === 1 && selectedCells[0].id !== id) {
editingContent = false; }
const idx = selectedCells.findIndex(c => c.id === id);
if (idx !== -1) {
selectedCells.splice(idx, 1);
if (selectedCells.length === 0) editingContent = false; } else {
selectedCells = [cell];
}
renderMlamh();
refreshToolbar(); }
function refreshToolbar() {
if (selectedCells.length === 1) {
const c = selectedCells[0];
document.getElementById('cell-color').value = c.bg || '#000000';
document.getElementById('cell-text-color').value = c.textColor || '#ffffff';
document.getElementById('cell-font-size').value = c.fontSize || 14;
document.getElementById('rotate-range').value = c.rotation || 0;
document.getElementById('rotate-val').textContent = (c.rotation || 0) + '°'; }
}
function cellInfo() {
const el = document.getElementById('cell-info');
if (selectedCells.length === 0) el.textContent = t('no_selection');
else if (selectedCells.length === 1) {
const c = selectedCells[0];
const extra = editingContent ? (c.widget ? t('size_info', { f: c.fontSize }) : t('crop_info', { w: Math.round(c.cropW), h: Math.round(c.cropH) })) : '';
el.textContent = t('cell_info', { x: c.x, y: c.y, w: c.w, h: c.h, z: c.zIndex }) + ' ' + (c.widget ? '[' + c.widget + '] ' : '') + extra; }
else el.textContent = t('cells_selected', { n: selectedCells.length }); }
let dragData = null;
function beginDrag(e, cell) {
if (selectedCells.length !== 1 || !selectedCells.find(c => c.id === cell.id)) return;
const rect = document.getElementById('mlamh-container').getBoundingClientRect();
const cw = (rect.width) / COLS;
const ch = (rect.height) / ROWS;
dragData = {
cell, startX: e.clientX, startY: e.clientY,
origX: cell.x, origY: cell.y, snapX: cw, snapY: ch, containerRect: rect
};
document.addEventListener('mousemove', dragMove);
document.addEventListener('mouseup', endDrag); }
function dragMove(e) {
if (!dragData) return;
const dx = (e.clientX - dragData.startX) / dragData.snapX;
const dy = (e.clientY - dragData.startY) / dragData.snapY;
let nx = dragData.origX + dx;
let ny = dragData.origY + dy;
nx = Math.max(0, Math.min(COLS - dragData.cell.w, nx));
ny = Math.max(0, Math.min(ROWS - dragData.cell.h, ny));
dragData.cell.x = nx;
dragData.cell.y = ny;
renderMlamh(); }
function endDrag() {
dragData = null;
document.removeEventListener('mousemove', dragMove);
document.removeEventListener('mouseup', endDrag); }
function beginResize(e, cell, dir) {
resizing = { cell, dir, startX: e.clientX, startY: e.clientY, origW: cell.w, origH: cell.h, origX: cell.x, origY: cell.y };
document.addEventListener('mousemove', resizeMove);
document.addEventListener('mouseup', endResize); }
function resizeMove(e) {
if (!resizing) return;
const rect = document.getElementById('mlamh-container').getBoundingClientRect();
const cw = rect.width / COLS;
const ch = rect.height / ROWS;
const dx = (e.clientX - resizing.startX) / cw;
const dy = (e.clientY - resizing.startY) / ch;
const cell = resizing.cell;
let nx = cell.x, ny = cell.y, nw = cell.w, nh = cell.h;
const dir = resizing.dir;
if (dir.includes('e')) { nw = Math.max(1, resizing.origW + dx); }
if (dir.includes('w')) {
const shrink = Math.min(dx, resizing.origW - 1);
nx = resizing.origX + shrink;
nw = resizing.origW - shrink;
if (nw < 1) { nw = 1; nx = resizing.origX + resizing.origW - 1; }
}
if (dir.includes('s')) { nh = Math.max(1, resizing.origH + dy); }
if (dir.includes('n')) {
const shrink = Math.min(dy, resizing.origH - 1);
ny = resizing.origY + shrink;
nh = resizing.origH - shrink;
if (nh < 1) { nh = 1; ny = resizing.origY + resizing.origH - 1; }
}
nx = Math.max(0, Math.min(COLS - nw, nx));
ny = Math.max(0, Math.min(ROWS - nh, ny));
cell.x = nx; cell.y = ny; cell.w = nw; cell.h = nh;
renderMlamh(); }
function endResize() {
resizing = null;
document.removeEventListener('mousemove', resizeMove);
document.removeEventListener('mouseup', endResize); }
let contentResizing = null;
function beginSize(e, cell, dir) {
contentResizing = { cell, dir, startX: e.clientX, startY: e.clientY, origW: cell.cropW, origH: cell.cropH, origFontSize: cell.fontSize };
document.addEventListener('mousemove', sizeMove);
document.addEventListener('mouseup', endSize); }
function sizeMove(e) {
if (!contentResizing) return;
const rect = document.getElementById('mlamh-container').getBoundingClientRect();
const cw = rect.width / COLS;
const ch = rect.height / ROWS;
const cell = contentResizing.cell;
const cellPxW = cell.w * cw;
const cellPxH = cell.h * ch;
const dx = (e.clientX - contentResizing.startX) / cellPxW * 100;
const dy = (e.clientY - contentResizing.startY) / cellPxH * 100;
if (cell.widget && cell.widget !== 'avatar' && cell.widget !== 'banner') {
const orig = contentResizing.origFontSize || cell.fontSize || 14;
let fs = orig + dy;
cell.fontSize = Math.max(1, Math.min(100, fs)); } else {
let nw = contentResizing.origW, nh = contentResizing.origH;
const dir = contentResizing.dir;
if (dir.includes('e')) { nw = Math.max(5, contentResizing.origW + dx); }
if (dir.includes('w')) { nw = Math.max(5, contentResizing.origW - dx); }
if (dir.includes('s')) { nh = Math.max(5, contentResizing.origH + dy); }
if (dir.includes('n')) { nh = Math.max(5, contentResizing.origH - dy); }
cell.cropW = Math.min(100, nw);
cell.cropH = Math.min(100, nh); }
renderMlamh(); }
function endSize() {
contentResizing = null;
document.removeEventListener('mousemove', sizeMove);
document.removeEventListener('mouseup', endSize); }
function forward() {
if (selectedCells.length !== 1) return;
const cell = selectedCells[0];
const maxZ = Math.max(...cells.filter(c => !c.deleted).map(c => c.zIndex));
cell.zIndex = maxZ + 1;
renderMlamh(); }
function backward() {
if (selectedCells.length !== 1) return;
const cell = selectedCells[0];
const minZ = Math.min(...cells.filter(c => !c.deleted).map(c => c.zIndex));
cell.zIndex = minZ - 1;
renderMlamh(); }
function removeCell() {
if (selectedCells.length === 0) return;
selectedCells.forEach(c => {
const cell = cellAt(c.id);
if (cell) { dropPending(cell); cell.deleted = true; } });
selectedCells = [];
renderMlamh(); }
function rotate(val) {
document.getElementById('rotate-val').textContent = val + '°';
if (selectedCells.length !== 1) return;
selectedCells[0].rotation = parseInt(val);
renderMlamh(); }
function tintCell(val) {
if (selectedCells.length === 0) return;
selectedCells.forEach(c => { const cell = cellAt(c.id); if (cell) cell.bg = val; });
renderMlamh(); }
function dropPending(cell) {
if (!cell) return;
if (cell._blob) { try { URL.revokeObjectURL(cell._blob); } catch(e) {} cell._blob = null; }
cell._file = null; }
const UP_CHUNK = document.querySelector('meta[name="wisal-runtime"]') ? 1024 * 1024 : 100 * 1024;
const UP_RATE = document.querySelector('meta[name="wisal-runtime"]') ? Infinity : 100 * 1024;
function upShow(text) {
const bar = document.getElementById('upl-bar');
document.getElementById('upl-text').textContent = text;
document.getElementById('upl-num').textContent = '0%';
document.getElementById('upl-fill').style.width = '0%';
bar.classList.add('on');
}
function upSet(sent, total, chunkIdx, chunkCount) {
const pct = total > 0 ? Math.floor(sent / total * 100) : 100;
document.getElementById('upl-num').textContent = pct + '%';
document.getElementById('upl-fill').style.width = pct + '%';
document.getElementById('upl-rate').textContent =
t('up_progress', { sent: fmtSize(sent), total: fmtSize(total) }) +
(chunkCount ? ' · ' + t('up_chunks', { i: chunkIdx, n: chunkCount }) : '');
}
function upHide() { document.getElementById('upl-bar').classList.remove('on'); }
function fmtSize(b) {
if (b < 1024) return b + ' B';
if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
return (b / 1048576).toFixed(2) + ' MB';
}
function upid() {
const cs = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let s = '';
for (let i = 0; i < 32; i++) s += cs[Math.floor(Math.random() * cs.length)];
return s;
}
const upSleep = ms => new Promise(r => setTimeout(r, ms));
async function uploadCellMedia(file, onProgress) {
const total = Math.ceil(file.size / UP_CHUNK) || 1;
const id = upid();
let sent = 0, last = null;
const started = performance.now();
for (let i = 0; i < total; i++) {
const blob = file.slice(i * UP_CHUNK, Math.min(file.size, (i + 1) * UP_CHUNK));
const fd = new FormData();
fd.append('upid', id);
fd.append('mchunk', String(i));
fd.append('mchunks', String(total));
fd.append('mname', file.name);
fd.append('mtype', file.type || 'application/octet-stream');
fd.append('mdata', blob, file.name);
const res = await fetch('/api/mlamh', { method: 'POST', body: fd });
const j = res.ok ? await res.json().catch(() => null) : null;
if (!res.ok || !j) throw new Error('chunk');
sent += blob.size;
if (onProgress) onProgress(sent, file.size);
upSet(sent, file.size, i + 1, total);
const want = sent / UP_RATE * 1000; // الزمن المستهدف لهذا القدر
const wait = want - (performance.now() - started);
if (wait > 0) await upSleep(wait);
last = j;
}
if (!last || !last.path) throw new Error('nopath');
return last.path;
}
async function uploadCellThumbs(thumbs, path) {
const base = path.split('/').pop();
for (const w of [96, 256, 640]) {
const blob = thumbs[w];
if (!blob) continue;
const total = Math.ceil(blob.size / UP_CHUNK) || 1;
const id = upid();
for (let i = 0; i < total; i++) {
const fd = new FormData();
fd.append('upid', id);
fd.append('mchunk', String(i));
fd.append('mchunks', String(total));
fd.append('mname', w + '.webp');
fd.append('mtype', 'image/webp');
fd.append('thumbw', String(w));
fd.append('thumbbase', base);
fd.append('mdata', blob.slice(i * UP_CHUNK, Math.min(blob.size, (i + 1) * UP_CHUNK)), w + '.webp');
const res = await fetch('/api/mlamh', { method: 'POST', body: fd });
if (!res.ok) return;
}
}
}
async function uploadMedia(input) {
if (selectedCells.length !== 1 || !input.files[0]) return;
const file = input.files[0];
const cell = selectedCells[0];
input.value = '';
dropPending(cell);
cell._file = file;
cell._blob = URL.createObjectURL(file);
cell.mediaType = file.type.startsWith('video/') ? 'video' : 'image';
cell.widget = '';
renderMlamh();
}
function emptyCell() {
if (selectedCells.length === 0) return;
selectedCells.forEach(c => {
const cell = cellAt(c.id);
if (cell) { dropPending(cell); cell.media = null; cell.mediaType = null; cell.widget = ''; cell.bg = '#000000'; cell.rotation = 0; cell.textColor = '#ffffff'; cell.fontSize = 14; cell.cropW = 100; cell.cropH = 100; }
});
renderMlamh(); }
function tintText(val) {
if (selectedCells.length === 0) return;
selectedCells.forEach(c => { const cell = cellAt(c.id); if (cell) cell.textColor = val; });
renderMlamh(); }
function textSize(val) {
if (selectedCells.length === 0) return;
const v = parseInt(val) || 14;
selectedCells.forEach(c => { const cell = cellAt(c.id); if (cell) cell.fontSize = Math.max(1, Math.min(100, v)); });
renderMlamh(); }
document.querySelectorAll('.widget-item').forEach(el => {
el.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('widget', e.target.dataset.widget); }); });
document.getElementById('mlamh-container').addEventListener('dragover', (e) => { e.preventDefault(); });
document.getElementById('mlamh-container').addEventListener('drop', (e) => {
e.preventDefault();
const widget = e.dataTransfer.getData('widget');
if (!widget) return;
const rect = document.getElementById('mlamh-container').getBoundingClientRect();
const w = document.getElementById('mlamh-container').clientWidth;
const h = document.getElementById('mlamh-container').clientHeight;
const cw = w / COLS;
const ch = h / ROWS;
const gx = Math.floor((e.clientX - rect.left) / cw);
const gy = Math.floor((e.clientY - rect.top) / ch);
if (gx < 0 || gx >= COLS || gy < 0 || gy >= ROWS) return;
const target = [...cells.filter(c => !c.deleted)].sort((a,b)=>b.zIndex - a.zIndex).find(c => c.x <= gx && c.x + c.w > gx && c.y <= gy && c.y + c.h > gy);
if (target) {
dropPending(target);
target.widget = widget;
target.media = null;
target.mediaType = null;
if (widget === 'custom_text') {
const txt = prompt(t('prompt_text'), target.customText || '');
if (txt !== null) target.customText = txt; }
renderMlamh(); }
});
document.getElementById('mlamh-container').addEventListener('click', () => {
if (!dragData && !resizing && !contentResizing) { selectedCells = []; editingContent = false; renderMlamh(); refreshToolbar(); }
});
function mlamhJSON() {
const active = cells.filter(c => !c.deleted);
return { rows: ROWS, cols: COLS, cells: active.map(c => ({
x: c.x, y: c.y, w: c.w, h: c.h, bg: c.bg !== '#000000' ? c.bg : undefined,
rotation: c.rotation, media: c.media, mediaType: c.mediaType,
widget: c.widget, zIndex: c.zIndex, textColor: c.textColor, fontSize: c.fontSize,
cropW: c.cropW, cropH: c.cropH, customText: c.customText || ''
}))}; }
function saveJSON() {
const json = JSON.stringify(mlamhJSON(), null, 2);
const blob = new Blob([json], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'mlamh_layout.json';
a.click(); }
async function applyToProfile() {
try {
const pending = cells.filter(c => !c.deleted && c._file);
const totalBytes = pending.reduce((s, c) => s + (c._file ? c._file.size : 0), 0);
let doneBytes = 0, failed = 0;
if (totalBytes > 0) upShow(t('up_preparing'));
for (const cell of pending) {
try {
let file = cell._file;
let thumbs = null;
if (window.wcompress) {
if (file.type.indexOf('image/') === 0) {
const c = await wcompress.image(file, { max: 1280, budget: 900 * 1024, thumbs: [96, 256, 640] });
if (c) {
file = new File([c.blob], (file.name || 'cell').replace(/\.[^.]+$/, '') + '.webp', { type: 'image/webp' });
thumbs = c.thumbs;
}
} else if (file.type.indexOf('video/') === 0) {
const v = await wcompress.video(file);
if (v) file = new File([v.blob], v.name, { type: 'video/webm' });
}
}
cell.media = await uploadCellMedia(file, (sent) => upSet(doneBytes + sent, totalBytes));
if (thumbs) await uploadCellThumbs(thumbs, cell.media);
doneBytes += cell._file.size;
} catch (e) { failed++; }
dropPending(cell);
renderMlamh();
}
if (totalBytes > 0) upHide();
if (failed) alert(t('upload_failed'));
const json = JSON.stringify(mlamhJSON());
const res = await fetch('/api/mlamh', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'layout_data=' + encodeURIComponent(json)
});
if (res.ok && await res.text()) { loadMlamh(); }
else { alert(t('save_failed_toast')); }
} catch(e) { upHide(); alert(t('net_error') + e); }
}
async function deleteCumlamh() {
if (!confirm(t('delete_confirm'))) return;
try {
const res = await fetch('/api/mlamh', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'layout_data='
});
if (res.ok && await res.text()) {
cells.forEach(c => { dropPending(c); c.widget = ''; c.media = null; c.mediaType = null; c.customText = ''; });
renderMlamh(); } else { alert(t('delete_failed')); }
} catch(e) { alert(t('net_error') + e); }
}
async function loadMlamh() {
try {
const res = await fetch('/api/user?what=me');
const data = await res.json();
if (!data?.username) { location.replace('/login'); return; }
currentProfile = data;
let raw = data.mlamh;
if (!raw || raw === '') return;
const layout = JSON.parse(raw);
const maxZ = Math.max(...cells.filter(c => !c.deleted).map(c => c.zIndex));
layout.cells.forEach(lc => {
const match = cells.find(c => !c.deleted && Math.round(c.x) === Math.round(lc.x) && Math.round(c.y) === Math.round(lc.y));
if (match) {
match.w = lc.w; match.h = lc.h; match.bg = lc.bg;
match.rotation = lc.rotation || 0;
match.media = lc.media || null;
match.mediaType = lc.mediaType || null;
match.widget = lc.widget || '';
match.zIndex = lc.zIndex !== undefined ? lc.zIndex : maxZ;
match.textColor = lc.textColor || '#ffffff';
match.fontSize = lc.fontSize || 14;
match.cropW = lc.cropW || 100;
match.cropH = lc.cropH || 100;
match.customText = lc.customText || ''; }
});
renderMlamh(); } catch(e) {}
}
async function loadWidgetData() {
try {
let [pr, lr] = await Promise.all([
jsonget('/api/user?what=posts&username=' + encodeURIComponent(currentProfile.username || '')),
jsonget('/api/user?what=liked&username=' + encodeURIComponent(currentProfile.username || ''))
]);
widgetData.posts = pr || [];
widgetData.liked = lr || [];
renderMlamh(); } catch(e) {}
}
let resizeTimer;
window.onresize = () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(renderMlamh, 150); };
initMlamh();
setTimeout(async () => { await loadMlamh(); loadWidgetData(); }, 100);
document.addEventListener('langchange', () => { renderMlamh(); refreshToolbar(); });
</script>
</body>
</html>
new.html
<!DOCTYPE html>
<html lang="ar">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>وِصَال</title>
<link rel="icon" type="image/png" sizes="32x32" href="/media/favicon.png">
<link rel="icon" type="image/png" sizes="16x16" href="/media/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/media/favicon-180.png">
<link rel="icon" type="image/png" sizes="192x192" href="/media/favicon-192.png">
<meta name="theme-color" content="#030305">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
<link href="/media/pixelart/pixelart-icons-font.css" rel="stylesheet">
<style>
body {
margin: 0; padding: 24px 16px;
background: #000; color: #fff;
font-family: monospace;
display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 20px;
min-height: 100vh; box-sizing: border-box; padding: 60px 16px; }
.box { width: 100%; max-width: 520px; display: flex; flex-direction: column; gap: 12px; }
h1 { margin: 0; font-size: 26px; font-weight: normal; }
textarea, input[type="text"] {
background: rgba(255,255,255,0.06);
backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255,255,255,0.1);
color: #fff; font-family: inherit; font-size: 16px; line-height: 1.6;
border-radius: 12px; padding: 18px; width: 100%;
outline: none; box-sizing: border-box; }
textarea { min-height: 190px; resize: vertical; word-break: break-word; }
textarea:focus, input[type="text"]:focus { border-color: rgba(255,255,255,0.3); }
textarea::placeholder, input::placeholder { color: rgba(255,255,255,0.3); }
input[type="file"] { color: #fff; font-family: inherit; font-size: 15px; width: 100%; }
button {
background: #fff; color: #000; font-family: inherit; font-size: 19px;
border: none; border-radius: 8px; padding: 10px 28px; cursor: pointer; }
button:hover { background: #ddd; }
.note { font-size: 13px; min-height: 18px; color: #888; }
#preview { display: flex; flex-wrap: wrap; gap: 8px; }
#preview img, #preview video { width: 116px; height: 116px; object-fit: cover; border-radius: 8px; }
.src { position: fixed; bottom: 16px; left: 0; right: 0;
text-align: center; font-size: 13px; color: #777; }
.src a { color: inherit; }
</style>
<link href="/media/lang.css" rel="stylesheet">
<link href="/media/i18n.css" rel="stylesheet">
<script src="/media/i18n"></script>
</head>
<body class="pixel">
<div class="box">
<h1 data-i18n="create_title">منشور جديد</h1>
<textarea id="content" placeholder="وش تبغا تحط اليوم؟"></textarea>
<input type="file" id="files" multiple accept="image/jpeg,image/png,image/gif,video/mp4">
<input type="text" id="supporturl" placeholder="https://supportsite.com">
<div class="note" id="supportmsg"></div>
<div id="preview"></div>
<button onclick="publish()" data-i18n="publish">انشر</button>
<div class="note" id="msg"></div>
</div>
<div class="src">
<a href="/source" data-i18n="source_link">الكود قدامك</a> ·
<a href="/privacy" data-i18n="privacy_link">الخصوصية</a>
</div>
<script src="/media/compress"></script>
<script>
let selected = [];
let blobs = [];
const el = (id) => document.getElementById(id);
el('files').onchange = function () {
const preview = el('preview');
for (const f of Array.from(this.files)) {
selected.push(f);
const url = URL.createObjectURL(f);
blobs.push(url);
const node = document.createElement(f.type.startsWith('video/') ? 'video' : 'img');
node.src = url;
if (node.tagName === 'VIDEO') { node.muted = true; node.controls = true; node.playsinline = true; }
preview.appendChild(node);
}
this.value = '';
};
el('preview').onclick = function (e) {
const i = Array.from(this.children).indexOf(e.target);
if (i < 0) return;
URL.revokeObjectURL(blobs[i]);
blobs.splice(i, 1);
selected.splice(i, 1);
e.target.remove();
};
async function publish() {
const content = el('content').value.trim();
if (!content && !selected.length) return el('msg').textContent = t('need_text');
const fd = new FormData();
if (content) fd.append('content', content);
const wc = window.wcompress;
for (let i = 0; i < selected.length; i++) {
const f = selected[i];
let blob = f;
let name = f.name || ('file' + i);
if (wc) {
const c = await wc.image(f, { max: 2048, thumbs: [96, 256, 640] });
if (c) {
name = name.replace(/\.[^.]+$/, '') + '.webp';
blob = new File([c.blob], name, { type: 'image/webp' });
fd.append('media', blob, name);
for (const w of [96, 256, 640]) {
if (c.thumbs[w]) fd.append('thumb' + w + '.' + i, c.thumbs[w], 'thumb.webp');
}
continue;
}
if (f.type.indexOf('video/') === 0) {
const v = await wc.video(f, p => {
el('msg').textContent = t('publishing') + ' ' + Math.round(p * 100) + '%';
});
if (v) {
name = v.name;
blob = new File([v.blob], name, { type: 'video/webm' });
}
}
}
fd.append('media', blob, name);
}
const su = el('supporturl').value.trim();
if (su) fd.append('supporturl', su);
el('msg').textContent = t('publishing');
const r = await uploadForm('/api/create', fd).catch(() => null);
if (!r) { el('msg').textContent = t('no_conn'); return; }
if (r.ok && await r.text()) {
el('msg').style.color = '#6f6';
el('msg').textContent = t('published');
setTimeout(() => { location.href = '/madar'; }, 600);
} else {
el('msg').style.color = '#f66';
el('msg').textContent = t('error');
}
}
</script>
<script src="/media/app.js"></script>
</body>
</html>
privacy.html
<!DOCTYPE html>
<html lang="ar">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>وِصَال</title>
<link rel="icon" type="image/png" sizes="32x32" href="/media/favicon.png">
<link rel="icon" type="image/png" sizes="16x16" href="/media/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/media/favicon-180.png">
<link rel="icon" type="image/png" sizes="192x192" href="/media/favicon-192.png">
<meta name="theme-color" content="#030305">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { background:#000; color:rgba(255,255,255,0.7); font-family:monospace; font-size:13px; line-height:1.6; padding:40px; max-width:760px; min-height:100vh; margin:0 auto; display:flex; flex-direction:column; align-items:center; justify-content:center; }
h1 { font-family:monospace; color:#fff; font-size:28px; margin:40px 0 12px; letter-spacing:2px; border-bottom:1px solid #333; padding-bottom:8px; }
h2 { font-family:monospace; color:rgba(255,255,255,0.85); font-size:18px; margin:28px 0 8px; }
p { margin-bottom:10px; text-align:center; color:#fff; }
ul { margin:0 0 10px 22px; }
li { margin-bottom:4px; }
a { color:rgba(255,255,255,0.55); }
b { color:#fff; }
</style>
<link href="/media/lang.css" rel="stylesheet">
</head>
<body class="legal mono">
<p>شغال عليه في النسخه العامه بيكون جاهز</p>
</body>
</html>
profile.html
<!DOCTYPE html>
<html lang="ar">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>وِصَال</title>
<link rel="icon" type="image/png" sizes="32x32" href="/media/favicon.png">
<link rel="icon" type="image/png" sizes="16x16" href="/media/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/media/favicon-180.png">
<link rel="icon" type="image/png" sizes="192x192" href="/media/favicon-192.png">
<meta name="theme-color" content="#030305">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
<link href="/media/pixelart/pixelart-icons-font.css" rel="stylesheet">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body {
background:#030305; color:#fff; font-family:'VT323',monospace;
min-height:100vh;
background-size:cover; background-position:center; background-repeat:no-repeat;
background-attachment:fixed;
scrollbar-width:none; -ms-overflow-style:none; overflow-y:scroll; }
body::-webkit-scrollbar { display:none; }
.container { max-width:1000px; margin:0 auto; padding:0 16px; min-height:100vh; }
.glass-box {
background:rgba(255,255,255,0.02);
backdrop-filter:blur(32px); -webkit-backdrop-filter:blur(32px);
border-left:1px solid rgba(255,255,255,0.06);
border-right:1px solid rgba(255,255,255,0.06);
border-radius:0 0 16px 16px;
overflow:hidden;
width: 100%; }
.prof-head {
position:relative; padding:0 16px; margin-top:-75px;
display:flex; align-items:flex-end; gap:16px; min-height:50px; }
.av-wrap {
width:165px; height:165px; border-radius:50%;
border:3px solid rgba(255,255,255,0.15); overflow:hidden;
background:rgba(255,255,255,0.06);
backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px);
flex-shrink:0; position:relative; cursor:pointer;
margin-left:0; }
.av-wrap img { width:100%; height:100%; object-fit:cover; }
.prof-info { padding:12px 16px 50px; }
.prof-info .nm { font-size:28px; text-shadow:0 2px 8px rgba(0,0,0,0.4); }
.prof-info .un { font-size:16px; margin-top:2px; }
.prof-info .bio { font-size:18px; margin-top:10px; white-space:pre-wrap; }
.prof-info .meta { font-size:14px; margin-top:8px; display:flex; gap:16px; flex-wrap:wrap; align-items:center; }
.edit-btn {
background:none; border:1px solid rgba(255,255,255,0.15);
font-family:'VT323',monospace; font-size:14px; padding:4px 14px; border-radius:999px;
cursor:pointer; transition:color 0.15s, border-color 0.15s;
text-transform:uppercase; letter-spacing:1px;
margin-left:auto; margin-bottom:4px; }
.banner { width:100%; height:300px; border-radius:0 0 12px 12px; background:rgba(255,255,255,0.04); overflow:hidden; position:relative; backdrop-filter:blur(8px); -webkit-backdrop-filter:blur(8px); border-bottom:1px solid rgba(255,255,255,0.3); }
.banner img { width:100%; height:100%; object-fit:cover; object-position:center; }
.tabs {
display:flex; gap:6px; justify-content:center;
margin-top:10px; padding-bottom:16px; width:100%; }
.tabs div {
flex:1; text-align:center;
padding:6px 20px; font-size:15px; cursor:pointer;
text-transform:uppercase; letter-spacing:1px;
transition:color 0.15s, border-color 0.15s, background 0.15s;
background:rgba(255,255,255,0.02);
backdrop-filter:blur(32px); -webkit-backdrop-filter:blur(32px);
border:1px solid rgba(255,255,255,0.06);
border-radius:999px;
box-shadow:0 8px 32px rgba(0,0,0,0.3); }
.tabs div.act { border-color:rgba(255,255,255,0.15); background:rgba(255,255,255,0.06); color:#fff; }
.feed { padding:20px 0; width:100%; }
.post-card { margin-bottom:12px; width:100%; }
.loading { text-align:center; color:#888; padding:40px 20px; font-size:16px; }
.edit-overlay {
position:fixed; top:0; left:0; width:100%; height:100%;
background:rgba(0,0,0,0.5); backdrop-filter:blur(8px);
z-index:9999; display:none; align-items:center; justify-content:center; }
.edit-box {
background:rgba(255,255,255,0.06); backdrop-filter:blur(24px);
border:1px solid rgba(255,255,255,0.1); border-radius:12px;
padding:28px; width:700px; max-width:90vw; max-height:90vh; overflow-y:auto;
box-shadow:0 8px 32px rgba(0,0,0,0.4);
display:grid; grid-template-columns:1fr 1fr; gap:8px 16px; }
.edit-box .ttl { font-size:22px; grid-column:1/-1; text-align:center; margin-bottom:8px; }
.edit-box label { display:block; font-size:13px; margin-bottom:2px; margin-top:4px; }
.edit-box input[type=file], .edit-box input[type=text], .edit-box textarea {
width:100%; font-family:'VT323',monospace; font-size:14px; color:#fff;
background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08);
border-radius:6px; padding:6px; outline:none; cursor:pointer; box-sizing:border-box; }
.edit-box input[type=color] { height:36px; padding:2px; width:100%; box-sizing:border-box; cursor:pointer; }
.edit-box textarea { resize:vertical; min-height:50px; }
.eb-span2 { grid-column:span 2; }
.edit-box .eb-btns { display:flex; gap:10px; grid-column:1/-1; margin-top:8px; }
.edit-box .eb-btns button {
flex:1; padding:8px 0; border-radius:6px; cursor:pointer;
font-family:'VT323',monospace; font-size:16px; border:none; transition:opacity 0.15s;
color:#fff; }
.edit-box .eb-save { background:rgba(255,255,255,0.1); }
.edit-box .eb-save:hover { opacity:0.7; }
.edit-box .eb-can { background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.06); }
.edit-box .eb-can:hover { opacity:0.7; }
.mlamh-mlamh {
position:fixed; top:0; left:0; width:100%; height:100%;
z-index:9998; display:none; }
.mlamh-mlamh .cc {
position:absolute;
display:flex; align-items:center; justify-content:center;
overflow:hidden;
font-size:12px; padding:4px; }
.mlamh-mlamh .cc img, .mlamh-mlamh .cc video { width:100%; height:100%; object-fit:cover; }
.src-link { position:fixed; bottom:16px; left:16px; z-index:100000; }
.src-link a { color:#fff; font-size:18px; font-family:'VT323',monospace; text-decoration:none; transition:color 0.15s; }
.src-link a:hover { color:#ccc; }
@media (max-width: 600px) {
.glass-box { border-radius: 0 0 12px 12px; }
.banner { width:100%; height:200px; }
.banner img { height:100%; object-fit:cover; object-position:center; }
.edit-btn { font-size: 12px; padding: 3px 10px; }
.tabs { gap: 4px; padding-bottom: 12px; }
.tabs div { padding: 5px 14px; font-size: 13px; }
.edit-box { padding: 20px; grid-template-columns: 1fr; width: 100%; max-width: 100%; border-radius: 12px 12px 0 0; }
.eb-span2 { grid-column: auto; }
.edit-box .eb-btns { flex-direction: column; }
}
@media (max-width:1599px) { .src-link a { font-size:13px; } .src-link { bottom:24px; } }
@media (max-width:1200px) { .src-link a { font-size:11px; } .src-link { bottom:18px; } }
@media (max-width:900px) { .src-link a { font-size:10px; } .src-link { bottom:12px; left:8px; } }
@media (max-width:700px) { .src-link a { font-size:9px; } .src-link { bottom:8px; } }
</style>
<link href="/media/lang.css" rel="stylesheet">
<link href="/media/i18n.css" rel="stylesheet">
<script src="/media/i18n"></script>
<script src="/media/compress.js" defer></script>
</head>
<body data-home="/madar" class="pixel">
<video id="profileBgVideo" autoplay muted loop playsinline style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;object-fit:cover;z-index:-1;"></video>
<div class="src-link"><a href="/source" data-i18n="src_credit">ذابحه الثقه موري كوده قدام... محد</a><br><a href="/privacy" style="color:#fff;font-size:11px;text-decoration:none;font-family:inherit;" data-i18n="privacy_terms">سياسه الخصوصيه وشروط الاستخدام</a></div>
<div class="container">
<div id="content"></div>
</div>
<div class="mlamh-mlamh" id="mlamhMlamh"></div>
<div id="imgViewer" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:999999;cursor:zoom-out;justify-content:center;align-items:center;" onclick="closeimage()"><img id="imgViewerSrc" style="max-width:95%;max-height:95%;object-fit:contain;"><div style="position:fixed;top:16px;right:24px;font-size:32px;color:#fff;cursor:pointer;font-family:monospace;" onclick="closeimage()">x</div></div>
<div class="edit-overlay" id="editOverlay">
<div class="edit-box">
<div class="ttl" data-i18n="edit_title">تعديل</div>
<div><label data-i18n="l_avatar">الافتار</label><input type="file" id="avInput" accept="image/jpeg,image/png,image/gif,video/mp4"></div>
<div><label data-i18n="l_banner">الغلاف</label><input type="file" id="bannerInput" accept="image/jpeg,image/png,image/gif,video/mp4"></div>
<div><label data-i18n="l_bg">الخلفيه</label><input type="file" id="bgInput" accept="image/jpeg,image/png,image/gif,video/mp4"></div>
<div><label class="mono" data-i18n="l_displayname">الاسم الظاهر</label><input type="text" id="dnInput"></div>
<div class="eb-span2"><label data-i18n="l_bio">البايو</label><textarea id="bioInput"></textarea></div>
<div><label data-i18n="l_namecolor">لون الاسم</label><input type="color" id="ncInput" value="#ffffff"></div>
<div><label data-i18n="l_biocolor">لون البايو</label><input type="color" id="bcInput" value="#ffffff"></div>
<div><label data-i18n="l_dob">تاريخ الميلاد</label><input type="date" id="dobInput"></div>
<div><label class="mono" data-i18n="l_jointext">نص الانضمام</label><input type="text" id="jtInput" value="عضو منذ"></div>
<div><label class="mono" data-i18n="l_joincolor">لون الانضمام</label><input type="color" id="jcInput" value="#ffffff"></div>
<div><label data-i18n="l_tabcolor">لون التبويبات</label><input type="color" id="tcInput" value="#ffffff"></div>
<div><label data-i18n="l_tab1">اسم التبويب ١</label><input type="text" id="t1Input" value="منشورات"></div>
<div><label data-i18n="l_tab2">اسم التبويب ٢</label><input type="text" id="t2Input" value="إعجابات"></div>
<div><label data-i18n="l_editbtn">زر التخصيص</label><input type="text" id="epInput" value="تعديل"></div>
<div><label data-i18n="l_editbtncolor">لون زر التعديل</label><input type="color" id="epcInput" value="#ffffff"></div>
<div><label data-i18n="l_postcolor">لون المنشور</label><input type="color" id="pcInput" value="#ffffff"></div>
<div class="eb-span2" style="margin-top:4px;"><label style="font-size:14px;color:#888;"><span data-i18n="support_label">دعم</span> <span style="color:#fff;font-size:11px;" data-i18n="support_free">بدون رسوم</span></label><div id="supportlinks"></div></div>
<div class="eb-span2" style="margin-top:12px; padding-top:12px; border-top:1px solid rgba(255,255,255,0.06);">
<div style="font-size:15px; color:#fff; line-height:1.4; margin-bottom:6px;">
<span class="mono" style="color:#ff4444;font-weight:bold;" data-i18n="danger_note">مايحتاج اقول</span>
</div>
<button onclick="ra()" style="background:rgba(255,0,0,0.15); color:#ff6666; border:1px solid rgba(255,0,0,0.2); border-radius:6px; padding:6px 14px; font-family:inherit; font-size:14px; cursor:pointer;" data-i18n="delete_account">حذف الحساب</button>
</div>
<div class="eb-btns">
<button class="eb-save" onclick="sp()" data-i18n="save">حفظ</button>
<button class="eb-can" onclick="te()" data-i18n="cancel">إلغاء</button>
</div>
</div>
</div>
<script src="/media/app"></script>
<script src="/media/profile"></script>
<script>
(async () => {
await myprofile();
const wanted = new URLSearchParams(location.search).get('username');
const who = wanted || curUser;
if (who) await fp(who);
else document.getElementById('content').innerHTML = '<div class="empty">سجّل دخولك عشان تشوف ملفك</div>';
document.addEventListener('langchange', () => { if (who) fp(who); });
})().catch(() => {
const c = document.getElementById('content');
if (c) c.innerHTML = '<div class="empty">تعذر تحميل الملف</div>';
});
</script>
</body>
</html>
profile.js
(function () {
const g = p => location.href = '/' + p;
window.g = g;
let c = 'posts', P = null;
function q(k) { return new URLSearchParams(location.search).get(k); }
function te() {
const e = document.getElementById('editOverlay'), s = e.style.display === 'flex';
e.style.display = s ? 'none' : 'flex';
if (!s && L) {
const box = document.getElementById('supportlinks');
box.innerHTML = '';
let l = [];
try {
let j = JSON.parse(L.supportlinks || '[]');
if (Array.isArray(j)) l = j;
} catch (err) {}
for (let i = 0; i < 10; i++) {
const n = document.createElement('input');
n.type = 'text';
n.id = 'support' + i;
n.value = l[i] || '';
n.placeholder = t('support_ph') + (i + 1);
n.style.cssText = 'width:100%;font-family:monospace;font-size:14px;color:#fff;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);border-radius:6px;padding:6px;outline:none;margin-bottom:4px;';
box.appendChild(n);
}
document.getElementById('dobInput').value = L.dob || '';
document.getElementById('dnInput').value = L.displayname || '';
document.getElementById('bioInput').value = L.bio || '';
document.getElementById('ncInput').value = L.namecolor || '#ffffff';
document.getElementById('bcInput').value = L.biocolor || '#ffffff';
document.getElementById('jtInput').value = L.jointext || t('d_join');
document.getElementById('jcInput').value = L.joincolor || '#ffffff';
document.getElementById('tcInput').value = L.tabcolor || '#ffffff';
document.getElementById('t1Input').value = L.tab1name || t('tab_posts');
document.getElementById('t2Input').value = L.tab2name || t('tab_likes');
document.getElementById('epInput').value = L.editprofiletext || t('d_editbtn');
document.getElementById('epcInput').value = L.editprofilecolor || '#ffffff';
document.getElementById('pcInput').value = L.postcolor || '#ffffff';
}
}
async function sp() {
const f = new FormData();
const a = document.getElementById('avInput');
const b = document.getElementById('bannerInput');
const bg = document.getElementById('bgInput');
for (const [input, field, max] of [[a, 'avatar', 1024], [b, 'banner', 2048], [bg, 'profilebg', 2048]]) {
if (!input.files.length) continue;
const file = input.files[0];
const packed = await wcompress.image(file, { max, thumbs: [96, 256, 640] });
if (packed) {
f.append(field, packed.blob, 'image.webp');
for (const width of [96, 256, 640]) if (packed.thumbs[width]) f.append('thumb' + width + '.' + field, packed.thumbs[width], 'thumb.webp');
} else f.append(field, file);
}
[['dnInput', 'displayname'], ['bioInput', 'bio'], ['ncInput', 'namecolor'],
['bcInput', 'biocolor'], ['jtInput', 'jointext'], ['jcInput', 'joincolor'],
['tcInput', 'tabcolor'], ['t1Input', 'tab1name'], ['t2Input', 'tab2name'],
['epInput', 'editprofiletext'], ['epcInput', 'editprofilecolor'],
['pcInput', 'postcolor']].forEach(([i, k]) => f.append(k, document.getElementById(i).value));
let h = false;
for (let i = 0; i < 10; i++) {
const n = document.getElementById('support' + i);
if (n && n.value.trim()) { h = true; f.append('support' + i, n.value.trim()); }
}
if (!h) f.append('clear_support', '1');
const r = await uploadForm('/api/user', f);
if (r.ok && await r.text()) {
const d = document.getElementById('dobInput').value;
if (d) {
const f2 = new FormData();
f2.append('dob', d);
await fetch('/api/account?what=dob', { method: 'POST', body: f2 });
}
a.value = ''; b.value = ''; bg.value = '';
te();
if (P) { c = 'posts'; await fp(P); }
} else alert(t('save_failed'));
}
function ra() {
if (!confirm(t('danger_note'))) return;
fetch('/api/account', { method: 'POST' }).then(r => {
if (r.ok) g('login');
else alert(t('save_failed'));
}).catch(() => alert(t('save_failed')));
}
async function fp(u) {
P = u;
const r = await fetch('/api/user?username=' + encodeURIComponent(u));
if (!r.ok) {
document.getElementById('content').innerHTML = '<div class="empty">' + t('user_missing') + '</div>';
return;
}
const u2 = await r.json();
if (!u2.username) {
document.getElementById('content').innerHTML = '<div class="empty">' + t('user_missing') + '</div>';
return;
}
const pc = u2.postcolor;
let ps = document.getElementById('postColorStyle');
if (!ps) {
ps = document.createElement('style');
ps.id = 'postColorStyle';
document.head.appendChild(ps);
}
ps.textContent = '#feed .post-body { color: ' + pc + ' !important; }';
const o = u === curUser;
L = u2;
let bv = document.getElementById('profileBgVideo');
let bv2 = /\.mp4$/i.test(u2.profilebg || '');
if (bv) {
bv.src = bv2 ? mediaurl(u2.profilebg) : '';
bv.style.display = bv2 ? 'block' : 'none';
}
if (u2.profilebg) {
document.body.style.backgroundImage = bv2 ? 'none' : 'url(' + mediaurl(u2.profilebg) + ')';
document.body.style.backgroundSize = 'cover';
document.body.style.backgroundPosition = 'center';
document.body.style.backgroundRepeat = 'no-repeat';
document.body.style.backgroundAttachment = 'fixed';
} else {
if (bv) { bv.src = ''; bv.style.display = 'none'; }
document.body.style.backgroundImage = 'url(/media/wbg/profilewallpaper.webp)';
}
document.getElementById('content').innerHTML = prohtml(u2, o);
if (o) {
document.getElementById('dnInput').value = u2.displayname || '';
document.getElementById('bioInput').value = u2.bio || '';
document.getElementById('ncInput').value = u2.namecolor || '#ffffff';
document.getElementById('bcInput').value = u2.biocolor || '#ffffff';
document.getElementById('jtInput').value = u2.jointext || 'عضو منذ';
document.getElementById('jcInput').value = u2.joincolor || '#ffffff';
document.getElementById('tcInput').value = u2.tabcolor || '#ffffff';
document.getElementById('t1Input').value = u2.tab1name || 'منشوراتي';
document.getElementById('t2Input').value = u2.tab2name || 'حبييييت <3';
document.getElementById('epInput').value = u2.editprofiletext || 'تخصيص';
document.getElementById('epcInput').value = u2.editprofilecolor || '#ffffff';
document.getElementById('pcInput').value = u2.postcolor || '#ffffff';
}
document.querySelectorAll('.tabs div').forEach(e => {
e.classList.toggle('act', e.dataset.tab === c);
e.onclick = () => {
if (e.dataset.tab === c) return;
document.querySelectorAll('.tabs div').forEach(x => x.classList.remove('act'));
e.classList.add('act');
c = e.dataset.tab;
document.getElementById('feed').innerHTML = '<div class="loading"></div>';
lf(u, c);
};
});
await lf(u, c);
updateMlamh();
}
async function lf(u, tab) {
const f = document.getElementById('feed');
if (!f) return;
const list = await jsonget('/api/user?what=' + (tab === 'posts' ? 'posts' : 'liked') +
'&username=' + encodeURIComponent(u));
if (!list.length) {
f.innerHTML = '<div class="empty">' + (tab === 'posts' ? t('empty_posts') : t('empty_likes')) + '</div>';
return;
}
f.innerHTML = postshtml(list, 'u');
const io = new IntersectionObserver(es => {
es.forEach(x => {
if (!x.isIntersecting) return;
const v = x.target, p = v.dataset.pid;
if (!p) return;
io.unobserve(v);
fetch('/api/feed?col=view&post_id=' + p, { method: 'POST' }).then(r2 => j(r2, { viewsco: 0 }))
.then(d2 => { v.textContent = (d2.counts && d2.counts[p]) || 0; });
});
}, { rootMargin: '100px' });
f.querySelectorAll('.vc').forEach(v => io.observe(v));
f.querySelectorAll('.post-media img').forEach(i => i.addEventListener('click', e => {
if (e.target.tagName === 'IMG' && e.target.closest('.post-media')) vi(e.target.src.replace(/\?w=\d+$/, ''));
}));
}
function updateMlamh() {
const gr = document.getElementById('mlamhMlamh');
if (!gr) return;
if (!L || !curUser || L.username !== curUser) { gr.style.display = 'none'; return; }
const raw = L.mlamh;
if (!raw || raw === '[]' || raw === '') { gr.style.display = 'none'; return; }
try {
const layout = JSON.parse(raw);
if (!layout.cells || layout.cells.length === 0) { gr.style.display = 'none'; return; }
gr.style.display = 'block';
profilemlamh(layout, gr);
} catch (e) { gr.style.display = 'none'; }
}
function profilemlamh(layout, gr) {
gr.innerHTML = '';
const cols = layout.cols || 16, rows = layout.rows || 16;
const w = window.innerWidth, h = window.innerHeight;
const cw = w / cols, ch = h / rows;
layout.cells.forEach(cell => {
if (!cell || cell.deleted) return;
const hasContent = cell.widget || (cell.mediaType && cell.media) || cell.customText || (cell.bg !== undefined);
if (!hasContent) return;
const el = document.createElement('div');
el.className = 'cc';
el.style.left = (cell.x * cw) + 'px';
el.style.top = (cell.y * ch) + 'px';
el.style.width = (cell.w * cw) + 'px';
el.style.height = (cell.h * ch) + 'px';
if (cell.rotation) el.style.transform = 'rotate(' + cell.rotation + 'deg)';
if (cell.bg !== undefined) el.style.background = cell.bg;
if (cell.widget) {
const p = L;
if (cell.widget === 'avatar' && p.avatar) {
const img = document.createElement('img');
img.src = mediaurl(p.avatar);
el.appendChild(img);
} else if (cell.widget === 'banner' && p.banner) {
const img = document.createElement('img');
img.src = mediaurl(p.banner);
el.appendChild(img);
} else if (cell.widget === 'display_name') {
el.textContent = p.displayname || p.username || '?';
} else if (cell.widget === 'bio') {
el.textContent = p.bio || '';
} else if (cell.widget === 'custom_text') {
el.textContent = cell.customText || '';
} else if (cell.widget === 'joined') {
el.textContent = (p.jointext || t('d_join')) + ' ' + (p.createtime ? p.createtime.split('T')[0] : '');
} else if (cell.widget === 'links') {
let links = [];
try { const j = JSON.parse(p.supportlinks || '[]'); if (Array.isArray(j)) links = j.filter(l => l.trim()); } catch (e) {}
if (links.length === 0) { el.textContent = p.support || ''; }
else {
el.innerHTML = links.map(l => {
try {
const host = new URL(l).hostname.replace(/^www\./, '');
const known = PLATFORMS;
return '<a href="' + esc(l) + '" target="_blank" rel="noopener" style="color:#fff;text-decoration:none;border:1px solid rgba(255,255,255,0.1);border-radius:999px;padding:2px 12px;font-size:12px;display:inline-block;margin:2px;" onmouseover="var c=colors[Math.floor(Math.random()*colors.length)];this.style.color=c;this.style.borderColor=c;" onmouseout="this.style.color=\'#fff\';this.style.borderColor=\'rgba(255,255,255,0.1)\';">' + esc(known[host] || 'دعم') + '</a>';
} catch (e) { return ''; }
}).join(' ');
}
} else if (cell.widget === 'posts') {
el.textContent = t('tab_posts');
} else if (cell.widget === 'likes') {
el.textContent = t('tab_likes');
}
} else if (cell.mediaType === 'image' && cell.media) {
const img = document.createElement('img');
img.src = mediaurl(cell.media);
img.onerror = function () { this.parentElement.style.display = 'none'; };
el.appendChild(img);
} else if (cell.mediaType === 'video' && cell.media) {
const vid = document.createElement('video');
vid.src = mediaurl(cell.media);
vid.muted = true; vid.loop = true; vid.autoplay = true;
vid.style.width = '100%'; vid.style.height = '100%'; vid.style.objectFit = 'cover';
vid.onerror = function () { this.parentElement.style.display = 'none'; };
el.appendChild(vid);
} else if (cell.mediaType && !cell.media) {
return;
}
gr.appendChild(el);
});
}
Object.assign(window, { te, sp, ra, fp, lf });
})();
profilep.html
<!DOCTYPE html>
<html lang="ar">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>وِصَال</title>
<link rel="icon" type="image/png" sizes="32x32" href="/media/favicon.png">
<link rel="icon" type="image/png" sizes="16x16" href="/media/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/media/favicon-180.png">
<link rel="icon" type="image/png" sizes="192x192" href="/media/favicon-192.png">
<meta name="theme-color" content="#030305">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
<link href="/media/pixelart/pixelart-icons-font.css" rel="stylesheet">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body {
background:#030305; color:#fff; font-family:'VT323',monospace;
min-height:100vh;
background-size:cover; background-position:center; background-repeat:no-repeat;
background-attachment:fixed;
scrollbar-width:none; -ms-overflow-style:none; overflow-y:scroll; }
body::-webkit-scrollbar { display:none; }
.container { max-width:1000px; margin:0 auto; padding:0 10px; min-height:100vh; }
.glass-box {
background:rgba(255,255,255,0.02);
backdrop-filter:blur(32px); -webkit-backdrop-filter:blur(32px);
border-left:1px solid rgba(255,255,255,0.06);
border-right:1px solid rgba(255,255,255,0.06);
border-radius:0 0 12px 12px;
overflow:hidden;
width: 100%; }
.banner { width:100%; height:200px; border-radius:0 0 12px 12px; background:rgba(255,255,255,0.04); overflow:hidden; position:relative; backdrop-filter:blur(8px); -webkit-backdrop-filter:blur(8px); border-bottom:1px solid rgba(255,255,255,0.3); }
.banner img { width:100%; height:100%; object-fit:contain; object-position:center; }
.prof-head {
position:relative; padding:0 12px; margin-top:-55px;
display:flex; align-items:flex-end; gap:12px; min-height:50px; }
.av-wrap {
width:110px; height:110px; border-radius:50%;
border:3px solid rgba(255,255,255,0.15); overflow:hidden;
background:rgba(255,255,255,0.06);
backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px);
flex-shrink:0; position:relative; cursor:pointer; }
.av-wrap img { width:100%; height:100%; object-fit:cover; }
.prof-info { padding:10px 12px 40px; }
.prof-info .nm { font-size:26px; text-shadow:0 2px 8px rgba(0,0,0,0.4); }
.prof-info .un { font-size:15px; margin-top:2px; }
.prof-info .bio { font-size:17px; margin-top:8px; white-space:pre-wrap; }
.prof-info .meta { font-size:13px; margin-top:6px; display:flex; gap:12px; flex-wrap:wrap; align-items:center; }
.edit-btn {
background:none; border:1px solid rgba(255,255,255,0.15);
font-family:'VT323',monospace; font-size:13px; padding:4px 12px; border-radius:999px;
cursor:pointer; transition:color 0.15s, border-color 0.15s;
text-transform:uppercase; letter-spacing:1px;
margin-left:auto; margin-bottom:4px; }
.tabs {
display:flex; gap:6px; justify-content:center;
margin-top:8px; padding-bottom:12px; width:100%; }
.tabs div {
flex:1; text-align:center;
padding:6px 16px; font-size:14px; cursor:pointer;
text-transform:uppercase; letter-spacing:1px;
transition:color 0.15s, border-color 0.15s, background 0.15s;
background:rgba(255,255,255,0.03);
border:1px solid rgba(255,255,255,0.06);
border-radius:999px;
box-shadow:0 2px 8px rgba(0,0,0,0.15); }
.tabs div.act { border-color:rgba(255,255,255,0.15); background:rgba(255,255,255,0.08); color:#fff; }
.feed { padding:16px 0; width:100%; }
.post-card { margin-bottom:12px; width:100%; }
.loading { text-align:center; color:#888; padding:40px 20px; font-size:16px; }
.edit-overlay {
position:fixed; top:0; left:0; width:100%; height:100%;
background:rgba(0,0,0,0.5); backdrop-filter:blur(8px);
z-index:9999; display:none; align-items:center; justify-content:center; }
.edit-box {
background:rgba(255,255,255,0.06); backdrop-filter:blur(24px);
border:1px solid rgba(255,255,255,0.1); border-radius:12px;
padding:20px; width:100%; max-width:100%; max-height:90vh; overflow-y:auto;
box-shadow:0 8px 32px rgba(0,0,0,0.4);
display:grid; grid-template-columns:1fr; gap:8px 16px; }
.edit-box .ttl { font-size:20px; grid-column:1/-1; text-align:center; margin-bottom:8px; }
.edit-box label { display:block; font-size:13px; margin-bottom:2px; margin-top:4px; }
.edit-box input[type=file], .edit-box input[type=text], .edit-box textarea {
width:100%; font-family:'VT323',monospace; font-size:14px; color:#fff;
background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08);
border-radius:6px; padding:6px; outline:none; cursor:pointer; box-sizing:border-box; }
.edit-box input[type=color] { height:36px; padding:2px; width:100%; box-sizing:border-box; cursor:pointer; }
.edit-box textarea { resize:vertical; min-height:50px; }
.eb-span2 { grid-column:1/-1; }
.edit-box .eb-btns { display:flex; gap:10px; grid-column:1/-1; margin-top:8px; flex-direction:column; }
.edit-box .eb-btns button {
flex:1; padding:8px 0; border-radius:6px; cursor:pointer;
font-family:'VT323',monospace; font-size:16px; border:none; transition:opacity 0.15s;
color:#fff; }
.edit-box .eb-save { background:rgba(255,255,255,0.1); }
.edit-box .eb-save:hover { opacity:0.7; }
.edit-box .eb-can { background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.06); }
.edit-box .eb-can:hover { opacity:0.7; }
.mlamh-mlamh {
position:fixed; top:0; left:0; width:100%; height:100%;
z-index:9998; display:none; }
.mlamh-mlamh .cc {
position:absolute;
display:flex; align-items:center; justify-content:center;
overflow:hidden;
font-size:12px; padding:4px; }
.mlamh-mlamh .cc img, .mlamh-mlamh .cc video { width:100%; height:100%; object-fit:cover; }
.src-link { position:fixed; bottom:16px; left:12px; z-index:100000; }
.src-link a { color:#fff; font-size:11px; font-family:'VT323',monospace; text-decoration:none; transition:color 0.15s; }
.src-link a:hover { color:#ccc; }
.back-btn {
position:fixed; top:12px; left:12px; z-index:100001;
background:none; border:1px solid rgba(255,255,255,0.15); border-radius:999px;
color:#fff; font-family:'VT323',monospace; font-size:16px;
padding:4px 14px; cursor:pointer; text-transform:uppercase; letter-spacing:1px;
-webkit-tap-highlight-color:transparent; outline:none; }
button, .edit-btn, .back-btn { outline:none; -webkit-tap-highlight-color:transparent; }
@media (max-width:1599px) { .src-link a { font-size:13px; } .src-link { bottom:24px; } }
@media (max-width:1200px) { .src-link a { font-size:11px; } .src-link { bottom:18px; } }
@media (max-width:900px) { .src-link a { font-size:10px; } .src-link { bottom:12px; left:8px; } }
@media (max-width:700px) { .src-link a { font-size:9px; } .src-link { bottom:8px; } }
</style>
<link href="/media/lang.css" rel="stylesheet">
<link href="/media/i18n.css" rel="stylesheet">
<script src="/media/i18n"></script>
<script src="/media/compress.js" defer></script>
</head>
<body data-home="/madar-phone" class="pixel">
<video id="profileBgVideo" autoplay muted loop playsinline style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;object-fit:cover;z-index:-1;"></video>
<button class="back-btn" onclick="window.location.href='/madar-phone'" data-i18n="back">رجوع</button>
<div class="src-link"><a href="/source" data-i18n="src_credit">ذابحه الثقه موري كوده قدام... محد</a><br><a href="/privacy" style="color:#fff;font-size:11px;text-decoration:none;font-family:inherit;" data-i18n="privacy_terms">سياسه الخصوصيه وشروط الاستخدام</a></div>
<div class="container">
<div id="content"></div>
</div>
<div class="mlamh-mlamh" id="mlamhMlamh"></div>
<div id="imgViewer" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:999999;cursor:zoom-out;justify-content:center;align-items:center;" onclick="closeimage()"><img id="imgViewerSrc" style="max-width:95%;max-height:95%;object-fit:contain;"><div style="position:fixed;top:16px;right:24px;font-size:32px;color:#fff;cursor:pointer;font-family:monospace;" onclick="closeimage()">x</div></div>
<div class="edit-overlay" id="editOverlay">
<div class="edit-box">
<div class="ttl" data-i18n="edit_title">تعديل</div>
<div><label data-i18n="l_avatar">الافتار</label><input type="file" id="avInput" accept="image/jpeg,image/png,image/gif,video/mp4"></div>
<div><label data-i18n="l_banner">الغلاف</label><input type="file" id="bannerInput" accept="image/jpeg,image/png,image/gif,video/mp4"></div>
<div><label data-i18n="l_bg">الخلفيه</label><input type="file" id="bgInput" accept="image/jpeg,image/png,image/gif,video/mp4"></div>
<div><label class="mono" data-i18n="l_displayname">الاسم الظاهر</label><input type="text" id="dnInput"></div>
<div class="eb-span2"><label data-i18n="l_bio">البايو</label><textarea id="bioInput"></textarea></div>
<div><label data-i18n="l_namecolor">لون الاسم</label><input type="color" id="ncInput" value="#ffffff"></div>
<div><label data-i18n="l_biocolor">لون البايو</label><input type="color" id="bcInput" value="#ffffff"></div>
<div><label data-i18n="l_dob">تاريخ الميلاد</label><input type="date" id="dobInput"></div>
<div><label class="mono" data-i18n="l_jointext">نص الانضمام</label><input type="text" id="jtInput" value="عضو منذ"></div>
<div><label class="mono" data-i18n="l_joincolor">لون الانضمام</label><input type="color" id="jcInput" value="#ffffff"></div>
<div><label data-i18n="l_tabcolor">لون التبويبات</label><input type="color" id="tcInput" value="#ffffff"></div>
<div><label data-i18n="l_tab1">اسم التبويب ١</label><input type="text" id="t1Input" value="منشورات"></div>
<div><label data-i18n="l_tab2">اسم التبويب ٢</label><input type="text" id="t2Input" value="إعجابات"></div>
<div><label data-i18n="l_editbtn">زر التخصيص</label><input type="text" id="epInput" value="تعديل"></div>
<div><label data-i18n="l_editbtncolor">لون زر التعديل</label><input type="color" id="epcInput" value="#ffffff"></div>
<div><label data-i18n="l_postcolor">لون المنشور</label><input type="color" id="pcInput" value="#ffffff"></div>
<div class="eb-span2" style="margin-top:4px;"><label style="font-size:14px;color:#888;"><span data-i18n="support_label">دعم</span> <span style="color:#fff;font-size:11px;" data-i18n="support_free">بدون رسوم</span></label><div id="supportlinks"></div></div>
<div class="eb-span2" style="margin-top:12px; padding-top:12px; border-top:1px solid rgba(255,255,255,0.06);">
<div style="font-size:15px; color:#fff; line-height:1.4; margin-bottom:6px;">
<span class="mono" style="color:#ff4444;font-weight:bold;" data-i18n="danger_note">مايحتاج اقول</span>
</div>
<button onclick="ra()" style="background:rgba(255,0,0,0.15); color:#ff6666; border:1px solid rgba(255,0,0,0.2); border-radius:6px; padding:6px 14px; font-family:inherit; font-size:14px; cursor:pointer;" data-i18n="delete_account">حذف الحساب</button>
</div>
<div class="eb-btns">
<button class="eb-save" onclick="sp()" data-i18n="save">حفظ</button>
<button class="eb-can" onclick="te()" data-i18n="cancel">إلغاء</button>
</div>
</div>
</div>
<script src="/media/app.js"></script>
<script src="/media/profile"></script>
<div data-i18n="mobile_note" style="position:fixed;bottom:6px;left:0;right:0;text-align:center;font-size:11px;line-height:1.5;font-family:monospace;color:#fff;background:#000;padding:6px 8px;z-index:99999;letter-spacing:0;pointer-events:none">نسخة الجوال ماهي جاهزه ادخل من البيسي او لف الشاشه</div>
<script>
(async () => {
await myprofile();
const wanted = new URLSearchParams(location.search).get('username');
const who = wanted || curUser;
if (who) await fp(who);
else document.getElementById('content').innerHTML = '<div class="empty">سجّل دخولك عشان تشوف ملفك</div>';
document.addEventListener('langchange', () => { if (who) fp(who); });
})().catch(() => {
const c = document.getElementById('content');
if (c) c.innerHTML = '<div class="empty">تعذر تحميل الملف</div>';
});
</script>
</body>
</html>
rules.html
<!DOCTYPE html>
<html lang="ar">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>وِصَال</title>
<link rel="icon" type="image/png" sizes="32x32" href="/media/favicon.png">
<link rel="icon" type="image/png" sizes="16x16" href="/media/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/media/favicon-180.png">
<link rel="icon" type="image/png" sizes="192x192" href="/media/favicon-192.png">
<meta name="theme-color" content="#030305">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body {
background:#000; color:rgba(255,255,255,0.7);
font-family:monospace; font-size:13px; line-height:1.6;
padding:40px; max-width:700px; min-height:100vh; margin:0 auto;
display:flex; flex-direction:column; align-items:center; justify-content:center; }
h1 { font-family:monospace; color:#fff; font-size:28px; margin-bottom:20px; letter-spacing:2px; }
h2 { font-family:monospace; color:#fff; font-size:20px; margin:24px 0 8px; }
p { margin-bottom:10px; text-align:center; color:#fff; }
</style>
<link href="/media/lang.css" rel="stylesheet">
</head>
<body class="legal mono">
<p>شغال عليه في النسخه العامه بيكون جاهز يا انه بيكون شي تاريخي</p>
</body>
</html>
users.html
<!DOCTYPE html>
<html lang="ar">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>وِصَال</title>
<link rel="icon" type="image/png" sizes="32x32" href="/media/favicon.png">
<link rel="icon" type="image/png" sizes="16x16" href="/media/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/media/favicon-180.png">
<link rel="icon" type="image/png" sizes="192x192" href="/media/favicon-192.png">
<meta name="theme-color" content="#030305">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
<link href="/media/pixelart/pixelart-icons-font.css" rel="stylesheet">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body {
background:#030305; color:#fff; font-family:'VT323',monospace;
height:100vh; display:flex; flex-direction:column;
align-items:center; justify-content:center; gap:20px; }
.row { display:flex; gap:20px; flex-wrap:wrap; justify-content:center; }
.gcard {
background:rgba(255,255,255,0.04);
border:1px solid rgba(255,255,255,0.08);
border-radius:10px;
box-shadow:0 4px 20px rgba(0,0,0,0.3);
overflow:hidden;
width:320px; }
.ghead {
padding:12px 16px 8px;
display:flex; justify-content:space-between; align-items:baseline; }
.ghead .gl { font-size:13px; text-transform:uppercase; letter-spacing:2px; opacity:0.6; }
.ghead .gv { font-size:22px; }
canvas { width:100%; height:130px; display:block; }
.back { text-align:center; }
.back a { color:rgba(255,255,255,0.4); font-size:16px; text-decoration:none; }
.back a:hover { color:#fff; }
.src-link { position:fixed; bottom:16px; left:16px; z-index:100000; }
.src-link a { color:rgba(255,255,255,0.35); font-size:16px; text-decoration:none; }
.src-link a:hover { color:rgba(255,255,255,0.7); }
@media (max-width:1599px) { .src-link a { font-size:13px; } .src-link { bottom:24px; } }
@media (max-width:1200px) { .src-link a { font-size:11px; } .src-link { bottom:18px; } }
@media (max-width:900px) { .src-link a { font-size:10px; } .src-link { bottom:12px; left:8px; } }
@media (max-width:700px) { .src-link a { font-size:9px; } .src-link { bottom:8px; } }
</style>
<link href="/media/lang.css" rel="stylesheet">
</head>
<body class="pixel">
<div class="src-link"><a href="/source">ذابحه الثقه موري كوده قدام... محد</a></div>
<div class="row">
<div class="gcard">
<div class="ghead">
<span class="gl" style="color:#fff;">الإجمالي</span>
<span class="gv" style="color:#fff;" id="tv">-</span>
</div>
<canvas id="gcT" width="320" height="130"></canvas>
</div>
<div class="gcard">
<div class="ghead">
<span class="gl" style="color:#ffd700;">نشِط ٢٤ س</span>
<span class="gv" style="color:#ffd700;" id="av">-</span>
</div>
<canvas id="gcA" width="320" height="130"></canvas>
</div>
</div>
<div id="tip" style="display:none;position:fixed;z-index:99999;background:rgba(0,0,0,0.85);border:1px solid rgba(255,255,255,0.1);padding:4px 10px;border-radius:4px;font-size:12px;pointer-events:none;font-family:'VT323',monospace;"></div>
<div class="back"><a href="/madar">رجوع</a></div>
<script src="/media/app.js"></script>
<script>
function drawGraph(canvas, endVal, color) {
let c = document.getElementById(canvas);
let ctx = c.getContext('2d');
let w = c.width, h = c.height;
ctx.clearRect(0, 0, w, h);
let start = new Date('2026-05-22');
let now = new Date();
let days = Math.floor((now - start) / 86400000);
if (days < 1) days = 1;
let pad = { t: 8, r: 8, b: 18, l: 8 };
let gw = w - pad.l - pad.r;
let gh = h - pad.t - pad.b;
let maxV = Math.max(endVal, 1);
let pts = [];
for (let d = 0; d <= days; d++) {
let t = d / days;
let v = Math.round(endVal * t);
pts.push(v); }
ctx.beginPath();
for (let d = 0; d < pts.length; d++) {
let x = pad.l + (d / days) * gw;
let y = pad.t + gh - (pts[d] / maxV) * gh;
if (d === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y); }
ctx.lineTo(pad.l + gw, pad.t + gh);
ctx.lineTo(pad.l, pad.t + gh);
ctx.closePath();
let g = ctx.createLinearGradient(0, pad.t, 0, pad.t + gh);
g.addColorStop(0, color + '40');
g.addColorStop(1, color + '05');
ctx.fillStyle = g;
ctx.fill();
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = 2;
for (let d = 0; d < pts.length; d++) {
let x = pad.l + (d / days) * gw;
let y = pad.t + gh - (pts[d] / maxV) * gh;
if (d === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y); }
ctx.stroke();
let lx = pad.l + gw;
let ly = pad.t + gh - (pts[pts.length-1] / maxV) * gh;
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(lx, ly, 3, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
ctx.lineWidth = 1;
let divStep = Math.max(1, Math.floor(days / 10));
for (let d = 0; d <= days; d += divStep) {
let x = pad.l + (d / days) * gw;
ctx.beginPath();
ctx.moveTo(x, pad.t);
ctx.lineTo(x, pad.t + gh);
ctx.stroke(); }
}
function setupHover(canvasId, color, endVal) {
let c = document.getElementById(canvasId);
let start = new Date('2026-05-22');
let now = new Date();
let days = Math.floor((now - start) / 86400000);
if (days < 1) days = 1;
let tip = document.getElementById('tip');
c.addEventListener('mousemove', function(e) {
let rect = c.getBoundingClientRect();
let mx = e.clientX - rect.left;
let scaleX = c.width / rect.width;
let cx = mx * scaleX;
let pad = { t: 8, r: 8, b: 18, l: 8 };
let gw = c.width - pad.l - pad.r;
let gh = c.height - pad.t - pad.b;
if (cx < pad.l || cx > pad.l + gw) { tip.style.display = 'none'; return; }
let dayIdx = Math.round(((cx - pad.l) / gw) * days);
if (dayIdx < 0) dayIdx = 0;
if (dayIdx > days) dayIdx = days;
let t = dayIdx / days;
let v = Math.round(endVal * t);
let dt = new Date(start);
dt.setDate(dt.getDate() + dayIdx);
let dateStr = ardate(dt);
tip.style.display = 'block';
tip.style.left = (e.clientX + 10) + 'px';
tip.style.top = (e.clientY - 30) + 'px';
tip.style.color = color;
tip.innerHTML = dateStr + ' — ' + v; });
c.addEventListener('mouseleave', function() {
tip.style.display = 'none'; }); }
async function loadStats() {
let r = await fetch('/api/stats');
if (!r.ok) return;
let d = await r.json();
document.getElementById('tv').textContent = d.total;
document.getElementById('av').textContent = d.active_24h;
drawGraph('gcT', d.total, '#ffffff');
drawGraph('gcA', d.active_24h, '#ffd700');
setupHover('gcT', '#ffffff', d.total);
setupHover('gcA', '#ffd700', d.active_24h); }
loadStats();
</script>
</body>
</html>
runtime.js
(() => {
const api = document.querySelector('meta[name="wisal-api"]')?.content;
if (!api) return;
window.WISAL_MEDIA_ORIGIN = document.querySelector('meta[name="wisal-media"]')?.content || api;
const native = window.fetch.bind(window);
window.fetch = (input, options) => {
const request = input instanceof Request;
const url = new URL(request ? input.url : input, location.href);
const method = (options?.method || (request ? input.method : 'GET')).toUpperCase();
if (url.origin === location.origin && (url.pathname.startsWith('/api/') || (url.pathname === '/login' && method === 'POST'))) {
const target = api + url.pathname + url.search;
return native(request ? new Request(target, input) : target, {...options, credentials: 'include'});
}
return native(input, options);
};
const phone = {'/': '/madar-phone', '/madar': '/madar-phone', '/login': '/login-phone', '/profile': '/profile-phone'};
if (/Android|iPhone|iPod|iPad|Mobile/i.test(navigator.userAgent) && phone[location.pathname]) {
location.replace(phone[location.pathname] + location.search + location.hash);
}
if (location.pathname === '/create') {
window.addEventListener('DOMContentLoaded', async () => {
try {
const response = await fetch('/api/user?what=me');
if (!(await response.json())?.username) location.replace('/login');
} catch {}
}, {once: true});
}
})();
pages.mjs
import {copyFile, mkdir, mkdtemp, readdir, readFile, rename, rm, writeFile} from 'node:fs/promises';
import {dirname, extname, join, resolve} from 'node:path';
import {brotli} from '../alqela/codec.mjs';
import {buildAlqela} from '../alqela/build.mjs';
const escape = value => value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
const routes = {'/madar': 'madar', '/madar-phone': 'madarp', '/login': 'login', '/login-phone': 'loginp', '/profile': 'profile', '/profile-phone': 'profilep', '/create': 'new', '/mlamh': 'mlamh', '/users': 'users', '/rules': 'rules', '/privacy': 'privacy'};
export async function buildPages(options = {}) {
const root = options.root || import.meta.dirname;
const project = options.project || resolve(import.meta.dirname, '..');
const frontend = join(project, 'cfrontend');
const output = join(root, 'public');
const api = new URL(options.apiOrigin || 'https://api.wisal.space');
const site = new URL(options.siteOrigin || 'https://wisal.space');
const media = new URL(options.mediaOrigin || api.origin);
for (const origin of [api, site, media]) {
if (origin.protocol !== 'https:' || origin.username || origin.password || origin.pathname !== '/' || origin.search || origin.hash) throw new Error('invalid public origin');
}
await mkdir(root, {recursive: true});
const stage = await mkdtemp(join(root, '.pages-next-'));
const records = new Map();
const put = async (path, bytes) => {
const destination = join(stage, path.replace(/^\//, ''));
await mkdir(dirname(destination), {recursive: true});
await writeFile(destination, bytes);
};
try {
const routeForStem = Object.fromEntries(Object.entries(routes).map(([path, stem]) => [stem, path]));
for (const entry of await readdir(join(frontend, 'frontendorg'), {withFileTypes: true})) {
if (!entry.isFile() || !/\.(html|js|css)$/.test(entry.name)) continue;
let bytes = await readFile(join(frontend, 'frontendorg', entry.name));
const html = extname(entry.name) === '.html';
const stem = entry.name.replace(/\.html$/, '');
if (html) bytes = Buffer.from(bytes.toString().replace('<head>', '<head><meta name="wisal-api" content="' + escape(api.origin) + '"><meta name="wisal-media" content="' + escape(media.origin) + '"><script src="/media/runtime.js"></script><script src="/_alqela/register.js" defer></script>'));
const path = html ? (routeForStem[stem] || '/' + stem) + '.html' : '/media/' + entry.name;
await put(path, bytes);
if (!html) records.set(path, bytes);
}
const runtime = await readFile(new URL('./runtime.js', import.meta.url));
await put('/media/runtime.js', runtime);
records.set('/media/runtime.js', runtime);
const copyTree = async (source, path) => {
for (const entry of await readdir(source, {withFileTypes: true})) {
if (entry.isDirectory()) await copyTree(join(source, entry.name), path + '/' + entry.name);
else if (!/\.(br|gz|zst)$/.test(entry.name)) {
const bytes = await readFile(join(source, entry.name));
await put(path + '/' + entry.name, bytes);
if (/\.(js|css)$/.test(entry.name)) records.set(path + '/' + entry.name, bytes);
}
}
};
for (const name of ['fonts', 'wbg', 'defaultavatar', 'pixelart']) await copyTree(join(frontend, name), '/media/' + name);
await copyTree(join(frontend, 'icons'), '/media');
await copyFile(join(stage, 'media', 'favicon.png'), join(stage, 'favicon.png'));
await copyFile(join(stage, 'madar.html'), join(stage, 'index.html'));
await put('/404.html', '');
let source = '<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Wisal source</title><style>body{background:#000;color:#ddd;font:13px monospace;padding:24px}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:#9cf}</style><a href="/madar">back</a>';
for (const directory of ['backend', 'cfrontend/frontendorg']) {
for (const name of (await readdir(join(project, directory))).sort()) {
if (!/\.(go|js|css|html)$/.test(name) || name.endsWith('_test.go')) continue;
source += '<h2>' + escape(name) + '</h2><pre>' + escape(await readFile(join(project, directory, name), 'utf8')) + '</pre>';
}
}
for (const name of ['runtime.js', 'pages.mjs', 'build.mjs']) source += '<h2>' + name + '</h2><pre>' + escape(await readFile(new URL('./' + name, import.meta.url), 'utf8')) + '</pre>';
await put('/source.html', source + '</html>');
await buildAlqela(records, root, {outputDirectory: stage, retainHistory: true});
const indexPath = join(stage, '_alqela', 'index.json');
const index = JSON.parse(await readFile(indexPath, 'utf8'));
const manifest = JSON.parse(await readFile(join(root, 'alqela-manifest.json'), 'utf8'));
const patches = [];
for (const [path, entry] of Object.entries(index.entries)) {
if (!entry.patch) continue;
const route = manifest[entry.patch.path];
if (!route || route.identity.size + 512 >= brotli(records.get(path)).length) {
delete entry.patch;
continue;
}
await copyFile(join(stage, route.identity.path.slice(1)), join(stage, entry.patch.path.slice(1)));
entry.patch.bytes = route.identity.size;
patches.push(entry.patch.path);
}
try {
for (const name of await readdir(join(output, '_alqela'))) {
if (/^[a-f0-9]{64}-[a-f0-9]{64}\.alqela$/.test(name) && !patches.includes('/_alqela/' + name)) {
await copyFile(join(output, '_alqela', name), join(stage, '_alqela', name));
patches.push('/_alqela/' + name);
}
}
} catch (error) { if (error.code !== 'ENOENT') throw error; }
for (const name of await readdir(join(stage, '_alqela'))) if (name.endsWith('.bin')) await rm(join(stage, '_alqela', name));
await writeFile(indexPath, JSON.stringify(index));
const redirects = [];
for (const name of ['app', 'profile', 'i18n', 'login', 'compress']) redirects.push('/media/' + name + ' /media/' + name + '.js 200');
redirects.push('/sitemap.xml ' + api.origin + '/sitemap.xml 302');
await put('/_redirects', redirects.join('\n') + '\n');
const csp = "default-src 'self'; base-uri 'none'; form-action 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: " + media.origin + "; media-src 'self' blob: " + media.origin + "; connect-src 'self' " + api.origin + " https://fonts.googleapis.com https://fonts.gstatic.com; object-src 'none'; frame-src 'none'; worker-src 'self' blob:; frame-ancestors 'none'";
let headers = '/*\n X-Content-Type-Options: nosniff\n X-Frame-Options: DENY\n Referrer-Policy: no-referrer\n Cross-Origin-Opener-Policy: same-origin\n Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()\n Content-Security-Policy: ' + csp + '\n Cache-Control: public, no-cache\n';
for (const name of ['fonts', 'wbg', 'defaultavatar']) headers += '/media/' + name + '/*\n Cache-Control: public, max-age=3600, must-revalidate\n';
headers += '/alqela-sw.js\n Content-Type: text/javascript; charset=utf-8\n Service-Worker-Allowed: /\n/_alqela/decode.mjs\n Content-Type: text/javascript; charset=utf-8\n';
for (const path of patches) headers += path + '\n Content-Type: application/vnd.alqela\n Cache-Control: public, max-age=31536000, immutable\n';
await put('/_headers', headers);
await put('/robots.txt', 'User-agent: *\nDisallow: /create\nDisallow: /mlamh\nSitemap: ' + site.origin + '/sitemap.xml\n');
const previous = join(root, '.pages-previous');
try { await rename(output, previous); } catch (error) { if (error.code !== 'ENOENT') throw error; }
await rename(stage, output);
await rm(previous, {recursive: true, force: true});
const report = {runtime: 'pages-static-go-api', apiOrigin: api.origin, siteOrigin: site.origin, mediaOrigin: media.origin, codeAssets: records.size, retainedPatches: patches.length};
await writeFile(join(root, 'pages-report.json'), JSON.stringify(report, null, 2) + '\n');
return report;
} catch (error) {
await rm(stage, {recursive: true, force: true});
throw error;
}
}
build.mjs
import {buildPages} from './pages.mjs';
await buildPages({
apiOrigin: process.env.WISAL_API_ORIGIN,
siteOrigin: process.env.WISAL_SITE_ORIGIN,
mediaOrigin: process.env.WISAL_MEDIA_ORIGIN
});