1 # Copyright (C) all contributors <meta@public-inbox.org>
2 # License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
4 # represents an POP3D (currently a singleton)
5 package PublicInbox::POP3D;
7 use parent qw(PublicInbox::Lock);
8 use DBI qw(:sql_types); # SQL_BLOB
10 use File::Temp 0.19 (); # 0.19 for ->newdir
11 use PublicInbox::Config;
12 use PublicInbox::POP3;
13 use PublicInbox::Syscall;
14 use File::Temp 0.19 (); # 0.19 for ->newdir
15 use Fcntl qw(F_SETLK F_UNLCK F_WRLCK SEEK_SET);
17 if ($^O eq 'linux' || $^O eq 'freebsd') {
20 my $sz = $Config::Config{lseeksize};
22 if ($sz == 8 && eval('length(pack("q", 1)) == 8')) { $off_t = 'q' }
23 elsif ($sz == 4) { $off_t = 'l' }
24 else { warn "sizeof(off_t)=$sz requires File::FcntlLock\n" }
26 if (defined($off_t)) {
28 @FLOCK = ("ss\@8$off_t$off_t\@32",
29 qw(l_type l_whence l_start l_len));
30 } elsif ($^O eq 'freebsd') {
31 @FLOCK = ("${off_t}${off_t}lss\@256",
32 qw(l_start l_len l_pid l_type l_whence));
36 @FLOCK or eval { require File::FcntlLock } or
37 die "File::FcntlLock required for POP3 on $^O: $@\n";
40 my ($cls, $pi_cfg) = @_;
41 $pi_cfg //= PublicInbox::Config->new;
42 my $d = $pi_cfg->{'publicinbox.pop3state'} //
43 die "publicinbox.pop3state undefined\n";
46 File::Path::make_path($d, { mode => 0700 });
47 PublicInbox::Syscall::nodatacow_dir($d);
53 lock_path => "$d/db.lock", # PublicInbox::Lock to protect SQLite
54 # interprocess lock is the $pop3state/txn.locks file
55 # txn_locks => {}, # intraworker locks
56 # accept_tls => { SSL_server => 1, ..., SSL_reuse_ctx => ... }
60 sub refresh_groups { # PublicInbox::Daemon callback
61 my ($self, $sig) = @_;
62 # TODO share pi_cfg with nntpd/imapd inside -netd
63 my $new = PublicInbox::Config->new;
64 my $old = $self->{pi_cfg};
65 my $s = 'publicinbox.pop3state';
66 $new->{$s} //= $old->{$s};
67 if ($new->{$s} ne $old->{$s}) {
69 $s changed: `$old->{$s}' => `$new->{$s}', config reload ignored
72 $self->{pi_cfg} = $new;
77 sub create_state_tables ($$) {
78 my ($self, $dbh) = @_;
80 $dbh->do(<<''); # map publicinbox.<name>.newsgroup to integers
81 CREATE TABLE IF NOT EXISTS newsgroups (
82 newsgroup_id INTEGER PRIMARY KEY NOT NULL,
83 newsgroup VARBINARY NOT NULL,
86 # the $NEWSGROUP_NAME.$SLICE_INDEX is part of the POP3 username;
87 # POP3 has no concept of folders/mailboxes like IMAP/JMAP
89 CREATE TABLE IF NOT EXISTS mailboxes (
90 mailbox_id INTEGER PRIMARY KEY NOT NULL,
91 newsgroup_id INTEGER NOT NULL REFERENCES newsgroups,
92 slice INTEGER NOT NULL, /* -1 for most recent slice */
93 UNIQUE (newsgroup_id, slice) )
95 $dbh->do(<<''); # actual users are differentiated by their UUID
96 CREATE TABLE IF NOT EXISTS users (
97 user_id INTEGER PRIMARY KEY NOT NULL,
98 uuid VARBINARY NOT NULL,
99 last_seen INTEGER NOT NULL, /* to expire idle accounts */
102 # we only track the highest-numbered deleted message per-UUID@mailbox
104 CREATE TABLE IF NOT EXISTS deletes (
105 txn_id INTEGER PRIMARY KEY NOT NULL, /* -1 == txn lock offset */
106 user_id INTEGER NOT NULL REFERENCES users,
107 mailbox_id INTEGER NOT NULL REFERENCES mailboxes,
108 uid_dele INTEGER NOT NULL DEFAULT -1, /* IMAP UID, NNTP article */
109 UNIQUE(user_id, mailbox_id) )
115 my $f = "$self->{pi_cfg}->{'publicinbox.pop3state'}/db.sqlite3";
118 open my $fh, '+>>', $f or Carp::croak "open($f): $!";
119 PublicInbox::Syscall::nodatacow_fh($fh);
122 my $dbh = DBI->connect("dbi:SQLite:dbname=$f",'','', {
126 sqlite_use_immediate_transaction => 1,
127 sqlite_see_if_its_a_number => 1,
129 $dbh->do('PRAGMA journal_mode = WAL') if $creat;
130 $dbh->do('PRAGMA foreign_keys = ON'); # don't forget this
132 # ensure the interprocess fcntl lock file exists
133 $f = "$self->{pi_cfg}->{'publicinbox.pop3state'}/txn.locks";
134 open my $fh, '+>>', $f or Carp::croak("open($f): $!");
135 $self->{txn_fh} = $fh;
137 create_state_tables($self, $dbh);
142 my ($self, %lk) = @_;
143 $lk{l_pid} = 0; # needed for *BSD
144 $lk{l_whence} = SEEK_SET;
146 fcntl($self->{txn_fh}, F_SETLK,
147 pack($FLOCK[0], @lk{@FLOCK[1..$#FLOCK]}));
149 my $fs = File::FcntlLock->new(%lk);
150 $fs->lock($self->{txn_fh}, F_SETLK);
155 my ($self, $pop3) = @_; # pop3 - PublicInbox::POP3 client object
156 my $lk = $self->lock_for_scope; # lock the SQLite DB, only
157 my $dbh = $self->{-state_dbh} //= state_dbh_new($self);
158 my ($user_id, $ngid, $mbid, $txn_id);
159 my $uuid = delete $pop3->{uuid};
162 # 1. make sure the user exists, update `last_seen'
163 my $sth = $dbh->prepare_cached(<<'');
164 INSERT OR IGNORE INTO users (uuid, last_seen) VALUES (?,?)
166 $sth->bind_param(1, $uuid, SQL_BLOB);
167 $sth->bind_param(2, time);
168 if ($sth->execute == 0) { # existing user
169 $sth = $dbh->prepare_cached(<<'', undef, 1);
170 SELECT user_id FROM users WHERE uuid = ?
172 $sth->bind_param(1, $uuid, SQL_BLOB);
174 $user_id = $sth->fetchrow_array //
175 die 'BUG: user '.unpack('H*', $uuid).' not found';
176 $sth = $dbh->prepare_cached(<<'');
177 UPDATE users SET last_seen = ? WHERE user_id = ?
179 $sth->execute(time, $user_id);
181 $user_id = $dbh->last_insert_id(undef, undef,
185 # 2. make sure the newsgroup has an integer ID
186 $sth = $dbh->prepare_cached(<<'');
187 INSERT OR IGNORE INTO newsgroups (newsgroup) VALUES (?)
189 my $ng = $pop3->{ibx}->{newsgroup};
190 $sth->bind_param(1, $ng, SQL_BLOB);
191 if ($sth->execute == 0) {
192 $sth = $dbh->prepare_cached(<<'', undef, 1);
193 SELECT newsgroup_id FROM newsgroups WHERE newsgroup = ?
195 $sth->bind_param(1, $ng, SQL_BLOB);
197 $ngid = $sth->fetchrow_array // die "BUG: `$ng' not found";
199 $ngid = $dbh->last_insert_id(undef, undef,
200 'newsgroups', 'newsgroup_id');
203 # 3. ensure the mailbox exists
204 $sth = $dbh->prepare_cached(<<'');
205 INSERT OR IGNORE INTO mailboxes (newsgroup_id, slice) VALUES (?,?)
207 if ($sth->execute($ngid, $pop3->{slice}) == 0) {
208 $sth = $dbh->prepare_cached(<<'', undef, 1);
209 SELECT mailbox_id FROM mailboxes WHERE newsgroup_id = ? AND slice = ?
211 $sth->execute($ngid, $pop3->{slice});
212 $mbid = $sth->fetchrow_array //
213 die "BUG: mailbox_id for $ng.$pop3->{slice} not found";
215 $mbid = $dbh->last_insert_id(undef, undef,
216 'mailboxes', 'mailbox_id');
219 # 4. ensure the (max) deletes row exists for locking
220 $sth = $dbh->prepare_cached(<<'');
221 INSERT OR IGNORE INTO deletes (user_id,mailbox_id) VALUES (?,?)
223 if ($sth->execute($user_id, $mbid) == 0) {
224 $sth = $dbh->prepare_cached(<<'', undef, 1);
225 SELECT txn_id,uid_dele FROM deletes WHERE user_id = ? AND mailbox_id = ?
227 $sth->execute($user_id, $mbid);
228 ($txn_id, $pop3->{uid_dele}) = $sth->fetchrow_array;
230 $txn_id = $dbh->last_insert_id(undef, undef,
231 'deletes', 'txn_id');
235 # see if it's locked by the same worker:
236 return if $self->{txn_locks}->{$txn_id};
238 # see if it's locked by another worker:
239 _setlk($self, l_type => F_WRLCK, l_start => $txn_id - 1, l_len => 1)
242 $pop3->{user_id} = $user_id;
243 $pop3->{txn_id} = $txn_id;
244 $self->{txn_locks}->{$txn_id} = 1;
248 my ($self, $pop3) = @_;
249 my $txn_id = delete($pop3->{txn_id}) // return;
250 delete $self->{txn_locks}->{$txn_id}; # same worker
253 _setlk($self, l_type => F_UNLCK, l_start => $txn_id - 1, l_len => 1)
254 or die "F_UNLCK: $!";