This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
.git/
|
||||||
|
data/
|
||||||
|
ufile
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: tencent-local
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build image
|
||||||
|
run: docker build --build-arg GOPROXY=https://goproxy.cn,direct -t ufile:latest .
|
||||||
|
|
||||||
|
- name: Stop old container
|
||||||
|
run: |
|
||||||
|
if docker container inspect ufile >/dev/null 2>&1; then
|
||||||
|
docker stop ufile
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Remove old container
|
||||||
|
run: |
|
||||||
|
if docker container inspect ufile >/dev/null 2>&1; then
|
||||||
|
docker rm ufile
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Start new container
|
||||||
|
run: |
|
||||||
|
mkdir -p /root/deploy/ufile/data
|
||||||
|
chown -R 10001:10001 /root/deploy/ufile/data
|
||||||
|
docker run -d \
|
||||||
|
--name ufile \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-p 6616:6616 \
|
||||||
|
-v /root/deploy/ufile/data:/data \
|
||||||
|
ufile:latest \
|
||||||
|
/usr/local/bin/ufile \
|
||||||
|
--data /data \
|
||||||
|
--address 0.0.0.0:6616
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
FROM node:24-alpine AS frontend
|
||||||
|
|
||||||
|
WORKDIR /src/frontend
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM golang:1.24-alpine AS backend
|
||||||
|
|
||||||
|
ARG GOPROXY=https://proxy.golang.org,direct
|
||||||
|
ENV GOPROXY=${GOPROXY}
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY *.go ./
|
||||||
|
COPY --from=frontend /src/frontend/dist ./frontend/dist
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /ufile .
|
||||||
|
|
||||||
|
FROM alpine:3.22
|
||||||
|
|
||||||
|
RUN addgroup -S -g 10001 ufile \
|
||||||
|
&& adduser -S -D -H -u 10001 -G ufile ufile \
|
||||||
|
&& mkdir -p /data \
|
||||||
|
&& chown ufile:ufile /data
|
||||||
|
|
||||||
|
COPY --from=backend --chown=ufile:ufile /ufile /usr/local/bin/ufile
|
||||||
|
|
||||||
|
USER ufile
|
||||||
|
WORKDIR /data
|
||||||
|
VOLUME ["/data"]
|
||||||
|
EXPOSE 6616
|
||||||
|
|
||||||
|
CMD ["/usr/local/bin/ufile", "--data", "/data", "--address", "0.0.0.0:6616"]
|
||||||
@@ -35,3 +35,5 @@
|
|||||||
- 有取件码时追加:`code=<6位取件码>`
|
- 有取件码时追加:`code=<6位取件码>`
|
||||||
|
|
||||||
预览也会消耗一次访问次数。文件过期、访问次数耗尽或所属用户被删除时,数据库记录和磁盘文件都会被清理。
|
预览也会消耗一次访问次数。文件过期、访问次数耗尽或所属用户被删除时,数据库记录和磁盘文件都会被清理。
|
||||||
|
|
||||||
|
设置取件码的文件会统一记录校验、下载和预览请求中的连续失败;缺失或错误的取件码累计 5 次后,文件会立即从数据库和磁盘删除,正确校验会清零失败次数。
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ const sessionCookie = "ufile_session"
|
|||||||
|
|
||||||
var usernamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]{3,32}$`)
|
var usernamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]{3,32}$`)
|
||||||
var pickupCodePattern = regexp.MustCompile(`^[0-9]{6}$`)
|
var pickupCodePattern = regexp.MustCompile(`^[0-9]{6}$`)
|
||||||
|
var errFileUnavailable = errors.New("文件不存在或已失效")
|
||||||
|
var errPickupCodeRequired = errors.New("请输入取件码")
|
||||||
|
var errPickupCodeWrong = errors.New("取件码错误")
|
||||||
|
var errFileCheckFailed = errors.New("校验文件失败")
|
||||||
|
|
||||||
|
const publicFileOrCodeError = "文件不存在或取件码错误"
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
@@ -37,6 +43,7 @@ type App struct {
|
|||||||
web fs.FS
|
web fs.FS
|
||||||
uploadMu sync.Mutex
|
uploadMu sync.Mutex
|
||||||
downloadMu sync.Mutex
|
downloadMu sync.Mutex
|
||||||
|
pickupMu sync.Mutex
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +95,7 @@ func (a *App) router() *gin.Engine {
|
|||||||
r.POST("/api/auth/logout", a.auth(false), a.logout)
|
r.POST("/api/auth/logout", a.auth(false), a.logout)
|
||||||
r.GET("/api/auth/me", a.auth(false), a.me)
|
r.GET("/api/auth/me", a.auth(false), a.me)
|
||||||
r.PUT("/api/auth/password", a.auth(false), a.changePassword)
|
r.PUT("/api/auth/password", a.auth(false), a.changePassword)
|
||||||
|
r.GET("/api/pickup/:hash", a.verifyPickup)
|
||||||
r.POST("/api/files", a.auth(true), a.uploadFile)
|
r.POST("/api/files", a.auth(true), a.uploadFile)
|
||||||
r.GET("/api/users", a.auth(true), a.manageUsers, a.listUsers)
|
r.GET("/api/users", a.auth(true), a.manageUsers, a.listUsers)
|
||||||
r.POST("/api/users", a.auth(true), a.manageUsers, a.createUser)
|
r.POST("/api/users", a.auth(true), a.manageUsers, a.createUser)
|
||||||
@@ -407,18 +415,9 @@ func (a *App) uploadFile(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) downloadFile(c *gin.Context) {
|
func (a *App) downloadFile(c *gin.Context) {
|
||||||
var file SharedFile
|
file, err := a.resolvePublicFile(c.Param("hash"), c.Query("code"))
|
||||||
if err := a.db.Where("hash = ? AND ready = ?", c.Param("hash"), true).First(&file).Error; err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在或已失效"})
|
writePublicFileError(c, err)
|
||||||
return
|
|
||||||
}
|
|
||||||
if file.ExpiresAt != nil && !file.ExpiresAt.After(a.now()) {
|
|
||||||
a.removeFile(file)
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在或已失效"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if file.PickupCodeHash != "" && bcrypt.CompareHashAndPassword([]byte(file.PickupCodeHash), []byte(c.Query("code"))) != nil {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "取件码错误"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handle, err := os.Open(filepath.Join(a.filesDir, file.Hash))
|
handle, err := os.Open(filepath.Join(a.filesDir, file.Hash))
|
||||||
@@ -459,6 +458,94 @@ func (a *App) downloadFile(c *gin.Context) {
|
|||||||
_, _ = io.Copy(c.Writer, handle)
|
_, _ = io.Copy(c.Writer, handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) verifyPickup(c *gin.Context) {
|
||||||
|
if _, err := a.resolvePublicFile(c.Param("hash"), c.Query("code")); err != nil {
|
||||||
|
writePublicFileError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"valid": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) resolvePublicFile(hash, pickupCode string) (SharedFile, error) {
|
||||||
|
var file SharedFile
|
||||||
|
if err := a.db.Where("hash = ? AND ready = ?", hash, true).First(&file).Error; err != nil {
|
||||||
|
return SharedFile{}, errFileUnavailable
|
||||||
|
}
|
||||||
|
if file.ExpiresAt != nil && !file.ExpiresAt.After(a.now()) {
|
||||||
|
a.removeFile(file)
|
||||||
|
return SharedFile{}, errFileUnavailable
|
||||||
|
}
|
||||||
|
if file.MaxDownloads != nil && file.DownloadCount >= *file.MaxDownloads {
|
||||||
|
a.removeFile(file)
|
||||||
|
return SharedFile{}, errFileUnavailable
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(a.filesDir, file.Hash)); err != nil {
|
||||||
|
a.db.Delete(&file)
|
||||||
|
return SharedFile{}, errFileUnavailable
|
||||||
|
}
|
||||||
|
if file.PickupCodeHash == "" {
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
|
if pickupCode == "" {
|
||||||
|
return SharedFile{}, a.recordPickupFailure(file, errPickupCodeRequired)
|
||||||
|
}
|
||||||
|
if bcrypt.CompareHashAndPassword([]byte(file.PickupCodeHash), []byte(pickupCode)) != nil {
|
||||||
|
return SharedFile{}, a.recordPickupFailure(file, errPickupCodeWrong)
|
||||||
|
}
|
||||||
|
if err := a.clearPickupFailures(file); err != nil {
|
||||||
|
return SharedFile{}, err
|
||||||
|
}
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) recordPickupFailure(file SharedFile, failure error) error {
|
||||||
|
a.pickupMu.Lock()
|
||||||
|
defer a.pickupMu.Unlock()
|
||||||
|
result := a.db.Model(&SharedFile{}).Where("id = ?", file.ID).UpdateColumn("failed_code_attempts", gorm.Expr("failed_code_attempts + 1"))
|
||||||
|
if result.Error != nil {
|
||||||
|
return errFileCheckFailed
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return errFileUnavailable
|
||||||
|
}
|
||||||
|
var latest SharedFile
|
||||||
|
if err := a.db.Select("id", "hash", "failed_code_attempts").First(&latest, file.ID).Error; err != nil {
|
||||||
|
return errFileUnavailable
|
||||||
|
}
|
||||||
|
if latest.FailedCodeAttempts < 5 {
|
||||||
|
return failure
|
||||||
|
}
|
||||||
|
a.removeFile(file)
|
||||||
|
return errFileUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) clearPickupFailures(file SharedFile) error {
|
||||||
|
if file.FailedCodeAttempts == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
a.pickupMu.Lock()
|
||||||
|
defer a.pickupMu.Unlock()
|
||||||
|
result := a.db.Model(&SharedFile{}).Where("id = ?", file.ID).UpdateColumn("failed_code_attempts", 0)
|
||||||
|
if result.Error != nil {
|
||||||
|
return errFileCheckFailed
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return errFileUnavailable
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePublicFileError(c *gin.Context, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errFileUnavailable), errors.Is(err, errPickupCodeWrong):
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": publicFileOrCodeError})
|
||||||
|
case errors.Is(err, errPickupCodeRequired):
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": errPickupCodeRequired.Error()})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": errFileCheckFailed.Error()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) consumeDownload(file SharedFile) (allowed, exhausted bool, err error) {
|
func (a *App) consumeDownload(file SharedFile) (allowed, exhausted bool, err error) {
|
||||||
a.downloadMu.Lock()
|
a.downloadMu.Lock()
|
||||||
defer a.downloadMu.Unlock()
|
defer a.downloadMu.Unlock()
|
||||||
|
|||||||
+59
-3
@@ -132,9 +132,25 @@ func TestPickupCodePreviewConsumesFinalAccessAndDeletesFile(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
denied := request(t, router, http.MethodGet, "/uf/"+file.Hash, nil, "", nil)
|
missingCode := request(t, router, http.MethodGet, "/api/pickup/"+file.Hash, nil, "", nil)
|
||||||
if denied.Code != http.StatusForbidden {
|
if missingCode.Code != http.StatusForbidden || !strings.Contains(missingCode.Body.String(), "请输入取件码") {
|
||||||
t.Fatalf("missing code status = %d", denied.Code)
|
t.Fatalf("missing code status = %d, body = %s", missingCode.Code, missingCode.Body.String())
|
||||||
|
}
|
||||||
|
wrongCode := request(t, router, http.MethodGet, "/api/pickup/"+file.Hash+"?code=654321", nil, "", nil)
|
||||||
|
if wrongCode.Code != http.StatusNotFound || !strings.Contains(wrongCode.Body.String(), publicFileOrCodeError) {
|
||||||
|
t.Fatalf("wrong code status = %d, body = %s", wrongCode.Code, wrongCode.Body.String())
|
||||||
|
}
|
||||||
|
missingFile := request(t, router, http.MethodGet, "/api/pickup/notfound?code=654321", nil, "", nil)
|
||||||
|
if missingFile.Code != wrongCode.Code || missingFile.Body.String() != wrongCode.Body.String() {
|
||||||
|
t.Fatalf("missing file and wrong code responses differ: missing = %d %s, wrong = %d %s", missingFile.Code, missingFile.Body.String(), wrongCode.Code, wrongCode.Body.String())
|
||||||
|
}
|
||||||
|
verified := request(t, router, http.MethodGet, "/api/pickup/"+file.Hash+"?code=123456", nil, "", nil)
|
||||||
|
if verified.Code != http.StatusOK {
|
||||||
|
t.Fatalf("verify status = %d, body = %s", verified.Code, verified.Body.String())
|
||||||
|
}
|
||||||
|
var stored SharedFile
|
||||||
|
if err := app.db.Where("hash = ?", file.Hash).First(&stored).Error; err != nil || stored.DownloadCount != 0 || stored.FailedCodeAttempts != 0 {
|
||||||
|
t.Fatalf("verification state: downloads = %d, code failures = %d, err = %v", stored.DownloadCount, stored.FailedCodeAttempts, err)
|
||||||
}
|
}
|
||||||
preview := request(t, router, http.MethodGet, "/uf/"+file.Hash+"?preview=true&code=123456", nil, "", nil)
|
preview := request(t, router, http.MethodGet, "/uf/"+file.Hash+"?preview=true&code=123456", nil, "", nil)
|
||||||
if preview.Code != http.StatusOK || preview.Body.String() != "hello" {
|
if preview.Code != http.StatusOK || preview.Body.String() != "hello" {
|
||||||
@@ -157,6 +173,46 @@ func TestPickupCodePreviewConsumesFinalAccessAndDeletesFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFiveConsecutivePickupCodeFailuresDeleteFile(t *testing.T) {
|
||||||
|
app, _ := testApp(t)
|
||||||
|
codeHash, _ := bcrypt.GenerateFromPassword([]byte("123456"), bcrypt.DefaultCost)
|
||||||
|
max := 10
|
||||||
|
file := SharedFile{Hash: "badcode5", OriginalName: "secret.txt", MimeType: "text/plain", OwnerID: 1, MaxDownloads: &max, PickupCodeHash: string(codeHash), Ready: true, Size: 6}
|
||||||
|
if err := app.db.Create(&file).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(app.filesDir, file.Hash)
|
||||||
|
if err := os.WriteFile(path, []byte("secret"), 0o640); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
router := app.router()
|
||||||
|
requests := []string{
|
||||||
|
"/api/pickup/" + file.Hash,
|
||||||
|
"/uf/" + file.Hash + "?code=000001",
|
||||||
|
"/api/pickup/" + file.Hash + "?code=000002",
|
||||||
|
"/uf/" + file.Hash + "?code=000003",
|
||||||
|
"/api/pickup/" + file.Hash + "?code=000004",
|
||||||
|
}
|
||||||
|
for index, path := range requests {
|
||||||
|
response := request(t, router, http.MethodGet, path, nil, "", nil)
|
||||||
|
want := http.StatusNotFound
|
||||||
|
if index == 0 {
|
||||||
|
want = http.StatusForbidden
|
||||||
|
}
|
||||||
|
if response.Code != want {
|
||||||
|
t.Fatalf("failure %d status = %d, want %d, body = %s", index+1, response.Code, want, response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
app.db.Model(&SharedFile{}).Where("id = ?", file.ID).Count(&count)
|
||||||
|
if count != 0 {
|
||||||
|
t.Fatal("file record remains after five code failures")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("file remains on disk after five code failures")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFiniteDownloadCountDeletesAfterMoreThanOneAccess(t *testing.T) {
|
func TestFiniteDownloadCountDeletesAfterMoreThanOneAccess(t *testing.T) {
|
||||||
app, _ := testApp(t)
|
app, _ := testApp(t)
|
||||||
max := 3
|
max := 3
|
||||||
|
|||||||
+18
-7
@@ -31,13 +31,23 @@ function Login() {
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [pickup, setPickup] = useState({ hash: '', code: '' })
|
const [pickup, setPickup] = useState({ hash: '', code: '' })
|
||||||
const retrieve = (preview = false) => {
|
const [pickupError, setPickupError] = useState('')
|
||||||
|
const [pickupBusy, setPickupBusy] = useState(false)
|
||||||
|
const retrieve = async () => {
|
||||||
const hash = pickup.hash.trim()
|
const hash = pickup.hash.trim()
|
||||||
if (!hash) return
|
setPickupError('')
|
||||||
|
if (hash.length !== 8) return setPickupError('请输入 8 位文件码')
|
||||||
|
if (pickup.code && pickup.code.length !== 6) return setPickupError('取件码必须是 6 位数字')
|
||||||
const query = new URLSearchParams()
|
const query = new URLSearchParams()
|
||||||
if (pickup.code) query.set('code', pickup.code)
|
if (pickup.code) query.set('code', pickup.code)
|
||||||
if (preview) query.set('preview', 'true')
|
setPickupBusy(true)
|
||||||
window.open(`/uf/${encodeURIComponent(hash)}${query.size ? `?${query}` : ''}`, '_blank')
|
try {
|
||||||
|
await api(`/api/pickup/${encodeURIComponent(hash)}${query.size ? `?${query}` : ''}`)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = `/uf/${encodeURIComponent(hash)}${query.size ? `?${query}` : ''}`
|
||||||
|
document.body.appendChild(link); link.click(); link.remove()
|
||||||
|
} catch (error) { setPickupError(error.message) }
|
||||||
|
finally { setPickupBusy(false) }
|
||||||
}
|
}
|
||||||
const submit = async (event) => {
|
const submit = async (event) => {
|
||||||
event.preventDefault(); setBusy(true); setError('')
|
event.preventDefault(); setBusy(true); setError('')
|
||||||
@@ -54,10 +64,11 @@ function Login() {
|
|||||||
<div className="card mt-10 max-w-lg p-5">
|
<div className="card mt-10 max-w-lg p-5">
|
||||||
<div className="mb-3 flex items-center gap-2 text-sm font-semibold"><ArrowDownToLine size={17} className="text-leaf" />收到分享?直接取件</div>
|
<div className="mb-3 flex items-center gap-2 text-sm font-semibold"><ArrowDownToLine size={17} className="text-leaf" />收到分享?直接取件</div>
|
||||||
<div className="grid gap-2 sm:grid-cols-[1fr_130px_auto]">
|
<div className="grid gap-2 sm:grid-cols-[1fr_130px_auto]">
|
||||||
<input className="field" maxLength={8} placeholder="8 位文件码" value={pickup.hash} onChange={(e) => setPickup({ ...pickup, hash: e.target.value })} />
|
<input className="field" maxLength={8} placeholder="8 位文件码" value={pickup.hash} onChange={(e) => { setPickup({ ...pickup, hash: e.target.value }); setPickupError('') }} />
|
||||||
<input className="field" inputMode="numeric" maxLength={6} placeholder="取件码(可选)" value={pickup.code} onChange={(e) => setPickup({ ...pickup, code: e.target.value.replace(/\D/g, '') })} />
|
<input className="field" inputMode="numeric" maxLength={6} placeholder="取件码(可选)" value={pickup.code} onChange={(e) => { setPickup({ ...pickup, code: e.target.value.replace(/\D/g, '') }); setPickupError('') }} />
|
||||||
<button className="btn-secondary" onClick={() => retrieve(false)}>下载</button>
|
<button className="btn-secondary" disabled={pickupBusy} onClick={retrieve}>{pickupBusy ? <LoaderCircle className="animate-spin" size={17} /> : '下载'}</button>
|
||||||
</div>
|
</div>
|
||||||
|
{pickupError && <p className="mt-2 text-sm text-red-600">{pickupError}</p>}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="mx-auto w-full max-w-md py-8 lg:justify-self-end">
|
<section className="mx-auto w-full max-w-md py-8 lg:justify-self-end">
|
||||||
|
|||||||
@@ -23,18 +23,19 @@ type Session struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SharedFile struct {
|
type SharedFile struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
Hash string `gorm:"uniqueIndex;size:8;not null" json:"hash"`
|
Hash string `gorm:"uniqueIndex;size:8;not null" json:"hash"`
|
||||||
OriginalName string `gorm:"not null" json:"name"`
|
OriginalName string `gorm:"not null" json:"name"`
|
||||||
MimeType string `gorm:"not null" json:"mimeType"`
|
MimeType string `gorm:"not null" json:"mimeType"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
OwnerID uint `gorm:"index;not null" json:"ownerId"`
|
OwnerID uint `gorm:"index;not null" json:"ownerId"`
|
||||||
ExpiresAt *time.Time `gorm:"index" json:"expiresAt"`
|
ExpiresAt *time.Time `gorm:"index" json:"expiresAt"`
|
||||||
MaxDownloads *int `json:"maxDownloads"`
|
MaxDownloads *int `json:"maxDownloads"`
|
||||||
DownloadCount int `gorm:"not null;default:0" json:"downloadCount"`
|
DownloadCount int `gorm:"not null;default:0" json:"downloadCount"`
|
||||||
PickupCodeHash string `json:"-"`
|
PickupCodeHash string `json:"-"`
|
||||||
Ready bool `gorm:"index;not null;default:false" json:"-"`
|
FailedCodeAttempts int `gorm:"not null;default:0" json:"-"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
Ready bool `gorm:"index;not null;default:false" json:"-"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f SharedFile) HasPickupCode() bool { return f.PickupCodeHash != "" }
|
func (f SharedFile) HasPickupCode() bool { return f.PickupCodeHash != "" }
|
||||||
|
|||||||
Reference in New Issue
Block a user